vatche.thorossian commited on
Commit
bec1346
·
1 Parent(s): 4685df2

added buglens ui sample

Browse files
buglens-ui/BugLens.html ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>BugLens</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <style>
11
+ /* ───────────────────── tokens ───────────────────── */
12
+ .bl-app[data-theme="dark"]{
13
+ --bg:#0b0c0f; --surface:#15171d; --surface-2:#1c1f27; --surface-3:#23262f;
14
+ --border:#2a2e38; --border-soft:#1f222a;
15
+ --text:#edeff3; --text-dim:#9aa1ae; --text-faint:#646b78;
16
+ --shadow:0 18px 48px rgba(0,0,0,.45);
17
+ }
18
+ .bl-app[data-theme="light"]{
19
+ --bg:#eef0f4; --surface:#ffffff; --surface-2:#f6f7f9; --surface-3:#eef0f4;
20
+ --border:#e1e4ea; --border-soft:#eceef3;
21
+ --text:#171c25; --text-dim:#586072; --text-faint:#929aa7;
22
+ --shadow:0 14px 40px rgba(20,30,60,.12);
23
+ }
24
+ .bl-app{
25
+ --sev-critical:#f5544f; --sev-high:#ff9233; --sev-medium:#e0b020; --sev-low:#4c8dff;
26
+ --r:14px;
27
+ --pad:20px; --gap:16px;
28
+ }
29
+ .bl-app[data-density="compact"]{ --pad:15px; --gap:11px; }
30
+ .bl-app[data-density="comfy"]{ --pad:26px; --gap:22px; }
31
+
32
+ *{box-sizing:border-box}
33
+ html,body{margin:0;height:100%}
34
+ body{background:var(--bg-fallback,#0b0c0f);}
35
+ .bl-app{
36
+ font-family:'Space Grotesk',system-ui,sans-serif;
37
+ background:var(--bg); color:var(--text);
38
+ min-height:100vh; display:flex; flex-direction:column;
39
+ -webkit-font-smoothing:antialiased;
40
+ }
41
+ .bl-app ::selection{background:color-mix(in oklab,var(--accent) 35%,transparent)}
42
+
43
+ /* ───────────────────── header ───────────────────── */
44
+ .bl-top{
45
+ flex:0 0 auto; height:58px; display:flex; align-items:center; gap:22px;
46
+ padding:0 24px; border-bottom:1px solid var(--border);
47
+ background:color-mix(in oklab,var(--surface) 70%,transparent);
48
+ backdrop-filter:blur(12px); position:sticky; top:0; z-index:40;
49
+ }
50
+ .bl-top-brand{background:none;border:0;padding:0;cursor:pointer;display:flex}
51
+ .bl-word{display:flex;align-items:center;gap:9px;font-size:18px;font-weight:500;color:var(--text);letter-spacing:-.01em}
52
+ .bl-word b{font-weight:700}
53
+ .bl-top-tag{font-size:12.5px;color:var(--text-dim);font-family:'JetBrains Mono',monospace;letter-spacing:-.01em}
54
+ .bl-top-right{margin-left:auto;display:flex;align-items:center;gap:12px}
55
+ .bl-model-chip{display:inline-flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace;
56
+ font-size:11px;color:var(--text-dim);border:1px solid var(--border);border-radius:999px;padding:6px 11px;background:var(--surface)}
57
+ .bl-live{width:7px;height:7px;border-radius:50%;background:#37c99e;box-shadow:0 0 0 3px color-mix(in oklab,#37c99e 22%,transparent)}
58
+
59
+ .bl-stage-wrap{flex:1;overflow-y:auto;min-height:0}
60
+
61
+ /* ───────────────────── idle / hero ───────────────────── */
62
+ .bl-idle{max-width:1180px;margin:0 auto;padding:54px 24px 80px}
63
+ .bl-hero{text-align:center;max-width:680px;margin:0 auto 56px}
64
+ .bl-hero-lens{display:inline-flex;padding:18px;border-radius:50%;
65
+ background:radial-gradient(circle at 50% 40%,color-mix(in oklab,var(--accent) 18%,transparent),transparent 70%);
66
+ margin-bottom:22px}
67
+ .bl-hero-h{font-size:40px;line-height:1.08;font-weight:600;letter-spacing:-.025em;margin:0 0 16px;text-wrap:balance}
68
+ .bl-hero-sub{font-size:16px;line-height:1.55;color:var(--text-dim);margin:0 auto 34px;max-width:560px;text-wrap:pretty}
69
+ .bl-hero-sub b{color:var(--text);font-weight:600}
70
+
71
+ .bl-drop{
72
+ border:1.5px dashed var(--border); border-radius:18px; background:var(--surface);
73
+ padding:34px; cursor:pointer; transition:.18s; max-width:460px; margin:0 auto;
74
+ }
75
+ .bl-drop:hover{border-color:var(--accent);background:color-mix(in oklab,var(--accent) 5%,var(--surface));transform:translateY(-1px)}
76
+ .bl-drop-ic{font-size:26px;color:var(--accent);margin-bottom:8px}
77
+ .bl-drop-t{font-size:16px;font-weight:600}
78
+ .bl-drop-s{font-size:12px;color:var(--text-faint);margin-top:5px;font-family:'JetBrains Mono',monospace}
79
+
80
+ /* ───────────────────── gallery ───────────────────── */
81
+ .bl-gallery-h{display:flex;align-items:baseline;justify-content:space-between;
82
+ font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);
83
+ padding-bottom:14px;margin-bottom:20px;border-bottom:1px solid var(--border)}
84
+ .bl-gallery-hint{font-family:'JetBrains Mono',monospace;font-weight:500;letter-spacing:0;text-transform:none}
85
+ .bl-gallery-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
86
+ .bl-ex{text-align:left;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
87
+ padding:0;overflow:hidden;cursor:pointer;transition:.18s;display:flex;flex-direction:column}
88
+ .bl-ex:hover{border-color:color-mix(in oklab,var(--accent) 55%,var(--border));transform:translateY(-2px);box-shadow:var(--shadow)}
89
+ .bl-ex-thumb{position:relative;aspect-ratio:16/9;overflow:hidden;border-bottom:1px solid var(--border)}
90
+ .bl-ex-thumb .bl-sev{position:absolute;top:10px;right:10px;z-index:3}
91
+ .bl-ex-scrim{position:absolute;inset:0;z-index:2;
92
+ background:linear-gradient(to top,color-mix(in oklab,var(--surface) 30%,transparent),transparent 38%);pointer-events:none}
93
+ .bl-ex-meta{padding:14px 16px 16px}
94
+ .bl-ex-app{font-family:'JetBrains Mono',monospace;font-size:10.5px;color:var(--accent);font-weight:600;text-transform:uppercase;letter-spacing:.06em}
95
+ .bl-ex-title{font-size:15px;font-weight:600;margin:5px 0 4px;letter-spacing:-.01em}
96
+ .bl-ex-blurb{font-size:12.5px;color:var(--text-dim);line-height:1.45}
97
+
98
+ /* ───────────────────── loading ───────────────────── */
99
+ .bl-loading{max-width:980px;margin:0 auto;padding:60px 24px;display:grid;
100
+ grid-template-columns:1.3fr 1fr;gap:36px;align-items:center;min-height:60vh}
101
+ .bl-loading-shot{position:relative;aspect-ratio:16/10;border-radius:var(--r);overflow:hidden;
102
+ border:1px solid var(--border);box-shadow:var(--shadow)}
103
+ .bl-scan{position:absolute;left:0;right:0;height:42%;z-index:5;pointer-events:none;
104
+ background:linear-gradient(to bottom,transparent,color-mix(in oklab,var(--accent) 26%,transparent),transparent);
105
+ animation:scan 1.8s ease-in-out infinite}
106
+ @keyframes scan{0%{top:-42%}100%{top:100%}}
107
+ .bl-loading-side{}
108
+ .bl-load-file{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-faint);margin-bottom:22px}
109
+ .bl-stages{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}
110
+ .bl-stages li{display:flex;gap:13px;align-items:center;padding:11px 0;opacity:.4;transition:.3s}
111
+ .bl-stages li[data-state="active"]{opacity:1}
112
+ .bl-stages li[data-state="done"]{opacity:.85}
113
+ .bl-stage-dot{width:24px;height:24px;flex:0 0 24px;border-radius:50%;border:1.5px solid var(--border);
114
+ display:flex;align-items:center;justify-content:center;font-size:12px;color:#37c99e}
115
+ .bl-stages li[data-state="active"] .bl-stage-dot{border-color:var(--accent)}
116
+ .bl-stages li[data-state="done"] .bl-stage-dot{border-color:#37c99e;background:color-mix(in oklab,#37c99e 14%,transparent)}
117
+ .bl-stage-l{font-size:14px;font-weight:600}
118
+ .bl-stage-sub{font-size:11px;color:var(--text-faint);font-family:'JetBrains Mono',monospace;margin-top:2px}
119
+ .bl-mini-spin,.bl-stage-dot .bl-mini-spin{width:13px;height:13px;border-radius:50%;
120
+ border:2px solid color-mix(in oklab,var(--accent) 30%,transparent);border-top-color:var(--accent);
121
+ animation:mkspin .8s linear infinite}
122
+ @keyframes mkspin{to{transform:rotate(360deg)}}
123
+
124
+ /* ───────────────────── results ───────────────────── */
125
+ .bl-results{max-width:1320px;margin:0 auto;padding:28px 24px 60px;
126
+ display:grid;grid-template-columns:360px 1fr;gap:28px;align-items:start}
127
+ .bl-lens{position:sticky;top:86px;display:flex;flex-direction:column;gap:14px}
128
+ .bl-lens-shot{position:relative;aspect-ratio:16/11;border-radius:var(--r);overflow:hidden;
129
+ border:1px solid var(--border);box-shadow:var(--shadow)}
130
+ .bl-lens-file{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text-faint);
131
+ margin-top:-4px;text-align:center}
132
+ .bl-vision{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px 17px}
133
+ .bl-vision-h{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;color:var(--text);margin-bottom:12px}
134
+ .bl-vision-ic{color:var(--accent);font-size:14px}
135
+ .bl-vision ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
136
+ .bl-vision li{font-size:12.5px;line-height:1.5;color:var(--text-dim);padding-left:15px;position:relative}
137
+ .bl-vision li::before{content:"";position:absolute;left:0;top:7px;width:5px;height:5px;border-radius:50%;background:var(--accent);opacity:.7}
138
+
139
+ /* cards container */
140
+ .bl-output{min-width:0}
141
+ .bl-cards{display:grid;gap:var(--gap)}
142
+ .bl-cards-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
143
+ .bl-cards-column{grid-template-columns:1fr}
144
+
145
+ .bl-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
146
+ overflow:hidden;display:flex;flex-direction:column;--card-accent:var(--text-dim)}
147
+ .bl-card-h{display:flex;align-items:flex-start;gap:12px;padding:var(--pad);padding-bottom:13px;
148
+ border-bottom:1px solid var(--border-soft)}
149
+ .bl-card-ic{width:34px;height:34px;flex:0 0 34px;border-radius:9px;display:flex;align-items:center;justify-content:center;
150
+ font-size:16px;background:color-mix(in oklab,var(--card-accent) 14%,transparent);color:var(--card-accent)}
151
+ .bl-card-ht{flex:1;min-width:0}
152
+ .bl-kicker{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-faint);
153
+ text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px}
154
+ .bl-card-h h3{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}
155
+ .bl-card-b{padding:var(--pad);display:flex;flex-direction:column;gap:14px}
156
+
157
+ /* card kind accents */
158
+ .bl-card[data-kind="report"]{--card-accent:var(--accent)}
159
+ .bl-card[data-kind="tests"]{--card-accent:#37c99e}
160
+ .bl-card[data-kind="edges"]{--card-accent:#4c8dff}
161
+ .bl-card[data-kind="missing"]{--card-accent:var(--accent);
162
+ border-color:color-mix(in oklab,var(--accent) 45%,var(--border));
163
+ background:linear-gradient(180deg,color-mix(in oklab,var(--accent) 7%,var(--surface)),var(--surface) 120px);
164
+ box-shadow:0 0 0 1px color-mix(in oklab,var(--accent) 20%,transparent),var(--shadow)}
165
+
166
+ .bl-copy{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-dim);
167
+ background:var(--surface-2);border:1px solid var(--border);border-radius:7px;padding:6px 10px;cursor:pointer;transition:.15s;white-space:nowrap}
168
+ .bl-copy:hover{color:var(--text);border-color:var(--text-faint)}
169
+ .bl-copy.is-done{color:#37c99e;border-color:#37c99e}
170
+ .bl-progress{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);white-space:nowrap;
171
+ border:1px solid color-mix(in oklab,var(--accent) 40%,transparent);border-radius:7px;padding:6px 10px}
172
+
173
+ /* ── report card internals ── */
174
+ .bl-rep-top{display:flex;align-items:center;gap:11px;flex-wrap:wrap}
175
+ .bl-rep-why{font-size:12px;color:var(--text-dim);line-height:1.4}
176
+ .bl-summary{margin:0;font-size:13.5px;line-height:1.6;color:var(--text-dim)}
177
+ .bl-sublabel{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-faint)}
178
+ .bl-steps{margin:0;padding-left:0;list-style:none;counter-reset:s;display:flex;flex-direction:column;gap:8px}
179
+ .bl-steps li{counter-increment:s;position:relative;padding-left:28px;font-size:13px;line-height:1.45;color:var(--text)}
180
+ .bl-steps li::before{content:counter(s);position:absolute;left:0;top:0;width:19px;height:19px;border-radius:6px;
181
+ background:var(--surface-3);color:var(--text-dim);font-family:'JetBrains Mono',monospace;font-size:11px;
182
+ display:flex;align-items:center;justify-content:center;font-weight:600}
183
+ .bl-ea{display:grid;grid-template-columns:1fr 1fr;gap:11px}
184
+ .bl-ea-col{border:1px solid var(--border-soft);border-radius:10px;padding:12px;background:var(--surface-2)}
185
+ .bl-ea-lbl{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;margin-bottom:7px}
186
+ .bl-ea-exp .bl-ea-lbl{color:#37c99e}
187
+ .bl-ea-act .bl-ea-lbl{color:var(--sev-high)}
188
+ .bl-ea-col p{margin:0;font-size:12.5px;line-height:1.5;color:var(--text-dim)}
189
+ .bl-env{display:flex;flex-direction:column;border:1px solid var(--border-soft);border-radius:10px;overflow:hidden}
190
+ .bl-env-row{display:flex;justify-content:space-between;gap:14px;padding:9px 12px;font-size:12px;border-bottom:1px solid var(--border-soft)}
191
+ .bl-env-row:last-child{border-bottom:0}
192
+ .bl-env-k{color:var(--text-dim);font-weight:500}
193
+ .bl-env-v{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text);text-align:right}
194
+ .bl-env-row[data-known="0"] .bl-env-v{color:var(--sev-high)}
195
+
196
+ /* ── missing info card ── */
197
+ .bl-honest{margin:0;font-size:13px;line-height:1.55;color:var(--text-dim)}
198
+ .bl-honest b{color:var(--accent);font-weight:700}
199
+ .bl-missing{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
200
+ .bl-missing li{display:flex;gap:12px;align-items:flex-start;padding:12px 13px;border-radius:11px;
201
+ border:1px solid var(--border-soft);background:var(--surface-2);transition:.18s}
202
+ .bl-missing li.is-on{opacity:.62;background:transparent}
203
+ .bl-check{flex:0 0 20px;width:20px;height:20px;margin-top:1px;border-radius:6px;cursor:pointer;
204
+ border:1.5px solid var(--border);background:var(--surface);color:#fff;font-size:12px;font-weight:700;
205
+ display:flex;align-items:center;justify-content:center;transition:.15s}
206
+ .bl-missing li.is-on .bl-check{background:var(--accent);border-color:var(--accent)}
207
+ .bl-missing-q{font-size:13px;font-weight:600;line-height:1.4}
208
+ .bl-missing li.is-on .bl-missing-q{text-decoration:line-through;text-decoration-color:var(--text-faint)}
209
+ .bl-missing-w{font-size:12px;color:var(--text-dim);line-height:1.5;margin-top:4px}
210
+
211
+ /* ── tests card ── */
212
+ .bl-tests{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
213
+ .bl-test{border:1px solid var(--border-soft);border-radius:11px;padding:13px;background:var(--surface-2)}
214
+ .bl-test-h{display:flex;align-items:center;gap:9px;margin-bottom:10px}
215
+ .bl-test-id{font-family:'JetBrains Mono',monospace;font-size:10.5px;font-weight:600;color:#37c99e;
216
+ border:1px solid color-mix(in oklab,#37c99e 35%,transparent);border-radius:5px;padding:2px 6px}
217
+ .bl-test-title{font-size:13px;font-weight:600;line-height:1.3}
218
+ .bl-gwt{margin:0;display:flex;flex-direction:column;gap:5px}
219
+ .bl-gwt>div{display:flex;gap:9px;font-size:12.5px;line-height:1.45}
220
+ .bl-gwt dt{flex:0 0 42px;font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:600;color:var(--text-faint);text-transform:uppercase}
221
+ .bl-gwt dd{margin:0;color:var(--text-dim)}
222
+
223
+ /* ── edges card ── */
224
+ .bl-edges{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:0}
225
+ .bl-edges li{display:flex;gap:12px;align-items:flex-start;padding:12px 0;border-bottom:1px solid var(--border-soft)}
226
+ .bl-edges li:first-child{padding-top:0}
227
+ .bl-edges li:last-child{border-bottom:0;padding-bottom:0}
228
+ .bl-edge-marker{flex:0 0 7px;width:7px;height:7px;margin-top:6px;border-radius:2px;
229
+ background:#4c8dff;transform:rotate(45deg)}
230
+ .bl-edge-t{font-size:13px;font-weight:600}
231
+ .bl-edge-d{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin-top:3px}
232
+
233
+ /* ── tabs layout ── */
234
+ .bl-tabs{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:11px;padding:4px;margin-bottom:var(--gap)}
235
+ .bl-tabs button{flex:1;border:0;background:none;color:var(--text-dim);font-family:inherit;font-size:13px;font-weight:600;
236
+ padding:9px;border-radius:8px;cursor:pointer;transition:.15s}
237
+ .bl-tabs button:hover{color:var(--text)}
238
+ .bl-tabs button.is-on{background:color-mix(in oklab,var(--accent) 16%,transparent);color:var(--accent)}
239
+ .bl-tabpane .bl-card{animation:fade .25s ease}
240
+ @keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
241
+
242
+ /* ── export bar ── */
243
+ .bl-export{display:flex;align-items:center;gap:10px;margin-top:var(--gap);padding:16px 18px;
244
+ background:var(--surface);border:1px solid var(--border);border-radius:var(--r);flex-wrap:wrap}
245
+ .bl-export-l{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--text-faint);margin-right:4px}
246
+
247
+ /* ── buttons ── */
248
+ .bl-btn{font-family:inherit;font-size:13px;font-weight:600;border-radius:9px;padding:9px 15px;cursor:pointer;
249
+ display:inline-flex;align-items:center;gap:8px;transition:.15s;border:1px solid transparent}
250
+ .bl-btn-ic{font-size:13px;opacity:.85}
251
+ .bl-btn-primary{background:var(--accent);color:#1a1205;border-color:var(--accent)}
252
+ .bl-btn-primary:hover{filter:brightness(1.08)}
253
+ .bl-btn-ghost{background:var(--surface-2);color:var(--text);border-color:var(--border)}
254
+ .bl-btn-ghost:hover{border-color:var(--text-faint);background:var(--surface-3)}
255
+ .bl-btn-full{width:100%;justify-content:center}
256
+
257
+ /* ── badges ── */
258
+ .bl-sev{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;
259
+ padding:4px 9px;border-radius:999px;color:var(--sc);background:color-mix(in oklab,var(--sc) 15%,transparent);
260
+ border:1px solid color-mix(in oklab,var(--sc) 35%,transparent)}
261
+ .bl-sev i{width:6px;height:6px;border-radius:50%;background:var(--sc)}
262
+
263
+ /* ── toasts ── */
264
+ .bl-toasts{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;gap:8px;align-items:center}
265
+ .bl-toast{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:10px;
266
+ padding:11px 17px;font-size:13px;font-weight:500;box-shadow:var(--shadow);animation:toastin .25s ease}
267
+ @keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
268
+
269
+ /* responsive */
270
+ @media (max-width:980px){
271
+ .bl-results{grid-template-columns:1fr}
272
+ .bl-lens{position:static}
273
+ .bl-cards-grid{grid-template-columns:1fr}
274
+ .bl-gallery-grid{grid-template-columns:1fr}
275
+ .bl-loading{grid-template-columns:1fr}
276
+ .bl-hero-h{font-size:32px}
277
+ }
278
+ </style>
279
+ </head>
280
+ <body>
281
+ <div id="root"></div>
282
+
283
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
284
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
285
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
286
+
287
+ <script type="text/babel" src="tweaks-panel.jsx"></script>
288
+ <script type="text/babel" src="buglens-data.jsx"></script>
289
+ <script type="text/babel" src="buglens-screens.jsx"></script>
290
+ <script type="text/babel" src="buglens-components.jsx"></script>
291
+ <script type="text/babel" src="buglens-cards.jsx"></script>
292
+ <script type="text/babel" src="buglens-app.jsx"></script>
293
+ <script>
294
+ // inject the mock-screen styles once React/Babel have defined MOCK_STYLE
295
+ window.addEventListener('DOMContentLoaded', () => {
296
+ const tryInject = () => {
297
+ if (window.MOCK_STYLE && !document.getElementById('mk-style')) {
298
+ const s = document.createElement('style');
299
+ s.id = 'mk-style';
300
+ s.textContent = window.MOCK_STYLE;
301
+ document.head.appendChild(s);
302
+ } else if (!window.MOCK_STYLE) {
303
+ setTimeout(tryInject, 60);
304
+ }
305
+ };
306
+ tryInject();
307
+ });
308
+ </script>
309
+ </body>
310
+ </html>
buglens-ui/buglens-app.jsx ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // buglens-app.jsx
2
+ // Main application: idle → loading → results, with Tweaks for style + layout.
3
+
4
+ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
5
+ "accent": "#FF8A3D",
6
+ "theme": "dark",
7
+ "cardLayout": "grid",
8
+ "density": "regular",
9
+ "showVision": true
10
+ }/*EDITMODE-END*/;
11
+
12
+ const LOAD_STAGES = [
13
+ { k: "upload", label: "Uploading screenshot", ms: 500 },
14
+ { k: "vision", label: "Reading with MiniCPM-V 4.6", sub: "vision · OCR", ms: 1100 },
15
+ { k: "structure", label: "Structuring report", sub: "small LLM · strict JSON", ms: 1000 },
16
+ ];
17
+
18
+ function useToasts() {
19
+ const [toasts, setToasts] = React.useState([]);
20
+ const push = React.useCallback((msg) => {
21
+ const id = Math.random().toString(36).slice(2);
22
+ setToasts((t) => [...t, { id, msg }]);
23
+ setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
24
+ }, []);
25
+ return [toasts, push];
26
+ }
27
+
28
+ // ── Header ──
29
+ function Header({ onReset, view }) {
30
+ return (
31
+ <header className="bl-top">
32
+ <button className="bl-top-brand" onClick={onReset} title="Start over">
33
+ <Wordmark size={24} />
34
+ </button>
35
+ <div className="bl-top-tag">Screenshot → filed bug, in one read</div>
36
+ <div className="bl-top-right">
37
+ <span className="bl-model-chip"><i className="bl-live" />MiniCPM-V 4.6 · 1.3B</span>
38
+ </div>
39
+ </header>
40
+ );
41
+ }
42
+
43
+ // ── Idle / upload + gallery ──
44
+ function IdleView({ onPick }) {
45
+ const onDrop = (ev) => { ev.preventDefault(); onPick(EXAMPLES[0].id); };
46
+ return (
47
+ <div className="bl-idle">
48
+ <div className="bl-hero">
49
+ <div className="bl-hero-lens"><Logo size={58} /></div>
50
+ <h1 className="bl-hero-h">Turn a bug screenshot into a filed report.</h1>
51
+ <p className="bl-hero-sub">
52
+ BugLens reads the image, writes a Jira-ready report, lists the regression tests
53
+ and edge cases — and tells you exactly what it <b>can’t</b> see.
54
+ </p>
55
+ <div
56
+ className="bl-drop"
57
+ onDragOver={(e) => e.preventDefault()}
58
+ onDrop={onDrop}
59
+ onClick={() => onPick(EXAMPLES[0].id)}
60
+ >
61
+ <div className="bl-drop-ic">⤢</div>
62
+ <div className="bl-drop-t">Drop a screenshot</div>
63
+ <div className="bl-drop-s">PNG or JPG · or pick an example below</div>
64
+ </div>
65
+ </div>
66
+
67
+ <div className="bl-gallery">
68
+ <div className="bl-gallery-h">
69
+ <span>Example screenshots</span>
70
+ <span className="bl-gallery-hint">click to analyze</span>
71
+ </div>
72
+ <div className="bl-gallery-grid">
73
+ {EXAMPLES.map((e) => (
74
+ <button key={e.id} className="bl-ex" onClick={() => onPick(e.id)}>
75
+ <div className="bl-ex-thumb">
76
+ <MockScreen id={e.id} />
77
+ <span className="bl-ex-scrim" />
78
+ <SeverityBadge level={e.severity} />
79
+ </div>
80
+ <div className="bl-ex-meta">
81
+ <div className="bl-ex-app">{e.app}</div>
82
+ <div className="bl-ex-title">{e.title}</div>
83
+ <div className="bl-ex-blurb">{e.blurb}</div>
84
+ </div>
85
+ </button>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ // ── Loading ──
94
+ function LoadingView({ example, stageIdx }) {
95
+ return (
96
+ <div className="bl-loading">
97
+ <div className="bl-loading-shot">
98
+ <MockScreen id={example.id} />
99
+ <div className="bl-scan" />
100
+ </div>
101
+ <div className="bl-loading-side">
102
+ <div className="bl-load-file">{example.captured}</div>
103
+ <ul className="bl-stages">
104
+ {LOAD_STAGES.map((s, i) => {
105
+ const state = i < stageIdx ? "done" : i === stageIdx ? "active" : "todo";
106
+ return (
107
+ <li key={s.k} data-state={state}>
108
+ <span className="bl-stage-dot">
109
+ {state === "done" ? "✓" : state === "active" ? <span className="bl-mini-spin" /> : ""}
110
+ </span>
111
+ <div>
112
+ <div className="bl-stage-l">{s.label}</div>
113
+ {s.sub && <div className="bl-stage-sub">{s.sub}</div>}
114
+ </div>
115
+ </li>
116
+ );
117
+ })}
118
+ </ul>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ── Results ──
125
+ function ResultsView({ example, layout, showVision, onReset, push }) {
126
+ const cards = [
127
+ <BugReportCard e={example} key="report" />,
128
+ <MissingInfoCard e={example} key="missing" />,
129
+ <RegressionCard e={example} key="tests" />,
130
+ <EdgeCard e={example} key="edges" />,
131
+ ];
132
+ const tabsMeta = [
133
+ { k: "report", label: "Report" },
134
+ { k: "missing", label: "Missing info" },
135
+ { k: "tests", label: "Tests" },
136
+ { k: "edges", label: "Edge cases" },
137
+ ];
138
+ const [tab, setTab] = React.useState(0);
139
+
140
+ return (
141
+ <div className="bl-results">
142
+ {/* Left rail: the lens */}
143
+ <aside className="bl-lens">
144
+ <div className="bl-lens-shot">
145
+ <MockScreen id={example.id} />
146
+ </div>
147
+ <div className="bl-lens-file">{example.captured}</div>
148
+ {showVision && (
149
+ <div className="bl-vision">
150
+ <div className="bl-vision-h"><span className="bl-vision-ic">◎</span> What BugLens saw</div>
151
+ <ul>
152
+ {example.visionRead.map((v, i) => <li key={i}>{v}</li>)}
153
+ </ul>
154
+ </div>
155
+ )}
156
+ <Btn kind="ghost" full onClick={onReset} icon="←">Analyze another</Btn>
157
+ </aside>
158
+
159
+ {/* Right: structured output */}
160
+ <main className="bl-output">
161
+ {layout === "tabbed" ? (
162
+ <>
163
+ <div className="bl-tabs">
164
+ {tabsMeta.map((t, i) => (
165
+ <button key={t.k} className={i === tab ? "is-on" : ""} onClick={() => setTab(i)}>
166
+ {t.label}
167
+ </button>
168
+ ))}
169
+ </div>
170
+ <div className="bl-tabpane">{cards[tab]}</div>
171
+ </>
172
+ ) : (
173
+ <div className={`bl-cards bl-cards-${layout}`}>{cards}</div>
174
+ )}
175
+
176
+ <div className="bl-export">
177
+ <span className="bl-export-l">Export</span>
178
+ <Btn kind="primary" icon="↗" onClick={() => push("Sent to Jira — JIRA-4821 created (demo)")}>Create Jira issue</Btn>
179
+ <Btn kind="ghost" icon="⌥" onClick={() => push("Opened GitHub issue draft (demo)")}>GitHub issue</Btn>
180
+ <Btn kind="ghost" icon="▤" onClick={() => push("Downloaded buglens-report.csv (demo)")}>CSV</Btn>
181
+ </div>
182
+ </main>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ // ── App ──
188
+ function App() {
189
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
190
+ const [view, setView] = React.useState("idle"); // idle | loading | results
191
+ const [activeId, setActiveId] = React.useState(null);
192
+ const [stageIdx, setStageIdx] = React.useState(0);
193
+ const [toasts, push] = useToasts();
194
+ const timers = React.useRef([]);
195
+
196
+ const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
197
+
198
+ const pick = (id) => {
199
+ clearTimers();
200
+ setActiveId(id);
201
+ setStageIdx(0);
202
+ setView("loading");
203
+ let acc = 0;
204
+ LOAD_STAGES.forEach((s, i) => {
205
+ acc += s.ms;
206
+ timers.current.push(setTimeout(() => setStageIdx(i + 1), acc));
207
+ });
208
+ timers.current.push(setTimeout(() => setView("results"), acc + 250));
209
+ };
210
+
211
+ const reset = () => { clearTimers(); setView("idle"); setActiveId(null); };
212
+
213
+ React.useEffect(() => () => clearTimers(), []);
214
+
215
+ const example = EXAMPLES.find((e) => e.id === activeId);
216
+ const accent = Array.isArray(t.accent) ? t.accent[0] : t.accent;
217
+
218
+ return (
219
+ <div className="bl-app" data-theme={t.theme} data-density={t.density}
220
+ style={{ "--accent": accent }}>
221
+ <Header onReset={reset} view={view} />
222
+ <div className="bl-stage-wrap">
223
+ {view === "idle" && <IdleView onPick={pick} />}
224
+ {view === "loading" && example && <LoadingView example={example} stageIdx={stageIdx} />}
225
+ {view === "results" && example && (
226
+ <ResultsView example={example} layout={t.cardLayout} showVision={t.showVision}
227
+ onReset={reset} push={push} />
228
+ )}
229
+ </div>
230
+
231
+ <div className="bl-toasts">
232
+ {toasts.map((x) => <div key={x.id} className="bl-toast">{x.msg}</div>)}
233
+ </div>
234
+
235
+ <TweaksPanel title="Tweaks">
236
+ <TweakSection label="Style" />
237
+ <TweakColor label="Accent" value={t.accent}
238
+ options={["#FF8A3D", "#4C8DFF", "#37C99E", "#A878FF"]}
239
+ onChange={(v) => setTweak("accent", v)} />
240
+ <TweakRadio label="Theme" value={t.theme} options={["dark", "light"]}
241
+ onChange={(v) => setTweak("theme", v)} />
242
+ <TweakSection label="Results layout" />
243
+ <TweakRadio label="Cards" value={t.cardLayout}
244
+ options={[{ value: "grid", label: "Grid" }, { value: "column", label: "Column" }, { value: "tabbed", label: "Tabs" }]}
245
+ onChange={(v) => setTweak("cardLayout", v)} />
246
+ <TweakRadio label="Density" value={t.density} options={["compact", "regular", "comfy"]}
247
+ onChange={(v) => setTweak("density", v)} />
248
+ <TweakToggle label="Show “what I saw”" value={t.showVision}
249
+ onChange={(v) => setTweak("showVision", v)} />
250
+ <TweakSection label="Jump to" />
251
+ <div style={{ display: "flex", gap: 6 }}>
252
+ <TweakButton label="Upload" secondary onClick={reset} />
253
+ <TweakButton label="Results" onClick={() => pick(activeId || EXAMPLES[0].id)} />
254
+ </div>
255
+ </TweaksPanel>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ ReactDOM.createRoot(document.getElementById("root")).render(<App />);
buglens-ui/buglens-cards.jsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // buglens-cards.jsx
2
+ // The four structured-output cards. Each is self-contained and copy-able.
3
+
4
+ // ── plaintext builders for the copy actions ──
5
+ function reportToMarkdown(e) {
6
+ return [
7
+ `# ${e.title}`,
8
+ ``,
9
+ `**Severity:** ${SEVERITY[e.severity].label} — ${e.severityWhy}`,
10
+ ``,
11
+ `## Summary`,
12
+ e.summary,
13
+ ``,
14
+ `## Steps to reproduce`,
15
+ ...e.steps.map((s, i) => `${i + 1}. ${s}`),
16
+ ``,
17
+ `## Expected`,
18
+ e.expected,
19
+ ``,
20
+ `## Actual`,
21
+ e.actual,
22
+ ``,
23
+ `## Environment`,
24
+ ...e.env.map((r) => `- ${r.k}: ${r.v}`),
25
+ ].join("\n");
26
+ }
27
+ function testsToText(e) {
28
+ return e.tests
29
+ .map((t) => `${t.id} — ${t.title}\n Given ${t.given}\n When ${t.when}\n Then ${t.then}`)
30
+ .join("\n\n");
31
+ }
32
+
33
+ function CardShell({ kind, glyph, kicker, title, action, children, accent }) {
34
+ return (
35
+ <section className="bl-card" data-kind={kind} style={accent ? { "--card-accent": accent } : undefined}>
36
+ <header className="bl-card-h">
37
+ <span className="bl-card-ic">{glyph}</span>
38
+ <div className="bl-card-ht">
39
+ <div className="bl-kicker">{kicker}</div>
40
+ <h3>{title}</h3>
41
+ </div>
42
+ {action}
43
+ </header>
44
+ <div className="bl-card-b">{children}</div>
45
+ </section>
46
+ );
47
+ }
48
+
49
+ // ── 1. Bug report ──
50
+ function BugReportCard({ e }) {
51
+ return (
52
+ <CardShell
53
+ kind="report"
54
+ glyph="❏"
55
+ kicker="Bug report · paste into Jira"
56
+ title="Structured report"
57
+ action={<CopyBtn getText={() => reportToMarkdown(e)} label="Copy" />}
58
+ >
59
+ <div className="bl-rep-top">
60
+ <SeverityBadge level={e.severity} />
61
+ <span className="bl-rep-why">{e.severityWhy}</span>
62
+ </div>
63
+ <p className="bl-summary">{e.summary}</p>
64
+
65
+ <div className="bl-sublabel">Steps to reproduce</div>
66
+ <ol className="bl-steps">
67
+ {e.steps.map((s, i) => <li key={i}>{s}</li>)}
68
+ </ol>
69
+
70
+ <div className="bl-ea">
71
+ <div className="bl-ea-col bl-ea-exp">
72
+ <div className="bl-ea-lbl">Expected</div>
73
+ <p>{e.expected}</p>
74
+ </div>
75
+ <div className="bl-ea-col bl-ea-act">
76
+ <div className="bl-ea-lbl">Actual</div>
77
+ <p>{e.actual}</p>
78
+ </div>
79
+ </div>
80
+
81
+ <div className="bl-sublabel">Environment</div>
82
+ <div className="bl-env">
83
+ {e.env.map((r, i) => (
84
+ <div className="bl-env-row" key={i} data-known={r.known ? "1" : "0"}>
85
+ <span className="bl-env-k">{r.k}</span>
86
+ <span className="bl-env-v">{r.v}</span>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ </CardShell>
91
+ );
92
+ }
93
+
94
+ // ── 2. Missing information — THE DIFFERENTIATOR ──
95
+ function MissingInfoCard({ e }) {
96
+ const [confirmed, setConfirmed] = React.useState({});
97
+ const total = e.missing.length;
98
+ const done = Object.values(confirmed).filter(Boolean).length;
99
+ return (
100
+ <CardShell
101
+ kind="missing"
102
+ glyph="◎"
103
+ kicker="What I can’t see — confirm before filing"
104
+ title="Missing information"
105
+ accent="var(--accent)"
106
+ action={<span className="bl-progress">{done}/{total} confirmed</span>}
107
+ >
108
+ <p className="bl-honest">
109
+ A screenshot only shows the surface. BugLens flags what it <b>cannot</b> determine
110
+ rather than guessing — verify these to complete the report.
111
+ </p>
112
+ <ul className="bl-missing">
113
+ {e.missing.map((m, i) => {
114
+ const on = !!confirmed[i];
115
+ return (
116
+ <li key={i} className={on ? "is-on" : ""}>
117
+ <button
118
+ className="bl-check"
119
+ aria-pressed={on}
120
+ onClick={() => setConfirmed((c) => ({ ...c, [i]: !c[i] }))}
121
+ >
122
+ {on ? "✓" : ""}
123
+ </button>
124
+ <div className="bl-missing-t">
125
+ <div className="bl-missing-q">{m.q}</div>
126
+ <div className="bl-missing-w">{m.why}</div>
127
+ </div>
128
+ </li>
129
+ );
130
+ })}
131
+ </ul>
132
+ </CardShell>
133
+ );
134
+ }
135
+
136
+ // ── 3. Regression tests ──
137
+ function RegressionCard({ e }) {
138
+ return (
139
+ <CardShell
140
+ kind="tests"
141
+ glyph="⊞"
142
+ kicker="Regression tests · QA-runnable"
143
+ title="Suggested test cases"
144
+ action={<CopyBtn getText={() => testsToText(e)} label="Copy" />}
145
+ >
146
+ <ul className="bl-tests">
147
+ {e.tests.map((t) => (
148
+ <li key={t.id} className="bl-test">
149
+ <div className="bl-test-h">
150
+ <span className="bl-test-id">{t.id}</span>
151
+ <span className="bl-test-title">{t.title}</span>
152
+ </div>
153
+ <dl className="bl-gwt">
154
+ <div><dt>Given</dt><dd>{t.given}</dd></div>
155
+ <div><dt>When</dt><dd>{t.when}</dd></div>
156
+ <div><dt>Then</dt><dd>{t.then}</dd></div>
157
+ </dl>
158
+ </li>
159
+ ))}
160
+ </ul>
161
+ </CardShell>
162
+ );
163
+ }
164
+
165
+ // ── 4. Edge cases ──
166
+ function EdgeCard({ e }) {
167
+ return (
168
+ <CardShell
169
+ kind="edges"
170
+ glyph="⬡"
171
+ kicker="Edge cases · worth checking"
172
+ title="Edge cases"
173
+ >
174
+ <ul className="bl-edges">
175
+ {e.edges.map((c, i) => (
176
+ <li key={i}>
177
+ <span className="bl-edge-marker" />
178
+ <div>
179
+ <div className="bl-edge-t">{c.t}</div>
180
+ <div className="bl-edge-d">{c.d}</div>
181
+ </div>
182
+ </li>
183
+ ))}
184
+ </ul>
185
+ </CardShell>
186
+ );
187
+ }
188
+
189
+ Object.assign(window, { BugReportCard, MissingInfoCard, RegressionCard, EdgeCard });
buglens-ui/buglens-components.jsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // buglens-components.jsx
2
+ // Shared primitives: logo, severity badge, buttons, copy action, tag.
3
+
4
+ function Logo({ size = 26 }) {
5
+ // Aperture / lens mark — the "lens" that inspects the bug.
6
+ return (
7
+ <svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true">
8
+ <circle cx="16" cy="16" r="13" stroke="var(--accent)" strokeWidth="2" />
9
+ <path d="M16 3.5 L23 16 M16 3.5 L9 16" stroke="var(--accent)" strokeWidth="1.4" opacity=".55" />
10
+ <path d="M28.5 16 L16 16 M28.5 16 L22 27" stroke="var(--accent)" strokeWidth="1.4" opacity=".55" />
11
+ <path d="M3.5 16 L16 16 M3.5 16 L10 27" stroke="var(--accent)" strokeWidth="1.4" opacity=".55" />
12
+ <circle cx="16" cy="16" r="4.4" fill="var(--accent)" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ function Wordmark({ size = 26 }) {
18
+ return (
19
+ <div className="bl-word">
20
+ <Logo size={size} />
21
+ <span>Bug<b>Lens</b></span>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ function SeverityBadge({ level }) {
27
+ const s = SEVERITY[level] || SEVERITY.medium;
28
+ return (
29
+ <span className="bl-sev" style={{ "--sc": s.c }}>
30
+ <i /> {s.label}
31
+ </span>
32
+ );
33
+ }
34
+
35
+ function Btn({ children, kind = "ghost", onClick, icon, full, ...rest }) {
36
+ return (
37
+ <button className={`bl-btn bl-btn-${kind}${full ? " bl-btn-full" : ""}`} onClick={onClick} {...rest}>
38
+ {icon && <span className="bl-btn-ic">{icon}</span>}
39
+ {children}
40
+ </button>
41
+ );
42
+ }
43
+
44
+ function Tag({ children, tone = "neutral" }) {
45
+ return <span className={`bl-tag bl-tag-${tone}`}>{children}</span>;
46
+ }
47
+
48
+ // Copy-to-clipboard with a transient "Copied" state.
49
+ function CopyBtn({ getText, label = "Copy" }) {
50
+ const [done, setDone] = React.useState(false);
51
+ const onCopy = () => {
52
+ try {
53
+ const txt = typeof getText === "function" ? getText() : String(getText || "");
54
+ if (navigator.clipboard) navigator.clipboard.writeText(txt).catch(() => {});
55
+ } catch (e) {}
56
+ setDone(true);
57
+ setTimeout(() => setDone(false), 1400);
58
+ };
59
+ return (
60
+ <button className={`bl-copy${done ? " is-done" : ""}`} onClick={onCopy}>
61
+ {done ? "✓ Copied" : `⧉ ${label}`}
62
+ </button>
63
+ );
64
+ }
65
+
66
+ Object.assign(window, { Logo, Wordmark, SeverityBadge, Btn, Tag, CopyBtn });
buglens-ui/buglens-data.jsx ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // buglens-data.jsx
2
+ // Mock examples + their fully-structured BugLens output.
3
+ // Each example simulates: a screenshot → vision read → 4 structured cards.
4
+ // Content is hand-written to feel like a senior engineer triaged it, not lorem.
5
+
6
+ const EXAMPLES = [
7
+ // ────────────────────────────────────────────────────────────────────────
8
+ {
9
+ id: "payment",
10
+ app: "Checkout",
11
+ title: "Pay button stays disabled with a valid card",
12
+ blurb: "Card fields filled, but “Pay $49.00” won’t enable.",
13
+ severity: "high",
14
+ severityWhy:
15
+ "Blocks revenue on the final checkout step; no client-side workaround for the user.",
16
+ captured: "checkout-pay-disabled.png",
17
+ // What the vision model claims it can literally SEE — deliberately cautious.
18
+ visionRead: [
19
+ "A checkout panel titled “Payment”. Card number, expiry (12 / 27) and CVC (123) fields all appear filled.",
20
+ "A primary button labelled “Pay $49.00” is rendered in a low-contrast grey, consistent with a disabled state.",
21
+ "A red helper line under the card field reads “Card number looks incomplete”.",
22
+ "No network panel, console, or browser chrome is visible in the image.",
23
+ ],
24
+ summary:
25
+ "On the checkout payment step, the card number, expiry and CVC fields appear complete, but the primary “Pay $49.00” button remains in a disabled/greyed state. An inline validation message “Card number looks incomplete” is shown beneath the card field, suggesting the card-number validator is rejecting input the user believes is valid.",
26
+ steps: [
27
+ "Open the checkout flow and proceed to the Payment step.",
28
+ "Enter card number 4242 4242 4242 4242, expiry 12 / 27, CVC 123.",
29
+ "Observe the inline message “Card number looks incomplete”.",
30
+ "Observe that “Pay $49.00” stays disabled.",
31
+ ],
32
+ expected:
33
+ "With a valid 16-digit card number, the validation message clears and “Pay $49.00” becomes enabled.",
34
+ actual:
35
+ "Validation message persists and the Pay button never enables, blocking completion of the purchase.",
36
+ env: [
37
+ { k: "Visible in screenshot", v: "Payment step, $49.00 total", known: true },
38
+ { k: "Browser / OS", v: "not visible — please confirm", known: false },
39
+ { k: "Card type used", v: "not visible — please confirm", known: false },
40
+ ],
41
+ // ── THE DIFFERENTIATOR: honest about what it cannot determine ──
42
+ missing: [
43
+ {
44
+ q: "Does the field strip spaces before validation?",
45
+ why: "If the validator counts the spaces in “4242 4242 …” as part of the length, a 16-digit card reads as 19 chars and fails. Can’t confirm from a static image — needs the input handler.",
46
+ },
47
+ {
48
+ q: "What browser & version is this?",
49
+ why: "Autofill formatting differs across Chrome/Safari and can inject non-breaking spaces. Not visible in the screenshot.",
50
+ },
51
+ {
52
+ q: "Is the Pay button gated on a separate form-valid flag?",
53
+ why: "The button could be disabled by overall form state, not the card field alone. Needs the component’s enable condition.",
54
+ },
55
+ ],
56
+ tests: [
57
+ {
58
+ id: "REG-001",
59
+ title: "Spaced card number enables Pay",
60
+ given: "I am on the Payment step",
61
+ when: "I enter “4242 4242 4242 4242”, a valid expiry and CVC",
62
+ then: "no validation error shows and the Pay button is enabled",
63
+ },
64
+ {
65
+ id: "REG-002",
66
+ title: "Validator strips formatting",
67
+ given: "a card number with spaces or dashes",
68
+ when: "the field loses focus",
69
+ then: "the stored value contains digits only and length checks pass",
70
+ },
71
+ {
72
+ id: "REG-003",
73
+ title: "Pay disabled while genuinely incomplete",
74
+ given: "a 12-digit partial card number",
75
+ when: "I review the form",
76
+ then: "the Pay button stays disabled and the incomplete message shows",
77
+ },
78
+ ],
79
+ edges: [
80
+ { t: "Amex 15-digit cards", d: "A 16-digit length rule will wrongly reject valid Amex numbers." },
81
+ { t: "Browser autofill", d: "Autofilled values may not fire the same input events as typing." },
82
+ { t: "Trailing whitespace", d: "A pasted number with a trailing space can pass length but fail Luhn." },
83
+ { t: "Paste vs type", d: "Pasting may bypass per-keystroke formatting that strips spaces." },
84
+ ],
85
+ },
86
+
87
+ // ────────────────────────────────────────────────────────────────────────
88
+ {
89
+ id: "dashboard",
90
+ app: "Analytics",
91
+ title: "Revenue chart spins forever after date change",
92
+ blurb: "Switching the range to “Last 90 days” never resolves.",
93
+ severity: "medium",
94
+ severityWhy:
95
+ "Core dashboard widget unusable for a common range; other widgets still load.",
96
+ captured: "analytics-chart-spinner.png",
97
+ visionRead: [
98
+ "An analytics dashboard with a sidebar and four metric tiles across the top (Revenue, Orders, AOV, Refunds).",
99
+ "The large “Revenue over time” panel shows a centered loading spinner and no chart.",
100
+ "A date-range control in the top right reads “Last 90 days”.",
101
+ "The surrounding tiles show numbers, so the page itself has loaded.",
102
+ ],
103
+ summary:
104
+ "On the Analytics dashboard, selecting the “Last 90 days” range leaves the main “Revenue over time” chart stuck on its loading spinner indefinitely, while the summary tiles around it populate normally. This points to a failure isolated to the time-series request for the longer range rather than a full page-load failure.",
105
+ steps: [
106
+ "Open the Analytics dashboard (default range loads fine).",
107
+ "Open the date-range control in the top right.",
108
+ "Select “Last 90 days”.",
109
+ "Observe the Revenue chart spinner never resolves.",
110
+ ],
111
+ expected:
112
+ "The chart fetches the 90-day series and renders within a few seconds, or shows an error state if the request fails.",
113
+ actual:
114
+ "The spinner persists with no chart and no error message; the widget is stuck.",
115
+ env: [
116
+ { k: "Visible in screenshot", v: "“Last 90 days” selected, spinner shown", known: true },
117
+ { k: "Network response", v: "not visible — please confirm", known: false },
118
+ { k: "Console errors", v: "not visible — please confirm", known: false },
119
+ ],
120
+ missing: [
121
+ {
122
+ q: "Did the 90-day request return, error, or hang?",
123
+ why: "A spinner with no error usually means the promise never settled (timeout/aborted) — but the network tab isn’t in the image, so this is unconfirmed.",
124
+ },
125
+ {
126
+ q: "Is there a row-count or payload limit?",
127
+ why: "90 days of hourly points may exceed a server cap that silently truncates or 500s. Can’t tell from the screenshot.",
128
+ },
129
+ {
130
+ q: "Does a shorter custom range (e.g. 60 days) also hang?",
131
+ why: "Needed to tell whether it’s the 90-day preset specifically or any range past a threshold.",
132
+ },
133
+ ],
134
+ tests: [
135
+ {
136
+ id: "REG-011",
137
+ title: "90-day range renders",
138
+ given: "I am on the Analytics dashboard",
139
+ when: "I select “Last 90 days”",
140
+ then: "the Revenue chart renders a series within the timeout window",
141
+ },
142
+ {
143
+ id: "REG-012",
144
+ title: "Failed fetch shows an error, not a spinner",
145
+ given: "the time-series request errors or times out",
146
+ when: "I wait past the timeout",
147
+ then: "the chart shows a retryable error state instead of an endless spinner",
148
+ },
149
+ {
150
+ id: "REG-013",
151
+ title: "Range switching cancels stale requests",
152
+ given: "a 90-day request is in flight",
153
+ when: "I switch to “Last 7 days”",
154
+ then: "the in-flight request is aborted and the 7-day series renders",
155
+ },
156
+ ],
157
+ edges: [
158
+ { t: "Empty range", d: "A new account with no 90-day history should show an empty state, not a spinner." },
159
+ { t: "DST boundary", d: "Spanning a clock change can produce a duplicate or missing hour bucket." },
160
+ { t: "Slow network", d: "On 3G the request may legitimately take long — distinguish slow from hung." },
161
+ { t: "Rapid switching", d: "Toggling ranges fast can race responses and render stale data." },
162
+ ],
163
+ },
164
+
165
+ // ────────────────────────────────────────────────────────────────────────
166
+ {
167
+ id: "signup",
168
+ app: "Sign up",
169
+ title: "Password error overlaps the input on mobile",
170
+ blurb: "Error text renders on top of the field, unreadable.",
171
+ severity: "low",
172
+ severityWhy:
173
+ "Cosmetic but hurts conversion on the signup form; field is still usable.",
174
+ captured: "signup-error-overlap.png",
175
+ visionRead: [
176
+ "A mobile signup form with Email and Password fields and a “Create account” button.",
177
+ "Red error text “Use at least 8 characters” appears to overlap the lower edge of the Password input rather than sitting below it.",
178
+ "The layout looks like a narrow mobile viewport.",
179
+ "Device model and exact viewport width are not indicated in the image.",
180
+ ],
181
+ summary:
182
+ "On the mobile signup form, the password validation message “Use at least 8 characters” renders overlapping the bottom edge of the Password input instead of in the space beneath it. The field remains functional, but the collision makes both the error and the field value hard to read — a layout/spacing issue at narrow widths.",
183
+ steps: [
184
+ "Open the signup form on a narrow mobile viewport.",
185
+ "Enter a password shorter than 8 characters.",
186
+ "Trigger validation (blur or submit).",
187
+ "Observe the error text overlapping the input.",
188
+ ],
189
+ expected:
190
+ "The error message appears in a reserved space directly below the field without overlapping it.",
191
+ actual:
192
+ "The error text is absolutely positioned over the input’s lower edge, colliding with the field.",
193
+ env: [
194
+ { k: "Visible in screenshot", v: "Mobile signup, password error", known: true },
195
+ { k: "Device / viewport width", v: "not visible — please confirm", known: false },
196
+ { k: "Browser", v: "not visible — please confirm", known: false },
197
+ ],
198
+ missing: [
199
+ {
200
+ q: "Is the error absolutely positioned?",
201
+ why: "Overlap usually means the message is taken out of flow with no reserved height. Confirming needs the CSS, not the picture.",
202
+ },
203
+ {
204
+ q: "At what width does it start overlapping?",
205
+ why: "Could be every mobile width or only below a breakpoint. The exact viewport isn’t shown.",
206
+ },
207
+ {
208
+ q: "Does a two-line error wrap into the field?",
209
+ why: "A longer message may make the collision worse; can’t tell from a single short string.",
210
+ },
211
+ ],
212
+ tests: [
213
+ {
214
+ id: "REG-021",
215
+ title: "Error sits below the field",
216
+ given: "a mobile viewport of 360px",
217
+ when: "a validation error shows",
218
+ then: "the message renders below the input with no overlap",
219
+ },
220
+ {
221
+ id: "REG-022",
222
+ title: "Field reserves error space",
223
+ given: "any input with validation",
224
+ when: "no error is present",
225
+ then: "space is reserved so showing an error does not shift surrounding layout",
226
+ },
227
+ {
228
+ id: "REG-023",
229
+ title: "Two-line errors don’t collide",
230
+ given: "a long validation message",
231
+ when: "it wraps to two lines",
232
+ then: "the field and message remain fully readable",
233
+ },
234
+ ],
235
+ edges: [
236
+ { t: "320px screens", d: "The smallest common phones are most likely to collide." },
237
+ { t: "Large text setting", d: "OS-level font scaling grows the error and worsens overlap." },
238
+ { t: "RTL languages", d: "Translated errors are often longer and wrap differently." },
239
+ { t: "Landscape", d: "Short viewport height can push the error under the keyboard." },
240
+ ],
241
+ },
242
+
243
+ // ────────────────────────────────────────────────────────────────────────
244
+ {
245
+ id: "settings",
246
+ app: "Settings",
247
+ title: "Broken avatar collapses the profile header",
248
+ blurb: "Image fails to load and the layout jumps.",
249
+ severity: "medium",
250
+ severityWhy:
251
+ "Visible on every visit to Settings for affected users; degrades trust in the page.",
252
+ captured: "settings-broken-avatar.png",
253
+ visionRead: [
254
+ "A Settings → Profile page with a header area for an avatar, name and email.",
255
+ "Where the avatar should be, a broken-image placeholder icon is shown.",
256
+ "The name and email text appear shifted up/left, as if the avatar’s box has no reserved size.",
257
+ "No URL or network detail for the image is visible in the screenshot.",
258
+ ],
259
+ summary:
260
+ "On the Settings profile header, the user avatar fails to load and falls back to the browser’s broken-image icon. Because the avatar container doesn’t appear to reserve a fixed size, the adjacent name and email shift position, causing a visible layout jump. The result is a degraded header that looks broken on every visit for affected users.",
261
+ steps: [
262
+ "Open Settings → Profile for a user whose avatar URL is unreachable.",
263
+ "Observe the broken-image icon in place of the avatar.",
264
+ "Observe the name/email shifting to fill the empty space.",
265
+ ],
266
+ expected:
267
+ "A failed avatar shows a graceful fallback (initials or default) at the same fixed size, with no layout shift.",
268
+ actual:
269
+ "The broken-image icon shows and the surrounding text reflows, jumping the layout.",
270
+ env: [
271
+ { k: "Visible in screenshot", v: "Profile header, broken avatar", known: true },
272
+ { k: "Image URL / status code", v: "not visible — please confirm", known: false },
273
+ { k: "Affected users", v: "not visible — please confirm", known: false },
274
+ ],
275
+ missing: [
276
+ {
277
+ q: "Why did the image fail — 404, CORS, or expired URL?",
278
+ why: "The fix differs for each, and the status code isn’t in the screenshot.",
279
+ },
280
+ {
281
+ q: "Does the avatar have an onError fallback?",
282
+ why: "A broken-image icon suggests no error handler; confirming needs the component code.",
283
+ },
284
+ {
285
+ q: "Is the container sized independently of the image?",
286
+ why: "The layout jump implies the box collapses when the image is missing — needs the CSS.",
287
+ },
288
+ ],
289
+ tests: [
290
+ {
291
+ id: "REG-031",
292
+ title: "Failed avatar falls back gracefully",
293
+ given: "an avatar URL that returns an error",
294
+ when: "the profile header renders",
295
+ then: "a default/initials avatar shows at the correct size",
296
+ },
297
+ {
298
+ id: "REG-032",
299
+ title: "No layout shift on image failure",
300
+ given: "the avatar fails to load",
301
+ when: "the header renders",
302
+ then: "the name and email stay in the same position (reserved box)",
303
+ },
304
+ {
305
+ id: "REG-033",
306
+ title: "Broken-image icon never shown",
307
+ given: "any avatar load failure",
308
+ when: "the page renders",
309
+ then: "the native broken-image icon is never visible to the user",
310
+ },
311
+ ],
312
+ edges: [
313
+ { t: "Slow image load", d: "A reserved box should hold space while the image is still loading." },
314
+ { t: "Expired signed URL", d: "Time-limited URLs can 403 after a while and need refresh logic." },
315
+ { t: "No avatar set", d: "Brand-new users have no image at all and need the same fallback." },
316
+ { t: "Transparent PNG", d: "A valid but empty image can look broken — distinct from a load failure." },
317
+ ],
318
+ },
319
+ ];
320
+
321
+ const SEVERITY = {
322
+ critical: { label: "Critical", c: "var(--sev-critical)" },
323
+ high: { label: "High", c: "var(--sev-high)" },
324
+ medium: { label: "Medium", c: "var(--sev-medium)" },
325
+ low: { label: "Low", c: "var(--sev-low)" },
326
+ };
327
+
328
+ Object.assign(window, { EXAMPLES, SEVERITY });
buglens-ui/buglens-screens.jsx ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // buglens-screens.jsx
2
+ // Synthetic "screenshots" — believable buggy product UI rendered in HTML/CSS.
3
+ // These stand in for the user-uploaded image so the demo reads as real.
4
+ // All classes are prefixed `mk-` and scoped via a single injected <style>.
5
+
6
+ const MOCK_STYLE = `
7
+ .mk{position:absolute;inset:0;background:#f4f5f7;color:#1c2330;
8
+ font-family:'Space Grotesk',system-ui,sans-serif;overflow:hidden;
9
+ display:flex;flex-direction:column}
10
+ .mk *{box-sizing:border-box}
11
+ .mk-bar{height:34px;flex:0 0 34px;background:#e7e9ee;border-bottom:1px solid #d6d9e0;
12
+ display:flex;align-items:center;gap:6px;padding:0 12px}
13
+ .mk-dot{width:9px;height:9px;border-radius:50%;background:#c6cad3}
14
+ .mk-url{margin-left:10px;height:18px;flex:1;max-width:260px;border-radius:5px;
15
+ background:#fff;border:1px solid #d6d9e0;font-size:10px;color:#8a909c;
16
+ display:flex;align-items:center;padding:0 8px;font-family:'JetBrains Mono',monospace}
17
+
18
+ /* ---- Payment ---- */
19
+ .mk-pay-wrap{flex:1;display:flex;align-items:center;justify-content:center;padding:22px}
20
+ .mk-card{width:300px;background:#fff;border:1px solid #e3e6ec;border-radius:14px;
21
+ box-shadow:0 10px 30px rgba(20,30,60,.08);padding:22px}
22
+ .mk-h{font-size:17px;font-weight:600;margin:0 0 4px}
23
+ .mk-sub{font-size:11px;color:#8a909c;margin:0 0 18px}
24
+ .mk-field{margin-bottom:13px}
25
+ .mk-flbl{font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
26
+ color:#9aa0ac;margin-bottom:5px;display:block}
27
+ .mk-inp{height:36px;border:1.5px solid #dfe2e8;border-radius:8px;display:flex;align-items:center;
28
+ padding:0 11px;font-size:13px;font-family:'JetBrains Mono',monospace;color:#1c2330;background:#fff}
29
+ .mk-inp.err{border-color:#e5484d}
30
+ .mk-row2{display:flex;gap:11px}
31
+ .mk-row2>div{flex:1}
32
+ .mk-erline{font-size:10.5px;color:#e5484d;margin-top:6px;display:flex;align-items:center;gap:5px}
33
+ .mk-pay{height:42px;border-radius:9px;background:#c9ccd4;color:#fff;font-weight:600;font-size:14px;
34
+ display:flex;align-items:center;justify-content:center;margin-top:6px;cursor:not-allowed}
35
+ .mk-lock{font-size:9px;color:#aeb3bd;text-align:center;margin-top:9px}
36
+
37
+ /* ---- Dashboard ---- */
38
+ .mk-dash{flex:1;display:flex;min-height:0}
39
+ .mk-side{width:54px;flex:0 0 54px;background:#1c2330;display:flex;flex-direction:column;
40
+ align-items:center;gap:14px;padding:16px 0}
41
+ .mk-sq{width:22px;height:22px;border-radius:6px;background:rgba(255,255,255,.14)}
42
+ .mk-sq.on{background:var(--accent)}
43
+ .mk-main{flex:1;padding:18px;display:flex;flex-direction:column;gap:14px;min-width:0}
44
+ .mk-toprow{display:flex;align-items:center;justify-content:space-between}
45
+ .mk-title{font-size:16px;font-weight:600}
46
+ .mk-range{height:30px;border:1px solid #d6d9e0;border-radius:7px;background:#fff;font-size:11px;
47
+ display:flex;align-items:center;gap:8px;padding:0 11px;color:#3a4150;font-weight:500}
48
+ .mk-range b{color:#1c2330}
49
+ .mk-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:11px}
50
+ .mk-tile{background:#fff;border:1px solid #e6e8ee;border-radius:10px;padding:12px}
51
+ .mk-tlbl{font-size:9.5px;color:#9aa0ac;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
52
+ .mk-tval{font-size:19px;font-weight:600;margin-top:6px}
53
+ .mk-tval span{font-size:11px;color:#2f9e6f;font-weight:600;margin-left:5px}
54
+ .mk-panel{flex:1;background:#fff;border:1px solid #e6e8ee;border-radius:12px;min-height:0;
55
+ display:flex;flex-direction:column}
56
+ .mk-phead{padding:13px 16px;font-size:13px;font-weight:600;border-bottom:1px solid #eef0f4}
57
+ .mk-pbody{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:11px}
58
+ .mk-spin{width:30px;height:30px;border-radius:50%;border:3px solid #e6e8ee;
59
+ border-top-color:var(--accent);animation:mkspin 1s linear infinite}
60
+ @keyframes mkspin{to{transform:rotate(360deg)}}
61
+ .mk-ptext{font-size:11px;color:#aeb3bd}
62
+
63
+ /* ---- Signup (mobile) ---- */
64
+ .mk-mobwrap{flex:1;background:#e9ebf0;display:flex;align-items:center;justify-content:center;padding:18px}
65
+ .mk-phone{width:208px;height:330px;background:#fff;border-radius:22px;border:1px solid #d6d9e0;
66
+ box-shadow:0 14px 36px rgba(20,30,60,.14);padding:26px 20px;position:relative;overflow:hidden}
67
+ .mk-notch{position:absolute;top:9px;left:50%;transform:translateX(-50%);width:54px;height:5px;
68
+ border-radius:3px;background:#dcdfe6}
69
+ .mk-mh{font-size:17px;font-weight:600;margin:8px 0 3px}
70
+ .mk-msub{font-size:10px;color:#9aa0ac;margin:0 0 20px}
71
+ .mk-mfield{margin-bottom:16px;position:relative}
72
+ .mk-minp{height:34px;border:1.5px solid #dfe2e8;border-radius:8px;background:#fff;
73
+ display:flex;align-items:center;padding:0 10px;font-size:11px;color:#5a6170}
74
+ .mk-minp.err{border-color:#e5484d}
75
+ /* The bug: error pulled up over the field instead of below it */
76
+ .mk-merr{position:absolute;left:4px;bottom:5px;font-size:10px;color:#e5484d;font-weight:600;
77
+ background:rgba(255,255,255,.86);padding:0 3px}
78
+ .mk-mbtn{height:38px;border-radius:9px;background:var(--accent);color:#fff;font-weight:600;
79
+ font-size:12px;display:flex;align-items:center;justify-content:center;margin-top:6px}
80
+
81
+ /* ---- Settings (broken avatar) ---- */
82
+ .mk-setwrap{flex:1;padding:26px 30px}
83
+ .mk-crumb{font-size:11px;color:#9aa0ac;font-family:'JetBrains Mono',monospace;margin-bottom:18px}
84
+ .mk-prof{display:flex;align-items:flex-start;gap:2px;background:#fff;border:1px solid #e6e8ee;
85
+ border-radius:12px;padding:20px}
86
+ .mk-broken{width:30px;height:24px;flex:0 0 auto;border:1.5px dashed #c2c7d0;border-radius:4px;
87
+ display:flex;align-items:center;justify-content:center;color:#b3b8c2;font-size:13px;
88
+ position:relative;margin-top:2px}
89
+ /* name/email shifted tight against the broken icon — the layout jump */
90
+ .mk-pinfo{margin-left:8px}
91
+ .mk-pname{font-size:17px;font-weight:600}
92
+ .mk-pmail{font-size:12px;color:#8a909c;font-family:'JetBrains Mono',monospace;margin-top:3px}
93
+ .mk-pfields{margin-top:22px;display:flex;flex-direction:column;gap:13px}
94
+ .mk-pf{display:flex;flex-direction:column;gap:5px}
95
+ .mk-pfl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#9aa0ac}
96
+ .mk-pfv{height:34px;background:#f4f5f7;border:1px solid #e6e8ee;border-radius:8px}
97
+ `;
98
+
99
+ function BrowserBar({ url }) {
100
+ return (
101
+ <div className="mk-bar">
102
+ <div className="mk-dot" /><div className="mk-dot" /><div className="mk-dot" />
103
+ <div className="mk-url">{url}</div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function MockScreen({ id }) {
109
+ if (id === "payment") {
110
+ return (
111
+ <div className="mk">
112
+ <BrowserBar url="shop.acme.io/checkout" />
113
+ <div className="mk-pay-wrap">
114
+ <div className="mk-card">
115
+ <h3 className="mk-h">Payment</h3>
116
+ <p className="mk-sub">Order total · $49.00</p>
117
+ <div className="mk-field">
118
+ <span className="mk-flbl">Card number</span>
119
+ <div className="mk-inp err">4242 4242 4242 4242</div>
120
+ <div className="mk-erline">⚠ Card number looks incomplete</div>
121
+ </div>
122
+ <div className="mk-row2">
123
+ <div className="mk-field">
124
+ <span className="mk-flbl">Expiry</span>
125
+ <div className="mk-inp">12 / 27</div>
126
+ </div>
127
+ <div className="mk-field">
128
+ <span className="mk-flbl">CVC</span>
129
+ <div className="mk-inp">123</div>
130
+ </div>
131
+ </div>
132
+ <div className="mk-pay">Pay $49.00</div>
133
+ <div className="mk-lock">🔒 Secured by AcmePay</div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ if (id === "dashboard") {
141
+ return (
142
+ <div className="mk">
143
+ <BrowserBar url="app.acme.io/analytics" />
144
+ <div className="mk-dash">
145
+ <div className="mk-side">
146
+ <div className="mk-sq on" /><div className="mk-sq" />
147
+ <div className="mk-sq" /><div className="mk-sq" />
148
+ </div>
149
+ <div className="mk-main">
150
+ <div className="mk-toprow">
151
+ <div className="mk-title">Analytics</div>
152
+ <div className="mk-range">Range · <b>Last 90 days</b> ▾</div>
153
+ </div>
154
+ <div className="mk-tiles">
155
+ <div className="mk-tile"><div className="mk-tlbl">Revenue</div><div className="mk-tval">$84.2k</div></div>
156
+ <div className="mk-tile"><div className="mk-tlbl">Orders</div><div className="mk-tval">1,904</div></div>
157
+ <div className="mk-tile"><div className="mk-tlbl">AOV</div><div className="mk-tval">$44</div></div>
158
+ <div className="mk-tile"><div className="mk-tlbl">Refunds</div><div className="mk-tval">31</div></div>
159
+ </div>
160
+ <div className="mk-panel">
161
+ <div className="mk-phead">Revenue over time</div>
162
+ <div className="mk-pbody">
163
+ <div className="mk-spin" />
164
+ <div className="mk-ptext">Loading…</div>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ if (id === "signup") {
174
+ return (
175
+ <div className="mk">
176
+ <BrowserBar url="acme.io/signup" />
177
+ <div className="mk-mobwrap">
178
+ <div className="mk-phone">
179
+ <div className="mk-notch" />
180
+ <h3 className="mk-mh">Create account</h3>
181
+ <p className="mk-msub">Start your 14-day trial</p>
182
+ <div className="mk-mfield">
183
+ <div className="mk-minp">you@company.com</div>
184
+ </div>
185
+ <div className="mk-mfield">
186
+ <div className="mk-minp err">•••••</div>
187
+ <div className="mk-merr">Use at least 8 characters</div>
188
+ </div>
189
+ <div className="mk-mbtn">Create account</div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ if (id === "settings") {
197
+ return (
198
+ <div className="mk">
199
+ <BrowserBar url="app.acme.io/settings/profile" />
200
+ <div className="mk-setwrap">
201
+ <div className="mk-crumb">Settings / Profile</div>
202
+ <div className="mk-prof">
203
+ <div className="mk-broken">🖼</div>
204
+ <div className="mk-pinfo">
205
+ <div className="mk-pname">Jordan Reyes</div>
206
+ <div className="mk-pmail">jordan@acme.io</div>
207
+ </div>
208
+ </div>
209
+ <div className="mk-pfields">
210
+ <div className="mk-pf"><span className="mk-pfl">Display name</span><div className="mk-pfv" /></div>
211
+ <div className="mk-pf"><span className="mk-pfl">Time zone</span><div className="mk-pfv" /></div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+ return null;
218
+ }
219
+
220
+ Object.assign(window, { MockScreen, MOCK_STYLE });
buglens-ui/screenshots/01-flow.png ADDED
buglens-ui/screenshots/02-flow.png ADDED
buglens-ui/screenshots/idle.png ADDED
buglens-ui/screenshots/light.png ADDED
buglens-ui/screenshots/light2.png ADDED
buglens-ui/screenshots/lower.png ADDED
buglens-ui/tweaks-panel.jsx ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
2
+
3
+ /* BEGIN USAGE */
4
+ // tweaks-panel.jsx
5
+ // Reusable Tweaks shell + form-control helpers.
6
+ // Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
7
+ // TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
8
+ //
9
+ // Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
10
+ // posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
11
+ // individual prototypes don't re-roll it. Ships a consistent set of controls so you
12
+ // don't hand-draw <input type="range">, segmented radios, steppers, etc.
13
+ //
14
+ // Usage (in an HTML file that loads React + Babel):
15
+ //
16
+ // const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
17
+ // "primaryColor": "#D97757",
18
+ // "palette": ["#D97757", "#29261b", "#f6f4ef"],
19
+ // "fontSize": 16,
20
+ // "density": "regular",
21
+ // "dark": false
22
+ // }/*EDITMODE-END*/;
23
+ //
24
+ // function App() {
25
+ // const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
26
+ // return (
27
+ // <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
28
+ // Hello
29
+ // <TweaksPanel>
30
+ // <TweakSection label="Typography" />
31
+ // <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
32
+ // onChange={(v) => setTweak('fontSize', v)} />
33
+ // <TweakRadio label="Density" value={t.density}
34
+ // options={['compact', 'regular', 'comfy']}
35
+ // onChange={(v) => setTweak('density', v)} />
36
+ // <TweakSection label="Theme" />
37
+ // <TweakColor label="Primary" value={t.primaryColor}
38
+ // options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
39
+ // onChange={(v) => setTweak('primaryColor', v)} />
40
+ // <TweakColor label="Palette" value={t.palette}
41
+ // options={[['#D97757', '#29261b', '#f6f4ef'],
42
+ // ['#475569', '#0f172a', '#f1f5f9']]}
43
+ // onChange={(v) => setTweak('palette', v)} />
44
+ // <TweakToggle label="Dark mode" value={t.dark}
45
+ // onChange={(v) => setTweak('dark', v)} />
46
+ // </TweaksPanel>
47
+ // </div>
48
+ // );
49
+ // }
50
+ //
51
+ // TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
52
+ // TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
53
+ // options are many or long. For color tweaks always curate 3-4 options rather than
54
+ // a free picker; an option can also be a whole 2–5 color palette (the stored value
55
+ // is the array). The Tweak* controls are a floor, not a ceiling — build custom
56
+ // controls inside the panel if a tweak calls for UI they don't cover.
57
+ /* END USAGE */
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ const __TWEAKS_STYLE = `
61
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
62
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
63
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
64
+ background:rgba(250,249,247,.78);color:#29261b;
65
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
66
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
67
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
68
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
69
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
70
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
71
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
72
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
73
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
74
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
75
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
76
+ overflow-y:auto;overflow-x:hidden;min-height:0;
77
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
78
+ .twk-body::-webkit-scrollbar{width:8px}
79
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
80
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
81
+ border:2px solid transparent;background-clip:content-box}
82
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
83
+ border:2px solid transparent;background-clip:content-box}
84
+ .twk-row{display:flex;flex-direction:column;gap:5px}
85
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
86
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
87
+ color:rgba(41,38,27,.72)}
88
+ .twk-lbl>span:first-child{font-weight:500}
89
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
90
+
91
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
92
+ color:rgba(41,38,27,.45);padding:10px 0 0}
93
+ .twk-sect:first-child{padding-top:0}
94
+
95
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
96
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
97
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
98
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
99
+ select.twk-field{padding-right:22px;
100
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
101
+ background-repeat:no-repeat;background-position:right 8px center}
102
+
103
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
104
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
105
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
106
+ width:14px;height:14px;border-radius:50%;background:#fff;
107
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
108
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
109
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
110
+
111
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
112
+ background:rgba(0,0,0,.06);user-select:none}
113
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
114
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
115
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
116
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
117
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
118
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
119
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
120
+ overflow-wrap:anywhere}
121
+
122
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
123
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
124
+ .twk-toggle[data-on="1"]{background:#34c759}
125
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
126
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
127
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
128
+
129
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
130
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
131
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
132
+ user-select:none;padding-right:8px}
133
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
134
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
135
+ outline:none;color:inherit;-moz-appearance:textfield}
136
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
137
+ -webkit-appearance:none;margin:0}
138
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
139
+
140
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
141
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
142
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
143
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
144
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
145
+
146
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
147
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
148
+ background:transparent;flex-shrink:0}
149
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
150
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
151
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
152
+
153
+ .twk-chips{display:flex;gap:6px}
154
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
155
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
156
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
157
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
158
+ .twk-chip:hover{transform:translateY(-1px);
159
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
160
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
161
+ 0 2px 6px rgba(0,0,0,.15)}
162
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
163
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
164
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
165
+ .twk-chip>span>i:first-child{box-shadow:none}
166
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
167
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
168
+ `;
169
+
170
+ // ── useTweaks ───────────────────────────────────────────────��───────────────
171
+ // Single source of truth for tweak values. setTweak persists via the host
172
+ // (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
173
+ function useTweaks(defaults) {
174
+ const [values, setValues] = React.useState(defaults);
175
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
176
+ // useState-style call doesn't write a "[object Object]" key into the persisted
177
+ // JSON block.
178
+ const setTweak = React.useCallback((keyOrEdits, val) => {
179
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
180
+ ? keyOrEdits : { [keyOrEdits]: val };
181
+ setValues((prev) => ({ ...prev, ...edits }));
182
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
183
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
184
+ // can react — the parent message only reaches the host, not peers.
185
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
186
+ }, []);
187
+ return [values, setTweak];
188
+ }
189
+
190
+ // ── TweaksPanel ─────────────────────────────────────────────────────────────
191
+ // Floating shell. Registers the protocol listener BEFORE announcing
192
+ // availability — if the announce ran first, the host's activate could land
193
+ // before our handler exists and the toolbar toggle would silently no-op.
194
+ // The close button posts __edit_mode_dismissed so the host's toolbar toggle
195
+ // flips off in lockstep; the host echoes __deactivate_edit_mode back which
196
+ // is what actually hides the panel.
197
+ function TweaksPanel({ title = 'Tweaks', children }) {
198
+ const [open, setOpen] = React.useState(false);
199
+ const dragRef = React.useRef(null);
200
+ const offsetRef = React.useRef({ x: 16, y: 16 });
201
+ const PAD = 16;
202
+
203
+ const clampToViewport = React.useCallback(() => {
204
+ const panel = dragRef.current;
205
+ if (!panel) return;
206
+ const w = panel.offsetWidth, h = panel.offsetHeight;
207
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
208
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
209
+ offsetRef.current = {
210
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
211
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
212
+ };
213
+ panel.style.right = offsetRef.current.x + 'px';
214
+ panel.style.bottom = offsetRef.current.y + 'px';
215
+ }, []);
216
+
217
+ React.useEffect(() => {
218
+ if (!open) return;
219
+ clampToViewport();
220
+ if (typeof ResizeObserver === 'undefined') {
221
+ window.addEventListener('resize', clampToViewport);
222
+ return () => window.removeEventListener('resize', clampToViewport);
223
+ }
224
+ const ro = new ResizeObserver(clampToViewport);
225
+ ro.observe(document.documentElement);
226
+ return () => ro.disconnect();
227
+ }, [open, clampToViewport]);
228
+
229
+ React.useEffect(() => {
230
+ const onMsg = (e) => {
231
+ const t = e?.data?.type;
232
+ if (t === '__activate_edit_mode') setOpen(true);
233
+ else if (t === '__deactivate_edit_mode') setOpen(false);
234
+ };
235
+ window.addEventListener('message', onMsg);
236
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
237
+ return () => window.removeEventListener('message', onMsg);
238
+ }, []);
239
+
240
+ const dismiss = () => {
241
+ setOpen(false);
242
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
243
+ };
244
+
245
+ const onDragStart = (e) => {
246
+ const panel = dragRef.current;
247
+ if (!panel) return;
248
+ const r = panel.getBoundingClientRect();
249
+ const sx = e.clientX, sy = e.clientY;
250
+ const startRight = window.innerWidth - r.right;
251
+ const startBottom = window.innerHeight - r.bottom;
252
+ const move = (ev) => {
253
+ offsetRef.current = {
254
+ x: startRight - (ev.clientX - sx),
255
+ y: startBottom - (ev.clientY - sy),
256
+ };
257
+ clampToViewport();
258
+ };
259
+ const up = () => {
260
+ window.removeEventListener('mousemove', move);
261
+ window.removeEventListener('mouseup', up);
262
+ };
263
+ window.addEventListener('mousemove', move);
264
+ window.addEventListener('mouseup', up);
265
+ };
266
+
267
+ if (!open) return null;
268
+ return (
269
+ <>
270
+ <style>{__TWEAKS_STYLE}</style>
271
+ <div ref={dragRef} className="twk-panel" data-omelette-chrome=""
272
+ style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
273
+ <div className="twk-hd" onMouseDown={onDragStart}>
274
+ <b>{title}</b>
275
+ <button className="twk-x" aria-label="Close tweaks"
276
+ onMouseDown={(e) => e.stopPropagation()}
277
+ onClick={dismiss}>✕</button>
278
+ </div>
279
+ <div className="twk-body">
280
+ {children}
281
+ </div>
282
+ </div>
283
+ </>
284
+ );
285
+ }
286
+
287
+ // ── Layout helpers ──────────────────────────────────────────────────────────
288
+
289
+ function TweakSection({ label, children }) {
290
+ return (
291
+ <>
292
+ <div className="twk-sect">{label}</div>
293
+ {children}
294
+ </>
295
+ );
296
+ }
297
+
298
+ function TweakRow({ label, value, children, inline = false }) {
299
+ return (
300
+ <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
301
+ <div className="twk-lbl">
302
+ <span>{label}</span>
303
+ {value != null && <span className="twk-val">{value}</span>}
304
+ </div>
305
+ {children}
306
+ </div>
307
+ );
308
+ }
309
+
310
+ // ── Controls ────────────────────────────────────────────────────────────────
311
+
312
+ function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
313
+ return (
314
+ <TweakRow label={label} value={`${value}${unit}`}>
315
+ <input type="range" className="twk-slider" min={min} max={max} step={step}
316
+ value={value} onChange={(e) => onChange(Number(e.target.value))} />
317
+ </TweakRow>
318
+ );
319
+ }
320
+
321
+ function TweakToggle({ label, value, onChange }) {
322
+ return (
323
+ <div className="twk-row twk-row-h">
324
+ <div className="twk-lbl"><span>{label}</span></div>
325
+ <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
326
+ role="switch" aria-checked={!!value}
327
+ onClick={() => onChange(!value)}><i /></button>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ function TweakRadio({ label, value, options, onChange }) {
333
+ const trackRef = React.useRef(null);
334
+ const [dragging, setDragging] = React.useState(false);
335
+ // The active value is read by pointer-move handlers attached for the lifetime
336
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
337
+ const valueRef = React.useRef(value);
338
+ valueRef.current = value;
339
+
340
+ // Segments wrap mid-word once per-segment width runs out. The track is
341
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
342
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
343
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
344
+ // back to a dropdown rather than wrap.
345
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
346
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
347
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
348
+ if (!fitsAsSegments) {
349
+ // <select> emits strings — map back to the original option value so the
350
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
351
+ const resolve = (s) => {
352
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
353
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
354
+ };
355
+ return <TweakSelect label={label} value={value} options={options}
356
+ onChange={(s) => onChange(resolve(s))} />;
357
+ }
358
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
359
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
360
+ const n = opts.length;
361
+
362
+ const segAt = (clientX) => {
363
+ const r = trackRef.current.getBoundingClientRect();
364
+ const inner = r.width - 4;
365
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
366
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
367
+ };
368
+
369
+ const onPointerDown = (e) => {
370
+ setDragging(true);
371
+ const v0 = segAt(e.clientX);
372
+ if (v0 !== valueRef.current) onChange(v0);
373
+ const move = (ev) => {
374
+ if (!trackRef.current) return;
375
+ const v = segAt(ev.clientX);
376
+ if (v !== valueRef.current) onChange(v);
377
+ };
378
+ const up = () => {
379
+ setDragging(false);
380
+ window.removeEventListener('pointermove', move);
381
+ window.removeEventListener('pointerup', up);
382
+ };
383
+ window.addEventListener('pointermove', move);
384
+ window.addEventListener('pointerup', up);
385
+ };
386
+
387
+ return (
388
+ <TweakRow label={label}>
389
+ <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
390
+ className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
391
+ <div className="twk-seg-thumb"
392
+ style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
393
+ width: `calc((100% - 4px) / ${n})` }} />
394
+ {opts.map((o) => (
395
+ <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
396
+ {o.label}
397
+ </button>
398
+ ))}
399
+ </div>
400
+ </TweakRow>
401
+ );
402
+ }
403
+
404
+ function TweakSelect({ label, value, options, onChange }) {
405
+ return (
406
+ <TweakRow label={label}>
407
+ <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
408
+ {options.map((o) => {
409
+ const v = typeof o === 'object' ? o.value : o;
410
+ const l = typeof o === 'object' ? o.label : o;
411
+ return <option key={v} value={v}>{l}</option>;
412
+ })}
413
+ </select>
414
+ </TweakRow>
415
+ );
416
+ }
417
+
418
+ function TweakText({ label, value, placeholder, onChange }) {
419
+ return (
420
+ <TweakRow label={label}>
421
+ <input className="twk-field" type="text" value={value} placeholder={placeholder}
422
+ onChange={(e) => onChange(e.target.value)} />
423
+ </TweakRow>
424
+ );
425
+ }
426
+
427
+ function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
428
+ const clamp = (n) => {
429
+ if (min != null && n < min) return min;
430
+ if (max != null && n > max) return max;
431
+ return n;
432
+ };
433
+ const startRef = React.useRef({ x: 0, val: 0 });
434
+ const onScrubStart = (e) => {
435
+ e.preventDefault();
436
+ startRef.current = { x: e.clientX, val: value };
437
+ const decimals = (String(step).split('.')[1] || '').length;
438
+ const move = (ev) => {
439
+ const dx = ev.clientX - startRef.current.x;
440
+ const raw = startRef.current.val + dx * step;
441
+ const snapped = Math.round(raw / step) * step;
442
+ onChange(clamp(Number(snapped.toFixed(decimals))));
443
+ };
444
+ const up = () => {
445
+ window.removeEventListener('pointermove', move);
446
+ window.removeEventListener('pointerup', up);
447
+ };
448
+ window.addEventListener('pointermove', move);
449
+ window.addEventListener('pointerup', up);
450
+ };
451
+ return (
452
+ <div className="twk-num">
453
+ <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
454
+ <input type="number" value={value} min={min} max={max} step={step}
455
+ onChange={(e) => onChange(clamp(Number(e.target.value)))} />
456
+ {unit && <span className="twk-num-unit">{unit}</span>}
457
+ </div>
458
+ );
459
+ }
460
+
461
+ // Relative-luminance contrast pick — checkmarks drawn over a swatch need to
462
+ // read on both #111 and #fafafa without per-option configuration. Hex input
463
+ // only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
464
+ function __twkIsLight(hex) {
465
+ const h = String(hex).replace('#', '');
466
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
467
+ const n = parseInt(x.slice(0, 6), 16);
468
+ if (Number.isNaN(n)) return true;
469
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
470
+ return r * 299 + g * 587 + b * 114 > 148000;
471
+ }
472
+
473
+ const __TwkCheck = ({ light }) => (
474
+ <svg viewBox="0 0 14 14" aria-hidden="true">
475
+ <path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
476
+ strokeLinecap="round" strokeLinejoin="round"
477
+ stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
478
+ </svg>
479
+ );
480
+
481
+ // TweakColor — curated color/palette picker. Each option is either a single
482
+ // hex string or an array of 1-5 hex strings; the card adapts — a lone color
483
+ // renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
484
+ // rest stacked in a sharp column on the right. onChange emits the
485
+ // option in the shape it was passed (string stays string, array stays array).
486
+ // Without options it falls back to the native color input for back-compat.
487
+ function TweakColor({ label, value, options, onChange }) {
488
+ if (!options || !options.length) {
489
+ return (
490
+ <div className="twk-row twk-row-h">
491
+ <div className="twk-lbl"><span>{label}</span></div>
492
+ <input type="color" className="twk-swatch" value={value}
493
+ onChange={(e) => onChange(e.target.value)} />
494
+ </div>
495
+ );
496
+ }
497
+ // Native <input type=color> emits lowercase hex per the HTML spec, so
498
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
499
+ // which returns the primitive undefined (no .toLowerCase).
500
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
501
+ const cur = key(value);
502
+ return (
503
+ <TweakRow label={label}>
504
+ <div className="twk-chips" role="radiogroup">
505
+ {options.map((o, i) => {
506
+ const colors = Array.isArray(o) ? o : [o];
507
+ const [hero, ...rest] = colors;
508
+ const sup = rest.slice(0, 4);
509
+ const on = key(o) === cur;
510
+ return (
511
+ <button key={i} type="button" className="twk-chip" role="radio"
512
+ aria-checked={on} data-on={on ? '1' : '0'}
513
+ aria-label={colors.join(', ')} title={colors.join(' · ')}
514
+ style={{ background: hero }}
515
+ onClick={() => onChange(o)}>
516
+ {sup.length > 0 && (
517
+ <span>
518
+ {sup.map((c, j) => <i key={j} style={{ background: c }} />)}
519
+ </span>
520
+ )}
521
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
522
+ </button>
523
+ );
524
+ })}
525
+ </div>
526
+ </TweakRow>
527
+ );
528
+ }
529
+
530
+ function TweakButton({ label, onClick, secondary = false }) {
531
+ return (
532
+ <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
533
+ onClick={onClick}>{label}</button>
534
+ );
535
+ }
536
+
537
+ Object.assign(window, {
538
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
539
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
540
+ TweakText, TweakNumber, TweakColor, TweakButton,
541
+ });