screengraft 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/screengraft.js +82 -0
- package/mcp/server.py +322 -0
- package/package.json +59 -0
- package/scripts/detect.py +677 -0
- package/scripts/grade.py +180 -0
- package/scripts/launch.sh +69 -0
- package/scripts/preflight.py +118 -0
- package/scripts/requirements.txt +2 -0
- package/scripts/scan.py +92 -0
- package/scripts/stop.sh +21 -0
- package/scripts/ui.py +503 -0
- package/scripts/warp.py +247 -0
- package/skills/inject-screenshot/SKILL.md +130 -0
- package/ui/fonts/OFL.txt +93 -0
- package/ui/fonts/mona-sans-wordmark.woff2 +0 -0
- package/ui/index.html +1902 -0
package/ui/index.html
ADDED
|
@@ -0,0 +1,1902 @@
|
|
|
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">
|
|
6
|
+
<title>screengraft</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Tokens — see the project's visual-language notes.
|
|
9
|
+
SURFACES STEP: well (recessed) < bg < sunk < card < raise < float. The
|
|
10
|
+
previous build used one tone for the top bar, rail, dock, popovers AND
|
|
11
|
+
buttons, so nothing read as a control sitting on a panel — it read as
|
|
12
|
+
regions of one sheet divided by lines. Depth now comes from tone steps
|
|
13
|
+
plus a 1px top inner highlight (--hi), which is what actually separates
|
|
14
|
+
a raised control from a div in a dark UI; shadow is reserved for things
|
|
15
|
+
that genuinely float.
|
|
16
|
+
Status colours keep three distinct LUMINANCE steps, not three hues —
|
|
17
|
+
measured, --ok and --warn once differed by 1.01:1, invisible in greyscale
|
|
18
|
+
and to red-green colour blindness. --line divides; --edge bounds anything
|
|
19
|
+
clickable and clears the 3:1 non-text bar. See the project's visual-critique notes */
|
|
20
|
+
:root{
|
|
21
|
+
/* Ported by hand from the project's Figma design file
|
|
22
|
+
(4 Sep 2026). Figma is where visual changes start; there is no
|
|
23
|
+
sync, so these names mirror the variable collection deliberately:
|
|
24
|
+
surface/* -> --well/--bg/--sunk/--card/--raise/--raise-hi,
|
|
25
|
+
border/* -> --line/--edge/--edge-hi, text/* -> --ink/--mute/--faint.
|
|
26
|
+
|
|
27
|
+
DARK ONLY. The light palette was dropped rather than invented: the
|
|
28
|
+
design is dark throughout, and a light counterpart for a #ec4c13 accent
|
|
29
|
+
needs a different shade to pass contrast on white — a decision that
|
|
30
|
+
belongs in Figma, not guessed here. */
|
|
31
|
+
--well:#0a0a0b; --bg:#111112; --sunk:#141415; --card:#161618;
|
|
32
|
+
/* A four-step raised ladder, added in the design file to fix two collapsed chip states: hover used to
|
|
33
|
+
step DOWN to --float and selected-hover had collapsed onto --edge. */
|
|
34
|
+
--raise:#2b2b30; --raise-mid:#333337; --raise-hi:#3a3a40; --raise-highest:#46464e;
|
|
35
|
+
--panel:#161618;
|
|
36
|
+
/* surface/float is a real token (#26262b, the chip hover surface). The
|
|
37
|
+
canvas overlay is a separate thing and gets its own name. */
|
|
38
|
+
--float:#26262b;
|
|
39
|
+
--overlay:rgba(17,17,18,.98);
|
|
40
|
+
--ink:#e9ecf1; --mute:#8f939a; --faint:#8f939ab2;
|
|
41
|
+
--line:#1d1e20; --edge:#3a3b41; --edge-mid:#494a50; --edge-hi:#6d6f79;
|
|
42
|
+
|
|
43
|
+
/* Accent, per the Button variant matrix (4:14). Primary at REST is the
|
|
44
|
+
accent at 85%, not solid — solid is the hover state. */
|
|
45
|
+
/* accent/acc + accent/acc_btn_rest re-picked in Figma 5 Sep:
|
|
46
|
+
#ec4c13 -> #f23b0d. --acc-soft is derived from the accent, so it follows. */
|
|
47
|
+
--acc:#f23b0d; --acc-btn-rest:rgba(242,59,13,.85); --acc-press:#bb2b00;
|
|
48
|
+
--acc-stroke:#ff6833; --acc-ink:#fbf9f9; --acc-soft:rgba(242,59,13,.14);
|
|
49
|
+
/* Neutral button hover/pressed are explicit surfaces in Figma, not a
|
|
50
|
+
brightness filter on the rest state. */
|
|
51
|
+
--btn-hover:#333337; --btn-hover-edge:#3f4045;
|
|
52
|
+
--btn-press:#1c1c20; --btn-press-edge:#2b2c31;
|
|
53
|
+
/* Accent disabled keeps the label READABLE rather than fading the whole
|
|
54
|
+
button — the Button component in Figma says so explicitly. */
|
|
55
|
+
--acc-dis:#bb2b00b2; --acc-dis-edge:#ff6833b2; --acc-dis-ink:#fbf9f966;
|
|
56
|
+
|
|
57
|
+
--warn:#f0c375;
|
|
58
|
+
/* --ok is status/ok, re-picked in the design file (#8fe3c0 -> #4fbf8b).
|
|
59
|
+
--err has no counterpart in the collection (the Status pill has only OK and
|
|
60
|
+
Warn variants), so it stays chosen here.
|
|
61
|
+
|
|
62
|
+
Status is encoded by glyph + weight + LUMINANCE, never hue alone. What the
|
|
63
|
+
new green bought, measured (scripts/contrast_audit.py, before -> after):
|
|
64
|
+
ok|warn normal 1.09 -> 1.39 deuteranopia 1.01 -> 1.67 trit 1.21 -> 1.99
|
|
65
|
+
That pair is the one an earlier finding raised, and it is the one that matters: ok and
|
|
66
|
+
warn are the two outcomes of the SAME detection, so they appear in
|
|
67
|
+
sequence and get compared.
|
|
68
|
+
|
|
69
|
+
Two shortfalls remain, both accepted with reasons, both still printed by
|
|
70
|
+
the audit on every run:
|
|
71
|
+
- ok|warn under PROTANOPIA is 1.24, essentially unmoved (1.23 before). A
|
|
72
|
+
red-blind viewer sees the amber lose its red and drift toward the green.
|
|
73
|
+
- ok|err regressed (2.45 -> 1.61 normal, 1.62 -> 1.01 tritanopia) because
|
|
74
|
+
the new green sits in the same luminance band as any usable error red.
|
|
75
|
+
Searched, not assumed: of 7056 candidate reds/pinks meeting 4.5:1 on
|
|
76
|
+
--card, exactly 59 clear the 1.35 floor and every one is a saturated
|
|
77
|
+
magenta — not an error colour, and it fights the accent. Accepted
|
|
78
|
+
because ok and err are mutually exclusive states of one pill and are
|
|
79
|
+
never on screen together.
|
|
80
|
+
Don't "fix" either by moving --ok back; that reopens the pair that counts. */
|
|
81
|
+
--ok:#4fbf8b; --err:#e5534b;
|
|
82
|
+
|
|
83
|
+
--hi:inset 0 1px 0 rgba(255,255,255,.06);
|
|
84
|
+
--shadow:0 16px 40px rgba(0,0,0,.55), 0 3px 10px rgba(0,0,0,.4);
|
|
85
|
+
--r-sm:6px; --r-md:8px; --r-lg:12px;
|
|
86
|
+
--s2:8px; --s3:12px; --s4:16px; --s6:24px; --s9:48px;
|
|
87
|
+
/* 120ms originally, per the visual-language notes. asked for a bit
|
|
88
|
+
slower on 5 Sep — one token, so retuning is a one-line change. */
|
|
89
|
+
--t:180ms cubic-bezier(.2,0,0,1);
|
|
90
|
+
--t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
|
|
91
|
+
/* Every control animates the SAME four properties on the same curve. Kept as
|
|
92
|
+
one token because the failure mode here is a control that transitions its
|
|
93
|
+
border but snaps its fill. */
|
|
94
|
+
--t-all:background-color var(--t), border-color var(--t), box-shadow var(--t), color var(--t);
|
|
95
|
+
}
|
|
96
|
+
@media (prefers-reduced-motion:reduce){ *{transition:none !important} }
|
|
97
|
+
*{box-sizing:border-box}
|
|
98
|
+
html,body{height:100%}
|
|
99
|
+
body{margin:0;background:var(--bg);color:var(--ink);
|
|
100
|
+
font:13px/1.45 -apple-system,BlinkMacSystemFont,"Inter",system-ui,sans-serif;overflow:hidden}
|
|
101
|
+
|
|
102
|
+
/* The shell IS the viewport. Nothing scrolls except the canvas, so the
|
|
103
|
+
fitting surface can never be pushed off-screen by finished work. */
|
|
104
|
+
.app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-height:0;
|
|
105
|
+
padding-bottom:32px;background:var(--bg)}
|
|
106
|
+
|
|
107
|
+
.topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
|
|
108
|
+
background:var(--card);border-bottom:1px solid var(--line);
|
|
109
|
+
box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0 rgba(0,0,0,.25)}
|
|
110
|
+
.tb-left{display:flex;align-items:center;gap:var(--s9)}
|
|
111
|
+
.tb-chips{display:flex;align-items:center;gap:var(--s2)}
|
|
112
|
+
.tb-arrow{color:var(--mute)}
|
|
113
|
+
.tb-actions{display:flex;align-items:center;gap:var(--s2)}
|
|
114
|
+
/* Mona Sans is the brand face in Figma; it is not installed on a stock Mac,
|
|
115
|
+
so it degrades to the system UI face rather than to a serif. */
|
|
116
|
+
/* The wordmark ships WITH the plugin. Mona Sans is the brand face in Figma and
|
|
117
|
+
it happened to be installed locally, which was the only reason it ever rendered —
|
|
118
|
+
on any other designer's machine the logo silently became SF Pro Display, ~24px
|
|
119
|
+
narrower. This is a variable subset containing only the letters of
|
|
120
|
+
"Screengraft" (6.2KB, vs 349KB for the full family), inlined so the page stays
|
|
121
|
+
a single self-contained file and needs no new server route. SIL OFL; the
|
|
122
|
+
licence travels in ui/fonts/OFL.txt.
|
|
123
|
+
Figma 6:5 is Mona Sans SemiBold 20px at wdth 125. font-stretch:125% does NOT
|
|
124
|
+
reach the wdth axis — measured 115.81px against the real instance's 128.26px —
|
|
125
|
+
so the axis is driven explicitly. */
|
|
126
|
+
@font-face{
|
|
127
|
+
font-family:"Mona Sans Wordmark";
|
|
128
|
+
src:url(data:font/woff2;base64,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) format("woff2");
|
|
129
|
+
font-weight:100 900; font-stretch:75% 125%; font-display:block;
|
|
130
|
+
}
|
|
131
|
+
.brand{font-family:"Mona Sans Wordmark","Mona Sans","SF Pro Display",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
132
|
+
font-weight:600;font-size:20px;font-stretch:125%;letter-spacing:.1px;
|
|
133
|
+
font-variation-settings:"wdth" 125}
|
|
134
|
+
.brand span{color:var(--mute);font-weight:400;margin-left:8px;font-size:12px;
|
|
135
|
+
font-variant-numeric:tabular-nums}
|
|
136
|
+
.spacer{flex:1}
|
|
137
|
+
.gap-3{width:var(--s3)}
|
|
138
|
+
/* One state model for every control: rest / hover / active / focus /
|
|
139
|
+
selected / disabled. Active genuinely depresses (darker than rest, no top
|
|
140
|
+
highlight, half-pixel down) instead of doing nothing. */
|
|
141
|
+
/* ------------------------------------------------------------------
|
|
142
|
+
Button — the full variant matrix from Figma 4:14.
|
|
143
|
+
Default : raise surface, hover #333337/#3f4045, pressed #1c1c20/#2b2c31
|
|
144
|
+
Primary : accent at 85%, hover solid #ec4c13, pressed #bb2b00
|
|
145
|
+
Sizes : Md 28px/12px/13px (the default), Sm 24px/9px/12px.
|
|
146
|
+
Hover and pressed are explicit SURFACES in the design, not a brightness
|
|
147
|
+
filter over the rest state — a filter shifts hue and cannot be measured
|
|
148
|
+
against a token.
|
|
149
|
+
------------------------------------------------------------------ */
|
|
150
|
+
button{font:inherit;color:var(--ink);background:var(--raise);
|
|
151
|
+
border:1px solid var(--edge);border-radius:var(--r-sm);
|
|
152
|
+
padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;box-shadow:var(--hi);
|
|
153
|
+
transition:var(--t-all), transform var(--t)}
|
|
154
|
+
button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
|
|
155
|
+
button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
|
|
156
|
+
box-shadow:none;transform:translateY(.5px)}
|
|
157
|
+
button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
|
|
158
|
+
/* Default disabled keeps the raised surface and dims only the LABEL — the
|
|
159
|
+
component description is explicit that opacity is not the mechanism. */
|
|
160
|
+
button[disabled]{background:var(--raise);border-color:var(--edge);
|
|
161
|
+
color:var(--faint);cursor:default;font-weight:400;
|
|
162
|
+
box-shadow:none;transform:none}
|
|
163
|
+
button[disabled]:hover{background:var(--raise);border-color:var(--edge)}
|
|
164
|
+
button.sm{height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
|
|
165
|
+
button.sm[disabled]{color:var(--mute)}
|
|
166
|
+
|
|
167
|
+
/* Primary. The accent means exactly one thing: the next action, so only one
|
|
168
|
+
of these is lit at a time (see the accent hand-off in the JS). */
|
|
169
|
+
button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
|
|
170
|
+
border-color:var(--acc-stroke);font-weight:600;
|
|
171
|
+
border-radius:var(--r-md);box-shadow:none}
|
|
172
|
+
button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
|
|
173
|
+
button.primary:active{background:var(--acc-press);border-color:var(--acc-dis-edge);
|
|
174
|
+
transform:translateY(.5px)}
|
|
175
|
+
button.primary.sm{border-radius:var(--r-sm)}
|
|
176
|
+
/* An accent-family button that is enabled but is NOT the next action reads
|
|
177
|
+
as an ordinary neutral control. */
|
|
178
|
+
button.accent:not(:disabled):not(.primary){
|
|
179
|
+
background:var(--raise); color:var(--ink); border-color:var(--edge);
|
|
180
|
+
font-weight:400; box-shadow:var(--hi); border-radius:var(--r-md)}
|
|
181
|
+
button.accent:disabled,button.primary:disabled{
|
|
182
|
+
background:var(--acc-dis); border-color:var(--acc-dis-edge);
|
|
183
|
+
color:var(--acc-dis-ink); font-weight:600;
|
|
184
|
+
border-radius:var(--r-md); box-shadow:none; opacity:1}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------
|
|
187
|
+
Chip (Figma 5:6). Selection is a raised neutral surface plus a lighter
|
|
188
|
+
border — deliberately NOT accent-filled, because the accent is reserved
|
|
189
|
+
for the next action. The old accent ring is gone for that reason.
|
|
190
|
+
------------------------------------------------------------------ */
|
|
191
|
+
.chip{border-radius:999px;padding:0 var(--s3);height:28px;font-size:12px;
|
|
192
|
+
background:var(--raise);border-color:var(--edge);box-shadow:var(--hi)}
|
|
193
|
+
/* Chip state matrix, ported from the Chip component set. Every state now
|
|
194
|
+
steps in the direction it should: hover UP from rest, pressed DOWN from it,
|
|
195
|
+
and selected-hover up again from selected-rest. Before the new tokens
|
|
196
|
+
existed, unselected hover stepped down onto --float and selected hover had
|
|
197
|
+
converged with --edge — reported and fixed. */
|
|
198
|
+
.chip:hover{background:var(--raise-mid);border-color:var(--edge-mid)}
|
|
199
|
+
.chip:active{background:var(--raise);border-color:var(--edge-mid)}
|
|
200
|
+
.chip.on{background:var(--raise-hi);border-color:var(--edge-hi);
|
|
201
|
+
color:var(--ink);font-weight:600;box-shadow:var(--hi)}
|
|
202
|
+
.chip.on:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
203
|
+
.chip.on:active{background:var(--raise-mid);border-color:var(--edge-hi)}
|
|
204
|
+
.chip[disabled]{opacity:.5;color:var(--mute);background:var(--raise);border-color:var(--edge)}
|
|
205
|
+
.chip.on[disabled]{background:var(--raise-hi);border-color:var(--edge-hi)}
|
|
206
|
+
|
|
207
|
+
/* A two-way choice should LOOK like one: a recessed track with a raised
|
|
208
|
+
thumb, not two identical buttons that happen to be adjacent. */
|
|
209
|
+
/* Segmented control — Figma 5:21 / 7:68: 146 x 28 overall, which is
|
|
210
|
+
2 padding + 70 + 2 gap + 70 + 2 padding. It is a CONTROL, not a field, so
|
|
211
|
+
it hugs that width. `inline-flex` alone did not achieve this: the rail is
|
|
212
|
+
a grid and stretches its children, so the track spread to the full 272px
|
|
213
|
+
column and read like a segmented *bar*. justify-self:start is what
|
|
214
|
+
actually holds it to its content width. */
|
|
215
|
+
|
|
216
|
+
/* A stepper should look joined. */
|
|
217
|
+
/* Stepper (Figma 28:816). NOT a bespoke control: it is ordinary Default
|
|
218
|
+
buttons butted together, so every state — hover, pressed, disabled — is
|
|
219
|
+
the button's own. The previous version was borderless, transparent and
|
|
220
|
+
24px tall, which is exactly why +/- read as a different species from the
|
|
221
|
+
buttons beside them. Only the corner radii and the shared middle border
|
|
222
|
+
are special. */
|
|
223
|
+
.stepper{display:inline-flex;align-items:center}
|
|
224
|
+
.stepper button{border-radius:0}
|
|
225
|
+
.stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
|
|
226
|
+
.stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
|
|
227
|
+
/* One border between neighbours rather than two stacked. */
|
|
228
|
+
.stepper button + button{margin-left:-1px}
|
|
229
|
+
.stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
|
|
230
|
+
/* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
|
|
231
|
+
.dock-step button{padding:0 var(--s3)}
|
|
232
|
+
/* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
|
|
233
|
+
than inherited from the base button rule, so the filled state is explicit and
|
|
234
|
+
the .is-empty override below has something definite to override.
|
|
235
|
+
NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
|
|
236
|
+
centred placeholder-paragraph class (padding:24px), and the chip was
|
|
237
|
+
silently inheriting it and rendering 50px tall instead of 32. */
|
|
238
|
+
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:300px;
|
|
239
|
+
height:32px;padding:0 12px 0 6px;border-radius:999px;
|
|
240
|
+
background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
|
|
241
|
+
.inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
|
|
242
|
+
background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}
|
|
243
|
+
.inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
244
|
+
/* Empty is the SAME box as filled — 32px tall, same padding, same 22px
|
|
245
|
+
avatar. Only the surface, border style and label colour change. It was
|
|
246
|
+
reading oversized because it had no surface to sit in. */
|
|
247
|
+
.inputchip.is-empty{background:var(--card);border-style:dashed;
|
|
248
|
+
color:var(--mute);box-shadow:none}
|
|
249
|
+
.inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
|
|
250
|
+
|
|
251
|
+
/* Status carries meaning in THREE channels: glyph, weight, luminance —
|
|
252
|
+
never hue alone. */
|
|
253
|
+
/* Status pill (Figma 5:13): h22, px10, gap 6, sunk surface, line border.
|
|
254
|
+
Glyph 11px and label 12px; WHICH of the two is semibold flips between OK
|
|
255
|
+
and Warn, which is a third encoding channel on top of glyph and colour. */
|
|
256
|
+
.status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--mute);
|
|
257
|
+
height:22px;padding:0 10px;border-radius:999px;background:var(--sunk);
|
|
258
|
+
border:1px solid var(--line);box-shadow:none;transition:var(--t-all)}
|
|
259
|
+
.status::before{font-size:11px}
|
|
260
|
+
.status.ok{color:var(--ok);font-weight:400} .status.ok::before{content:"\2713";font-weight:600}
|
|
261
|
+
.status.warn{color:var(--warn);font-weight:600} .status.warn::before{content:"!";font-weight:400}
|
|
262
|
+
.status.err{color:var(--err);font-weight:600} .status.err::before{content:"\00d7"}
|
|
263
|
+
/* A real spinner, not a static glyph. The old "\22ef" ellipsis never moved,
|
|
264
|
+
so a wait that genuinely was in progress looked identical to a dead UI —
|
|
265
|
+
which is exactly what it felt like while the agent wasn't running. */
|
|
266
|
+
.status.busy::before{content:"";width:9px;height:9px;border-radius:50%;
|
|
267
|
+
border:1.5px solid var(--line);border-top-color:var(--accent);
|
|
268
|
+
animation:sg-spin .7s linear infinite}
|
|
269
|
+
@keyframes sg-spin{to{transform:rotate(360deg)}}
|
|
270
|
+
@media (prefers-reduced-motion:reduce){
|
|
271
|
+
.status.busy::before{animation-duration:2.4s}
|
|
272
|
+
}
|
|
273
|
+
.status .el{opacity:.65;font-variant-numeric:tabular-nums}
|
|
274
|
+
.hintline{font-size:11px;color:var(--mute);margin-top:6px;line-height:1.5}
|
|
275
|
+
.hintline b{color:var(--fg);font-weight:600}
|
|
276
|
+
|
|
277
|
+
/* 304px wrapped "desktop" onto a second line. The rail is sized to the
|
|
278
|
+
content it must hold — four device chips in one row — rather than to a
|
|
279
|
+
round number. */
|
|
280
|
+
.stage{display:grid;grid-template-columns:minmax(0,1fr) 344px;min-height:0;overflow:hidden}
|
|
281
|
+
.stage.compare{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px}
|
|
282
|
+
#types{flex-wrap:nowrap}
|
|
283
|
+
.pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
|
|
284
|
+
position:relative; /* the frame the corner bar is anchored to */
|
|
285
|
+
border-right:1px solid var(--line)}
|
|
286
|
+
.panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
|
|
287
|
+
box-shadow:inset 0 -1px 0 var(--line);font-size:12px;flex-wrap:wrap;min-height:48px}
|
|
288
|
+
/* The Fit header pads 12px vertically around 28px controls (Figma 7:13),
|
|
289
|
+
which makes it 52px — deliberately taller than the Result header's 48. */
|
|
290
|
+
/* nowrap matches the design, but a long detection message must not be able
|
|
291
|
+
to widen the grid column — that is the v0.3.1 blowout, where an
|
|
292
|
+
unconstrained child forced the fixed rail off-screen. The pill truncates
|
|
293
|
+
instead; min-width:0 is what actually lets it. */
|
|
294
|
+
.fit-head{justify-content:space-between;flex-wrap:nowrap}
|
|
295
|
+
/* Both pane headers are the same height: they sit side by side, and 4px of
|
|
296
|
+
difference reads as a misalignment rather than as a hierarchy. */
|
|
297
|
+
.panehead{padding:var(--s3) var(--s4);min-height:52px}
|
|
298
|
+
.fit-head > .status{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
|
|
299
|
+
display:inline-block;line-height:20px}
|
|
300
|
+
.fit-head > .status::before{margin-right:6px}
|
|
301
|
+
.ph-left{display:flex;align-items:center;gap:var(--s6);min-width:0}
|
|
302
|
+
.ph-left-1{display:flex;align-items:center;gap:var(--s4)}
|
|
303
|
+
.ph-left-2{display:flex;align-items:center;gap:var(--s2)}
|
|
304
|
+
.zoomval{color:var(--mute);font-size:12px;font-variant-numeric:tabular-nums;min-width:38px}
|
|
305
|
+
.lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
|
|
306
|
+
.scroller{overflow:auto;background:var(--well);display:grid;
|
|
307
|
+
min-height:0;min-width:0;
|
|
308
|
+
box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
309
|
+
canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto}
|
|
310
|
+
|
|
311
|
+
/* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
|
|
312
|
+
inside the scroller would drift with pan; fixed-to-the-pane absolute
|
|
313
|
+
keeps it where the eye expects while the picture moves under it. */
|
|
314
|
+
/* max-width so a narrow pane (compare view on a small window) can never
|
|
315
|
+
push the bar outside the frame it is anchored to. */
|
|
316
|
+
.cvbar{position:absolute;left:50%;transform:translateX(-50%);bottom:16px;z-index:3;
|
|
317
|
+
max-width:calc(100% - 32px);overflow-x:auto;
|
|
318
|
+
display:flex;align-items:center;gap:var(--s6);
|
|
319
|
+
padding:var(--s2) var(--s2) var(--s2) var(--s3);
|
|
320
|
+
background:var(--overlay);backdrop-filter:blur(5px);
|
|
321
|
+
-webkit-backdrop-filter:blur(5px);
|
|
322
|
+
border-radius:var(--r-lg);box-shadow:var(--shadow)}
|
|
323
|
+
.cvbar-actions{display:flex;align-items:center;gap:var(--s2);flex-wrap:nowrap}
|
|
324
|
+
.cvbar{white-space:nowrap}
|
|
325
|
+
/* `hidden` is a UA display:none, which any explicit display beats — and
|
|
326
|
+
.cvbar sets display:flex. Without this the bar stays visible while the
|
|
327
|
+
photo is chosen but the screenshot is not, offering corners to fit
|
|
328
|
+
nothing. */
|
|
329
|
+
.cvbar[hidden]{display:none}
|
|
330
|
+
|
|
331
|
+
/* Preview popover: the composite at whatever size fits, on the sunk surface
|
|
332
|
+
so the image edge is unambiguous against the panel. */
|
|
333
|
+
#previewWrap{margin-top:10px;background:var(--well);border-radius:var(--r-md);
|
|
334
|
+
box-shadow:inset 0 1px 3px rgba(0,0,0,.5);display:grid;
|
|
335
|
+
place-items:center;min-height:200px;max-height:70vh;overflow:auto}
|
|
336
|
+
#previewImg{display:block;max-width:100%;height:auto}
|
|
337
|
+
|
|
338
|
+
/* Switch — the full 8-variant matrix from Figma 17:61
|
|
339
|
+
(State Rest/Hover/Pressed/Disabled x Selected No/Yes).
|
|
340
|
+
|
|
341
|
+
The correction that mattered: THE TRACK NEVER FILLS WITH ACCENT. It stays
|
|
342
|
+
surface/bg in every state; selection is carried by the HANDLE turning
|
|
343
|
+
accent and the border stepping up to edge-hi. The previous version filled
|
|
344
|
+
the track orange and made the handle white, which is why it did not look
|
|
345
|
+
like the design.
|
|
346
|
+
|
|
347
|
+
Geometry: track 47x22 r5; handle 18x18 r3 inset 2px, and it STRETCHES to
|
|
348
|
+
20px while pressed — that stretch is the press feedback, in place of a
|
|
349
|
+
colour change. The state word rides opposite the handle. */
|
|
350
|
+
/* Switch — rebuilt from the Figma component set 17:61 (8 variants:
|
|
351
|
+
State × Selected). The previous build was a 47×22 track with an 18px square
|
|
352
|
+
handle and an ON/OFF text label; none of that is in the component. What the
|
|
353
|
+
component actually specifies:
|
|
354
|
+
hit area 48×28, track 48×22 centred, radius 6, 1px border
|
|
355
|
+
handle 24×20, radius 4, inset 1px, left when off / right when on
|
|
356
|
+
grip a bar ON the handle — 4×6 dark when off, 2×12 accent when on
|
|
357
|
+
Pressed widens the handle to 26px (it squashes toward the far side) and
|
|
358
|
+
nudges the grip 1px with it. There is no text label in the component; state
|
|
359
|
+
reads from colour + handle side + grip. role=switch + aria-checked still
|
|
360
|
+
carry it for assistive tech, and the visible label above the control says
|
|
361
|
+
what it toggles. */
|
|
362
|
+
button.switch{background:none;border:none;box-shadow:none;padding:0;
|
|
363
|
+
width:48px;height:28px;display:inline-flex;align-items:center}
|
|
364
|
+
button.switch:hover{background:none}
|
|
365
|
+
button.switch:active{background:none;border:none;box-shadow:none;transform:none}
|
|
366
|
+
|
|
367
|
+
.sw-track{position:relative;display:block;width:48px;height:22px;border-radius:6px;
|
|
368
|
+
background:var(--bg);border:1px solid var(--edge);
|
|
369
|
+
transition:var(--t-all), box-shadow var(--t)}
|
|
370
|
+
.sw-handle{position:absolute;top:1px;bottom:1px;left:1px;width:24px;border-radius:4px;
|
|
371
|
+
background:var(--raise-hi);
|
|
372
|
+
transition:left var(--t), right var(--t), width var(--t), background-color var(--t), opacity var(--t)}
|
|
373
|
+
.sw-grip{position:absolute;border-radius:1px;background:var(--sunk);
|
|
374
|
+
left:11px;width:4px;top:8px;bottom:8px;
|
|
375
|
+
transition:left var(--t), width var(--t), top var(--t), bottom var(--t), background-color var(--t)}
|
|
376
|
+
|
|
377
|
+
/* --- OFF ------------------------------------------------------------- */
|
|
378
|
+
button.switch:hover .sw-handle{background:var(--float)}
|
|
379
|
+
button.switch:active .sw-handle{width:26px;background:var(--raise-hi)}
|
|
380
|
+
button.switch:active .sw-grip{left:12px}
|
|
381
|
+
|
|
382
|
+
/* --- ON -------------------------------------------------------------- */
|
|
383
|
+
button.switch[aria-checked="true"] .sw-track{background:var(--acc-btn-rest);border-color:var(--acc-stroke)}
|
|
384
|
+
button.switch[aria-checked="true"] .sw-handle{left:auto;right:1px;background:var(--acc-ink)}
|
|
385
|
+
button.switch[aria-checked="true"] .sw-grip{left:32px;width:2px;top:5px;bottom:5px;
|
|
386
|
+
background:var(--acc-btn-rest)}
|
|
387
|
+
button.switch[aria-checked="true"]:hover .sw-track{background:var(--acc);border-color:var(--acc-stroke)}
|
|
388
|
+
button.switch[aria-checked="true"]:hover .sw-grip{background:var(--acc)}
|
|
389
|
+
button.switch[aria-checked="true"]:active .sw-track{background:var(--acc-press);border-color:var(--acc-stroke)}
|
|
390
|
+
button.switch[aria-checked="true"]:active .sw-handle{width:26px;opacity:.8}
|
|
391
|
+
button.switch[aria-checked="true"]:active .sw-grip{left:31px;background:var(--acc-press)}
|
|
392
|
+
|
|
393
|
+
/* --- disabled --------------------------------------------------------
|
|
394
|
+
The component uses opacity here, against the project's usual rule. Kept as
|
|
395
|
+
designed rather than reinvented; it is a control you cannot operate, which
|
|
396
|
+
is the case WCAG 1.4.3 exempts. */
|
|
397
|
+
button.switch[disabled]{cursor:default}
|
|
398
|
+
button.switch[disabled] .sw-track{background:var(--bg);border-color:var(--line)}
|
|
399
|
+
button.switch[disabled] .sw-handle{background:var(--raise-hi);opacity:.4}
|
|
400
|
+
button.switch[disabled][aria-checked="true"] .sw-handle{background:var(--acc-dis);opacity:.4}
|
|
401
|
+
button.switch[disabled][aria-checked="true"] .sw-grip{display:none}
|
|
402
|
+
|
|
403
|
+
button.switch:focus-visible{outline:none}
|
|
404
|
+
button.switch:focus-visible .sw-track{box-shadow:0 0 0 3px var(--acc-soft);border-color:var(--acc)}
|
|
405
|
+
|
|
406
|
+
#outPane{display:none}
|
|
407
|
+
.stage.compare #outPane{display:grid}
|
|
408
|
+
#outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
409
|
+
#outImg{display:block;margin:auto}
|
|
410
|
+
.empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
|
|
411
|
+
|
|
412
|
+
.rail{min-width:0;overflow:auto;padding:0;display:grid;align-content:start;background:var(--card)}
|
|
413
|
+
.rail > div{padding:var(--s4);border-bottom:1px solid var(--line);display:grid;gap:0;align-content:start}
|
|
414
|
+
.sect-top + *{margin-top:var(--s4)}
|
|
415
|
+
.rail > div:last-child{border-bottom:none}
|
|
416
|
+
.rail h4{margin:0;font-size:11px;color:var(--mute);text-transform:uppercase;
|
|
417
|
+
letter-spacing:.06em;font-weight:600}
|
|
418
|
+
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
419
|
+
|
|
420
|
+
/* --- rail sections (Figma 7:46) -------------------------------------
|
|
421
|
+
Every section but Device is a switch with a body that only exists when the
|
|
422
|
+
switch is on. The description that used to sit permanently under each
|
|
423
|
+
control moved into a tooltip on the info icon: five paragraphs of
|
|
424
|
+
explanation made the rail a wall of prose you stopped reading after the
|
|
425
|
+
first fit, which is the opposite of what a reference panel is for. */
|
|
426
|
+
.sect-top{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}
|
|
427
|
+
.sect-left{display:flex;align-items:center;gap:var(--s2);min-width:0}
|
|
428
|
+
/* Collapsible body. `display:none` can't be transitioned and neither can
|
|
429
|
+
height:auto, so the height is MEASURED and animated in pixels by
|
|
430
|
+
setSectionOpen().
|
|
431
|
+
The obvious modern answer — transitioning grid-template-rows 1fr -> 0fr —
|
|
432
|
+
was tried and abandoned: WebKit reports support via CSS.supports() and then
|
|
433
|
+
leaves the transition stuck at the start value, so a section would collapse
|
|
434
|
+
once and afterwards just sit there open with data-on="0". Verified it was
|
|
435
|
+
the transition and not the rule: with `transition:none` the same rule
|
|
436
|
+
collapsed instantly. A measured pixel height is duller and always works.
|
|
437
|
+
Max-height is set back to `none` once open, so a body whose content grows
|
|
438
|
+
(a longer caption) is not clipped by a stale measurement.
|
|
439
|
+
The section's own gap is 0 and the spacing lives on the body's margin, or a
|
|
440
|
+
collapsed section leaves a 16px hole where its body used to be. */
|
|
441
|
+
.sect-body{overflow:hidden;max-height:none;
|
|
442
|
+
transition:max-height var(--t-pop), opacity var(--t), margin-top var(--t-pop)}
|
|
443
|
+
.sect-body-in{display:grid;gap:var(--s4);align-content:start}
|
|
444
|
+
.sect[data-on="0"] .sect-body{opacity:0;margin-top:0}
|
|
445
|
+
@media (prefers-reduced-motion:reduce){ .sect-body{transition:opacity 120ms} }
|
|
446
|
+
|
|
447
|
+
/* Info icon + tooltip. A button, not a span: it has to be reachable by
|
|
448
|
+
keyboard, and the tooltip shows on focus as well as hover — a hover-only
|
|
449
|
+
tooltip hides the only copy of the explanation from anyone not using a
|
|
450
|
+
mouse. */
|
|
451
|
+
.info{flex:none;width:16px;height:16px;padding:0;border:none;
|
|
452
|
+
background:none;box-shadow:none;color:var(--edge-hi);cursor:help;line-height:0}
|
|
453
|
+
.info svg{display:block;width:16px;height:16px}
|
|
454
|
+
.info:hover, .info:focus-visible{color:var(--mute)}
|
|
455
|
+
/* The tooltip is anchored to the section HEADER, not to the 16px icon, and
|
|
456
|
+
spans its full width. Anchored to the icon it extended past the rail's left
|
|
457
|
+
edge and .rail{overflow:auto} clipped it — half the sentence was cut off,
|
|
458
|
+
which a screenshot showed and the geometry did not. Spanning the header can
|
|
459
|
+
never overflow the rail, whatever the section or the icon's position. */
|
|
460
|
+
.sect-top{position:relative;z-index:2}
|
|
461
|
+
.sect-body{position:relative;z-index:1}
|
|
462
|
+
/* Tooltips live at BODY level, positioned by JS — not inside their section.
|
|
463
|
+
Nesting them was wrong twice over: .rail{overflow:auto} clipped one anchored
|
|
464
|
+
to the icon, and once .sect-top got a z-index to beat its own slider, that
|
|
465
|
+
z-index became a STACKING CONTEXT which trapped the tooltip inside it. Two
|
|
466
|
+
sections' headers then both sat at z-index 2 and the later one in the DOM
|
|
467
|
+
won, so Compare view's switch painted straight through Floating edge view's
|
|
468
|
+
tooltip. No amount of z-index inside a trapped context can escape it.
|
|
469
|
+
At body level with position:fixed there is no ancestor to be trapped by and
|
|
470
|
+
nothing to be clipped by — the same reason the toasts moved out of .pane. */
|
|
471
|
+
.tip{position:fixed;width:264px;max-width:calc(100vw - 32px);
|
|
472
|
+
padding:8px 10px;border-radius:var(--r-md);
|
|
473
|
+
background:var(--float);border:1px solid var(--edge);
|
|
474
|
+
box-shadow:var(--shadow), var(--hi);
|
|
475
|
+
color:var(--ink);font-size:12px;font-weight:400;line-height:1.45;
|
|
476
|
+
letter-spacing:normal;text-transform:none;text-align:left;white-space:normal;
|
|
477
|
+
z-index:70;opacity:0;visibility:hidden;pointer-events:none;
|
|
478
|
+
transform:translateY(-2px);
|
|
479
|
+
transition:opacity var(--t), transform var(--t), visibility 0s linear var(--t)}
|
|
480
|
+
.tip.on{opacity:1;visibility:visible;transform:none;
|
|
481
|
+
transition:opacity var(--t), transform var(--t), visibility 0s}
|
|
482
|
+
|
|
483
|
+
/* Device chips share the rail equally rather than sizing to their labels —
|
|
484
|
+
four ragged pills read as a list, four equal ones read as a choice. */
|
|
485
|
+
.chips{display:flex;gap:8px;width:100%}
|
|
486
|
+
.chips .chip{flex:1 1 0;min-width:0;padding:0 6px}
|
|
487
|
+
select,input[type=text]{width:100%;font:inherit;height:28px;padding:0 8px;
|
|
488
|
+
border-radius:var(--r-md);border:1px solid var(--edge);background:var(--sunk);color:var(--ink);
|
|
489
|
+
transition:var(--t-all)}
|
|
490
|
+
/* appearance:none is not cosmetic here. A native <select> is drawn by the
|
|
491
|
+
platform, and macOS paints its chevron ABOVE positioned web content — the
|
|
492
|
+
section tooltip has z-index 40 and the native arrow still sat on top of it.
|
|
493
|
+
Drawing our own chevron puts the control back under our own stacking rules,
|
|
494
|
+
and matches the design, which has no native control in it anywhere. */
|
|
495
|
+
select{appearance:none;-webkit-appearance:none;padding-right:26px;
|
|
496
|
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238f939a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
497
|
+
background-repeat:no-repeat;background-position:right 9px center}
|
|
498
|
+
select:hover,input[type=text]:hover{border-color:var(--edge-hi)}
|
|
499
|
+
select:focus-visible,input[type=text]:focus-visible{outline:2px solid var(--acc);outline-offset:1px}
|
|
500
|
+
/* The slider is the one thing you drag continuously — give it a real track
|
|
501
|
+
and a real thumb rather than the browser default in a box. */
|
|
502
|
+
/* margin:0 is load-bearing. The UA sheet gives a range input `margin:2px`,
|
|
503
|
+
so `width:100%` makes it 4px wider than its box — it overflows, and
|
|
504
|
+
.sect-body{overflow:hidden} (there to clip the collapse animation) cuts
|
|
505
|
+
whatever sticks out. In the Realism section the wrapper is
|
|
506
|
+
justify-items:end, which put the whole overflow on the LEFT, so the thumb
|
|
507
|
+
at minimum was sliced by the section's edge. Reported from Safari, which
|
|
508
|
+
insets the thumb less than Chromium and so cut visibly more of it.
|
|
509
|
+
padding-inline gives the thumb's 1px border and drop shadow somewhere to
|
|
510
|
+
land, since the thumb travels inside the track's content box. */
|
|
511
|
+
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:20px;
|
|
512
|
+
margin:0;padding-inline:2px;background:transparent;box-sizing:border-box}
|
|
513
|
+
input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:999px;transition:var(--t-all);
|
|
514
|
+
background:linear-gradient(to right, var(--acc) 0 calc(var(--p,0) * 100%), var(--sunk) calc(var(--p,0) * 100%) 100%);
|
|
515
|
+
box-shadow:inset 0 0 0 1px var(--line)}
|
|
516
|
+
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;
|
|
517
|
+
margin-top:-5px;border-radius:999px;background:var(--raise);border:1px solid var(--edge-hi);
|
|
518
|
+
box-shadow:var(--hi), 0 1px 3px rgba(0,0,0,.4);transition:var(--t-all)}
|
|
519
|
+
input[type=range]:hover::-webkit-slider-thumb{border-color:var(--acc)}
|
|
520
|
+
input[type=range]:focus-visible{outline:none}
|
|
521
|
+
input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--acc);outline-offset:2px}
|
|
522
|
+
/* Captions are text/faint in Figma. The port had quietly used --mute
|
|
523
|
+
instead; now they follow the token as designed. At the brightened
|
|
524
|
+
#8f939ab2 that measures 3.48:1 on card — up from 2.42:1, still under the
|
|
525
|
+
4.5:1 AA needs for 12px text. a deliberate call, recorded rather than
|
|
526
|
+
silently re-deviated. */
|
|
527
|
+
.hint{color:var(--faint);font-size:12px;margin:8px 0 0;line-height:1.5}
|
|
528
|
+
|
|
529
|
+
/* Figma 8:28: 198px tall — 16 padding, 28 header, 8 gap, 130 strip, 16
|
|
530
|
+
padding. It is a fixed band and must never absorb leftover height. */
|
|
531
|
+
.dock{background:var(--card);padding:var(--s4);display:grid;gap:var(--s2);
|
|
532
|
+
grid-template-rows:28px 130px;flex:none;
|
|
533
|
+
/* The rule is an inset shadow, not a border, so it does not add a
|
|
534
|
+
pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
|
|
535
|
+
box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
|
|
536
|
+
.dock.hidden{display:none}
|
|
537
|
+
/* Figma 8:29 is a 28px header holding 24px buttons — the header does not
|
|
538
|
+
shrink to hug them. */
|
|
539
|
+
.striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
|
|
540
|
+
canvas#strip{display:block;width:100%;height:130px;border-radius:var(--r-md);
|
|
541
|
+
background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
|
|
542
|
+
/* Floating mode: the loupe follows the edge instead of taking a permanent band. */
|
|
543
|
+
.floatloupe{position:fixed;z-index:40;display:none;padding:8px;border-radius:var(--r-lg);
|
|
544
|
+
background:var(--float);border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi)}
|
|
545
|
+
.floatloupe.on{display:block}
|
|
546
|
+
.floatloupe canvas{display:block;width:340px;height:104px;border-radius:var(--r-sm);
|
|
547
|
+
background:var(--well);box-shadow:inset 0 0 0 1px var(--line)}
|
|
548
|
+
.floatloupe .cap{font-size:11px;color:var(--mute);padding-top:4px;max-width:340px}
|
|
549
|
+
|
|
550
|
+
/* Toast, with Sonner's stacking behaviour (5 Sep).
|
|
551
|
+
Sonner itself is React-only — sonner@2.0.8 ships a single React build with
|
|
552
|
+
a react/react-dom peer dep, and React 19 no longer publishes UMD, so taking
|
|
553
|
+
it literally meant ~1MB of vendored JS and a React runtime inside a 75KB
|
|
554
|
+
stdlib-served page. What makes Sonner feel like Sonner is the behaviour,
|
|
555
|
+
and that ports cleanly: a COLLAPSED STACK that expands on hover,
|
|
556
|
+
swipe-to-dismiss, and neighbours that glide rather than jump.
|
|
557
|
+
|
|
558
|
+
Layout: the container is zero-height and every toast sits on the same top
|
|
559
|
+
baseline, so a toast's place in the stack is purely a transform — which is
|
|
560
|
+
why re-layout animates for free.
|
|
561
|
+
|
|
562
|
+
TOP CENTRE, fixed to the viewport (5 Sep). It was bottom-left inside the
|
|
563
|
+
canvas pane; centred at the top it is clear of both the 56px top bar and the
|
|
564
|
+
52px pane headers, so it covers only the canvas — and being viewport-fixed
|
|
565
|
+
it no longer has to dodge the corner bar, which is why the ResizeObserver
|
|
566
|
+
that measured it is gone. */
|
|
567
|
+
.toasts{position:fixed;top:116px;left:50%;transform:translateX(-50%);
|
|
568
|
+
z-index:60;width:min(380px, calc(100vw - 32px));height:0;
|
|
569
|
+
pointer-events:none}
|
|
570
|
+
.toast{position:absolute;left:0;top:0;width:100%;box-sizing:border-box;
|
|
571
|
+
display:flex;align-items:flex-start;gap:8px;padding:10px 12px;
|
|
572
|
+
border-radius:var(--r-md);background:var(--float);
|
|
573
|
+
border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi);
|
|
574
|
+
font-size:12px;line-height:1.45;color:var(--ink);
|
|
575
|
+
pointer-events:auto;touch-action:none;transform-origin:50% 0;
|
|
576
|
+
will-change:transform,opacity;
|
|
577
|
+
overflow:hidden;
|
|
578
|
+
transition:transform 380ms cubic-bezier(.21,1.02,.73,1),
|
|
579
|
+
height 380ms cubic-bezier(.21,1.02,.73,1),
|
|
580
|
+
opacity 260ms cubic-bezier(.21,1.02,.73,1),
|
|
581
|
+
background-color var(--t), border-color var(--t)}
|
|
582
|
+
/* No easing while a finger is down — a drag must track the pointer exactly. */
|
|
583
|
+
.toast[data-dragging="true"]{transition:none;cursor:grabbing}
|
|
584
|
+
.toast[data-gone="true"]{opacity:0}
|
|
585
|
+
@media (prefers-reduced-motion:reduce){ .toast{transition:opacity 120ms} }
|
|
586
|
+
|
|
587
|
+
/* Same three-channel encoding as .status — glyph, weight, luminance — so a
|
|
588
|
+
toast and a status pill never disagree, and meaning survives without hue. */
|
|
589
|
+
.toast .g{flex:none;font-weight:600;line-height:1.45}
|
|
590
|
+
.toast.ok .g{color:var(--ok)} .toast.ok .g::before{content:"\2713"}
|
|
591
|
+
.toast.warn .g{color:var(--warn)} .toast.warn .g::before{content:"!"}
|
|
592
|
+
.toast.err .g{color:var(--err)} .toast.err .g::before{content:"\00d7"}
|
|
593
|
+
.toast.info .g{color:var(--mute)} .toast.info .g::before{content:"\2022"}
|
|
594
|
+
/* Error toast: the CONTAINER stays quiet — stroke close to its fill, the same
|
|
595
|
+
relationship a standard button has — and the message carries the colour,
|
|
596
|
+
rather than a tinted stroke and glyph shouting around neutral text. */
|
|
597
|
+
.toast.err{border-color:var(--edge)}
|
|
598
|
+
.toast.err .g, .toast.err .msg{color:var(--warn)}
|
|
599
|
+
.toast .msg{min-width:0;word-break:break-word}
|
|
600
|
+
.toast .x{flex:none;margin:-4px -4px 0 4px;width:22px;height:22px;padding:0;
|
|
601
|
+
background:none;border:none;box-shadow:none;color:var(--mute);
|
|
602
|
+
font-size:14px;line-height:22px;border-radius:var(--r-sm);opacity:0;
|
|
603
|
+
transition:opacity var(--t), background-color var(--t), color var(--t)}
|
|
604
|
+
/* Close is stack-level furniture: it shows when the stack is open, so a
|
|
605
|
+
resting stack stays clean. Always available to keyboard users. */
|
|
606
|
+
.toasts[data-expanded="true"] .toast .x, .toast .x:focus-visible{opacity:1}
|
|
607
|
+
.toast .x:hover{background:var(--raise);color:var(--ink)}
|
|
608
|
+
|
|
609
|
+
/* Popovers animate both ways. `display` can't be transitioned, so visibility
|
|
610
|
+
carries the hit-testing (it IS transitionable, discretely) while opacity and
|
|
611
|
+
transform carry the motion — no timers, no class-removal races. */
|
|
612
|
+
.scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;
|
|
613
|
+
opacity:0;visibility:hidden;
|
|
614
|
+
transition:opacity var(--t-pop), visibility 0s linear var(--t-pop)}
|
|
615
|
+
.scrim.on{opacity:1;visibility:visible;transition:opacity var(--t-pop), visibility 0s}
|
|
616
|
+
.pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
|
|
617
|
+
max-height:78vh;overflow:auto;background:var(--float);border:1px solid var(--edge);
|
|
618
|
+
border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
|
|
619
|
+
opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
|
|
620
|
+
transform-origin:50% 0;
|
|
621
|
+
transition:opacity var(--t-pop), transform var(--t-pop),
|
|
622
|
+
visibility 0s linear var(--t-pop)}
|
|
623
|
+
.pop.on{opacity:1;visibility:visible;transform:none;
|
|
624
|
+
transition:opacity var(--t-pop), transform var(--t-pop), visibility 0s}
|
|
625
|
+
@media (prefers-reduced-motion:reduce){
|
|
626
|
+
.pop{transform:none}
|
|
627
|
+
}
|
|
628
|
+
.pop h3{margin:0 0 4px;font-size:14px;font-weight:600}
|
|
629
|
+
.pop .sub{color:var(--mute);font-size:12px;margin:0 0 16px;line-height:1.5}
|
|
630
|
+
.picker{display:grid;grid-template-columns:180px minmax(0,1fr);gap:12px;align-items:start}
|
|
631
|
+
.rail2{display:flex;flex-direction:column;gap:4px;max-height:300px;overflow:auto;padding:2px}
|
|
632
|
+
.th{display:flex;align-items:center;gap:10px;padding:6px;border-radius:var(--r-md);cursor:pointer;
|
|
633
|
+
border:1px solid transparent;background:var(--sunk);
|
|
634
|
+
transition:var(--t-all)}
|
|
635
|
+
.th:hover{border-color:var(--edge-hi);background:var(--raise)}
|
|
636
|
+
.th img{width:40px;height:40px;object-fit:cover;border-radius:6px;flex:none;background:var(--card)}
|
|
637
|
+
.th .cap{font-size:11.5px;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
638
|
+
.th.sel{border-color:var(--acc);background:var(--acc-soft)}
|
|
639
|
+
/* The preview frame sits INSIDE the popover, so --well (#0a0a0b, the canvas
|
|
640
|
+
tone) read as a hole punched in it. --sunk is the system's recessed surface:
|
|
641
|
+
lighter than the well, still clearly below --float, and it matches the
|
|
642
|
+
thumbnail rail beside it. The stroke moves to --edge so the frame still has
|
|
643
|
+
an edge now that its fill is closer to the surface behind it. */
|
|
644
|
+
.pv{border-radius:var(--r-md);background:var(--sunk);min-height:210px;
|
|
645
|
+
display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 0 1px var(--edge)}
|
|
646
|
+
.pv img{max-width:100%;max-height:300px;object-fit:contain;display:block}
|
|
647
|
+
.drop{border:1px dashed var(--edge);border-radius:var(--r-md);padding:14px;text-align:center;
|
|
648
|
+
color:var(--mute);cursor:pointer;margin-top:12px;font-size:12px;
|
|
649
|
+
transition:var(--t-all)}
|
|
650
|
+
.drop:hover{border-color:var(--edge-hi);color:var(--ink)}
|
|
651
|
+
.drop.over{border-color:var(--acc);color:var(--ink);background:var(--acc-soft)}
|
|
652
|
+
.sm{font-size:12px}
|
|
653
|
+
code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
|
|
654
|
+
border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
|
|
655
|
+
kbd{border:1px solid var(--edge);border-bottom-width:2px;border-radius:var(--r-sm);
|
|
656
|
+
padding:1px 5px;font:11px/1.6 inherit;color:var(--mute);background:var(--sunk)}
|
|
657
|
+
</style>
|
|
658
|
+
</head>
|
|
659
|
+
<body>
|
|
660
|
+
<div class="app">
|
|
661
|
+
|
|
662
|
+
<!-- Top bar (Figma 6:3). Two things moved OUT in the an earlier finding pass: the detection
|
|
663
|
+
status pill went to the Fit pane header, where the thing it describes
|
|
664
|
+
actually is, and Compare became a switch in the rail rather than a chip
|
|
665
|
+
competing with the actions. What is left is identity, inputs, actions. -->
|
|
666
|
+
<header class="topbar">
|
|
667
|
+
<div class="tb-left">
|
|
668
|
+
<span class="brand">Screengraft</span><span id="sess" hidden></span>
|
|
669
|
+
<div class="tb-chips">
|
|
670
|
+
<button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button>
|
|
671
|
+
<span class="tb-arrow">→</span>
|
|
672
|
+
<button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
<span class="spacer"></span>
|
|
676
|
+
<div class="tb-actions">
|
|
677
|
+
<button id="preview" hidden>Preview</button>
|
|
678
|
+
<button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
|
|
679
|
+
<button id="save" class="accent" disabled>Preview first</button>
|
|
680
|
+
</div>
|
|
681
|
+
</header>
|
|
682
|
+
|
|
683
|
+
<div class="stage" id="stage">
|
|
684
|
+
<section class="pane">
|
|
685
|
+
<!-- Structure is Figma 7:13 exactly: a Left group (gap 24) holding
|
|
686
|
+
Left-1 (gap 16: label, stepper, zoom %) and Left-2 (gap 8: Fit,
|
|
687
|
+
100%), with the status pill pushed to the far end by
|
|
688
|
+
justify-between. Buttons are Md — the same size as every other
|
|
689
|
+
button on screen, which is the point of the change. -->
|
|
690
|
+
<div class="panehead fit-head">
|
|
691
|
+
<div class="ph-left">
|
|
692
|
+
<div class="ph-left-1">
|
|
693
|
+
<span class="lbl">Fit</span>
|
|
694
|
+
<span class="stepper"><button id="czOut">−</button><button id="czIn">+</button></span>
|
|
695
|
+
<span id="czSt" class="zoomval"></span>
|
|
696
|
+
</div>
|
|
697
|
+
<div class="ph-left-2">
|
|
698
|
+
<button id="czFit">Fit</button>
|
|
699
|
+
<button id="cz100">100%</button>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
<span class="status" id="detSt"></span>
|
|
703
|
+
</div>
|
|
704
|
+
<div class="scroller" id="scroller">
|
|
705
|
+
<canvas id="c"></canvas>
|
|
706
|
+
</div>
|
|
707
|
+
<!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
|
|
708
|
+
positioned inside a scroll container scrolls with the content, so
|
|
709
|
+
when it lived over the canvas it slid out of view the moment you
|
|
710
|
+
zoomed in. Anchored to the pane it stays put while the picture moves
|
|
711
|
+
underneath, which is what a floating control should do. -->
|
|
712
|
+
<div class="cvbar" id="cvbar">
|
|
713
|
+
<span class="sm" style="color:var(--mute)">Select corner</span>
|
|
714
|
+
<span class="cvbar-actions">
|
|
715
|
+
<span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
|
|
716
|
+
<button class="sm" id="redetect">Re-detect</button>
|
|
717
|
+
</span>
|
|
718
|
+
</div>
|
|
719
|
+
</section>
|
|
720
|
+
|
|
721
|
+
<section class="pane" id="outPane">
|
|
722
|
+
<div class="panehead">
|
|
723
|
+
<span class="lbl">Result</span>
|
|
724
|
+
<span class="gap-3"></span>
|
|
725
|
+
<span class="sm" id="outSt" style="color:var(--mute)">Press Preview</span>
|
|
726
|
+
<span class="spacer"></span>
|
|
727
|
+
<span class="sm" style="color:var(--mute)">shares zoom & pan</span>
|
|
728
|
+
</div>
|
|
729
|
+
<div id="outWrap"><span class="empty" id="outEmpty">Press Preview — or turn on Compare view and this updates as you drag.</span><img id="outImg" alt="" hidden></div>
|
|
730
|
+
</section>
|
|
731
|
+
|
|
732
|
+
<aside class="rail">
|
|
733
|
+
<div class="sect" id="secDevice" data-on="1">
|
|
734
|
+
<div class="sect-top"><div class="sect-left"><h4>Device</h4></div></div>
|
|
735
|
+
<div class="chips" id="types"></div>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="sect" id="secRadius" data-on="1">
|
|
738
|
+
<div class="sect-top">
|
|
739
|
+
<div class="sect-left">
|
|
740
|
+
<h4>Corner radius</h4>
|
|
741
|
+
<button class="info" type="button" aria-label="About corner radius"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Rounds the injected screen so it follows the device's own bezel. Measured from the photo when it can be, otherwise from a device preset. Off gives square corners.</span></button>
|
|
742
|
+
</div>
|
|
743
|
+
<button class="switch" id="radBtn" role="switch" aria-checked="true" aria-label="Corner radius">
|
|
744
|
+
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
745
|
+
</button>
|
|
746
|
+
</div>
|
|
747
|
+
<div class="sect-body"><div class="sect-body-in">
|
|
748
|
+
<select id="preset" aria-label="Corner radius device preset"></select>
|
|
749
|
+
<input type="range" id="radius" min="0" max="0.25" step="0.002" value="0" aria-label="Corner radius, percent of screen width">
|
|
750
|
+
<p class="hint" id="radSt"></p>
|
|
751
|
+
</div></div>
|
|
752
|
+
</div>
|
|
753
|
+
<div class="sect" id="secRealism" data-on="1">
|
|
754
|
+
<div class="sect-top">
|
|
755
|
+
<div class="sect-left">
|
|
756
|
+
<h4>Realism</h4>
|
|
757
|
+
<button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing.</span></button>
|
|
758
|
+
</div>
|
|
759
|
+
<button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
|
|
760
|
+
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
761
|
+
</button>
|
|
762
|
+
</div>
|
|
763
|
+
<div class="sect-body"><div class="sect-body-in">
|
|
764
|
+
<div style="display:grid;gap:var(--s2);justify-items:end">
|
|
765
|
+
<span class="sm" id="gradeVal" style="font-variant-numeric:tabular-nums"></span>
|
|
766
|
+
<input type="range" id="gradeAmt" min="0.1" max="1" step="0.05" value="0.35" aria-label="Realism strength">
|
|
767
|
+
</div>
|
|
768
|
+
</div></div>
|
|
769
|
+
</div>
|
|
770
|
+
<div class="sect" id="secEdge" data-on="1">
|
|
771
|
+
<div class="sect-top">
|
|
772
|
+
<div class="sect-left">
|
|
773
|
+
<h4>Floating edge view</h4>
|
|
774
|
+
<button class="info" type="button" aria-label="About floating edge view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">The magnified strip follows the edge you're dragging, so your eye doesn't travel. Off docks it under the canvas in a fixed band.</span></button>
|
|
775
|
+
</div>
|
|
776
|
+
<button class="switch" id="edgeBtn" role="switch" aria-checked="true" aria-label="Floating edge view">
|
|
777
|
+
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
778
|
+
</button>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="sect" id="secCompare" data-on="1">
|
|
782
|
+
<div class="sect-top">
|
|
783
|
+
<div class="sect-left">
|
|
784
|
+
<h4>Compare view</h4>
|
|
785
|
+
<button class="info" type="button" aria-label="About compare view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Shows the fit and the composite side by side, sharing zoom and pan, so a corner can be judged against the same corner.</span></button>
|
|
786
|
+
</div>
|
|
787
|
+
<button class="switch" id="cmpBtn" role="switch" aria-checked="true" aria-label="Compare view">
|
|
788
|
+
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
789
|
+
</button>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
<div class="sect" id="secKeys" data-on="1">
|
|
793
|
+
<div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
|
|
794
|
+
<p class="hint"><kbd>←↑→↓</kbd> nudge 1px · <kbd>⇧</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
|
|
795
|
+
</div>
|
|
796
|
+
</aside>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
<footer class="dock" id="dock">
|
|
800
|
+
<div class="striphead">
|
|
801
|
+
<span class="lbl">Edge view</span>
|
|
802
|
+
<span class="stepper dock-step"><button class="sm" id="stripOut">−</button><button class="sm" id="stripIn">+</button></span>
|
|
803
|
+
<span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
|
|
804
|
+
<span class="spacer"></span>
|
|
805
|
+
<span class="sm" id="stripSt" style="color:var(--mute)"></span>
|
|
806
|
+
</div>
|
|
807
|
+
<canvas id="strip" width="1280" height="130"></canvas>
|
|
808
|
+
</footer>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
811
|
+
<div class="floatloupe" id="floatLoupe">
|
|
812
|
+
<canvas id="stripF" width="680" height="208"></canvas>
|
|
813
|
+
<div class="cap" id="stripFCap"></div>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
<!-- Toasts. Viewport-fixed at top centre, so they live at body level: parented
|
|
817
|
+
inside .pane they still positioned correctly, but any future transform or
|
|
818
|
+
filter on an ancestor would silently turn position:fixed into
|
|
819
|
+
position:absolute and move them without warning.
|
|
820
|
+
aria-live=polite announces confirmations without interrupting; individual
|
|
821
|
+
error toasts set role="alert" for themselves. -->
|
|
822
|
+
<div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div>
|
|
823
|
+
|
|
824
|
+
<div class="scrim" id="scrim"></div>
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
<div class="pop" id="previewPop" style="width:min(900px,calc(100vw - 24px))">
|
|
828
|
+
<h3>Preview</h3>
|
|
829
|
+
<p class="sub" id="previewSt">Rendering…</p>
|
|
830
|
+
<div id="previewWrap"><img id="previewImg" alt="" hidden></div>
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
<div class="pop" id="pop1">
|
|
834
|
+
<h3>Photo of the device</h3>
|
|
835
|
+
<p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
|
|
836
|
+
<div class="picker">
|
|
837
|
+
<div class="rail2" id="recent1"></div>
|
|
838
|
+
<div class="pv" id="pv1"><span class="empty">Select a recent image</span></div>
|
|
839
|
+
</div>
|
|
840
|
+
<div class="drop" id="drop1">Drop a photo here or click to browse</div>
|
|
841
|
+
<input type="file" id="file1" accept="image/*" hidden>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<div class="pop" id="pop2">
|
|
845
|
+
<h3>Screenshot to put on the screen</h3>
|
|
846
|
+
<p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will export it for you.</p>
|
|
847
|
+
<div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">Export from Figma</button></div>
|
|
848
|
+
<div class="status sm" id="figmaSt" style="margin-bottom:10px"></div>
|
|
849
|
+
<div class="hintline" id="figmaHint" hidden style="margin-bottom:10px"></div>
|
|
850
|
+
<div class="picker">
|
|
851
|
+
<div class="rail2" id="recent2"></div>
|
|
852
|
+
<div class="pv" id="pv2"><span class="empty">Select a recent image</span></div>
|
|
853
|
+
</div>
|
|
854
|
+
<div class="drop" id="drop2">Drop a screenshot here or click to browse</div>
|
|
855
|
+
<input type="file" id="file2" accept="image/*" hidden>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<script>
|
|
859
|
+
const $ = s => document.querySelector(s);
|
|
860
|
+
let previewHinted = false; // the preview nudge is orientation, shown once
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
/* Tooltips. Each .info keeps its own <span class="tip"> in the markup so the
|
|
864
|
+
text stays associated with the control for assistive tech, but at boot every
|
|
865
|
+
tip is MOVED to <body> and shown by JS. Positioned in place it was clipped by
|
|
866
|
+
the rail's overflow, and once its section needed a z-index it was trapped in
|
|
867
|
+
that section's stacking context and painted under the next section's switch.
|
|
868
|
+
A body-level fixed element has neither problem.
|
|
869
|
+
Shown on hover AND focus: :focus-visible is a heuristic that misses, and this
|
|
870
|
+
is the only copy of each explanation. */
|
|
871
|
+
(() => {
|
|
872
|
+
const place = (btn, tip) => {
|
|
873
|
+
const r = btn.getBoundingClientRect();
|
|
874
|
+
const w = tip.offsetWidth, gap = 8;
|
|
875
|
+
// Prefer left-aligned to the icon; pull back inside the viewport if needed.
|
|
876
|
+
let left = Math.min(r.left, innerWidth - w - 16);
|
|
877
|
+
left = Math.max(16, left);
|
|
878
|
+
let top = r.bottom + gap;
|
|
879
|
+
if (top + tip.offsetHeight > innerHeight - 16) top = Math.max(16, r.top - gap - tip.offsetHeight);
|
|
880
|
+
tip.style.left = Math.round(left) + 'px';
|
|
881
|
+
tip.style.top = Math.round(top) + 'px';
|
|
882
|
+
};
|
|
883
|
+
document.querySelectorAll('.info').forEach((btn, i) => {
|
|
884
|
+
const tip = btn.querySelector('.tip');
|
|
885
|
+
if (!tip) return;
|
|
886
|
+
tip.id = tip.id || ('tip-' + i);
|
|
887
|
+
btn.setAttribute('aria-describedby', tip.id);
|
|
888
|
+
document.body.appendChild(tip); // out of every stacking context
|
|
889
|
+
const show = () => { tip.classList.add('on'); place(btn, tip); };
|
|
890
|
+
const hide = () => tip.classList.remove('on');
|
|
891
|
+
btn.addEventListener('pointerenter', show);
|
|
892
|
+
btn.addEventListener('pointerleave', hide);
|
|
893
|
+
btn.addEventListener('focus', show);
|
|
894
|
+
btn.addEventListener('blur', hide);
|
|
895
|
+
});
|
|
896
|
+
addEventListener('scroll', () => document.querySelectorAll('.tip.on').forEach(t => t.classList.remove('on')), true);
|
|
897
|
+
})();
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
/* ===== toasts (Sonner-style behaviour) ====================================
|
|
901
|
+
One sink for every transient outcome: saved, sent to Claude, exported from
|
|
902
|
+
Figma, and the failures of each. Before this they lived in #actSt, which the
|
|
903
|
+
v0.8.2 layout left permanently hidden.
|
|
904
|
+
|
|
905
|
+
Sonner is React-only (measured: sonner@2.0.8 has react/react-dom peer deps
|
|
906
|
+
and one React build; React 19 dropped UMD), so its BEHAVIOUR is ported rather
|
|
907
|
+
than the package: toasts collapse into a stack, hovering expands them, a left
|
|
908
|
+
swipe dismisses, and removing one glides the rest into place.
|
|
909
|
+
|
|
910
|
+
Rules that make it a tool's toast rather than a website's:
|
|
911
|
+
- successes and info clear themselves; ERRORS DO NOT. A failure you missed
|
|
912
|
+
because it timed out is exactly the bug this class is made of.
|
|
913
|
+
- hovering the stack pauses every countdown in it, so a long path in a Save
|
|
914
|
+
confirmation can actually be read.
|
|
915
|
+
- glyph + weight + colour, matching .status, so colour is never load-bearing. */
|
|
916
|
+
const TOASTS = { max: 3, ok: 4200, info: 6000, warn: 8000 }; // err: sticky
|
|
917
|
+
const T_STACK = { peek: 14, scale: .06, gap: 8, swipe: 60 };
|
|
918
|
+
let toastItems = []; // newest first
|
|
919
|
+
let toastHover = 0;
|
|
920
|
+
|
|
921
|
+
function layoutToasts(expanded) {
|
|
922
|
+
const wrap = $('#toasts');
|
|
923
|
+
if (!wrap) return;
|
|
924
|
+
wrap.dataset.expanded = expanded ? 'true' : 'false';
|
|
925
|
+
|
|
926
|
+
// Measure natural heights first, with any imposed height released. Messages
|
|
927
|
+
// differ in length, so the cards differ in height — and on a shared bottom
|
|
928
|
+
// baseline that made the ones behind poke out above the front one at random.
|
|
929
|
+
// Sonner's answer, and this one: while collapsed every card takes the FRONT
|
|
930
|
+
// card's height, so the stack reads as one object. Heights are always set in
|
|
931
|
+
// px (never auto) so the change can be transitioned.
|
|
932
|
+
toastItems.forEach(it => { it.el.style.height = 'auto'; });
|
|
933
|
+
toastItems.forEach(it => { it.h = it.el.offsetHeight; });
|
|
934
|
+
const front = toastItems.length ? toastItems[0].h : 0;
|
|
935
|
+
|
|
936
|
+
let stacked = 0;
|
|
937
|
+
toastItems.forEach((it, i) => {
|
|
938
|
+
// Anchored at the top, so the stack grows DOWNWARD: older toasts sit below
|
|
939
|
+
// and behind the newest one.
|
|
940
|
+
const y = expanded ? stacked : i * T_STACK.peek;
|
|
941
|
+
const sc = expanded ? 1 : Math.max(0, 1 - i * T_STACK.scale);
|
|
942
|
+
it.el.style.height = (expanded ? it.h : front) + 'px';
|
|
943
|
+
it.el.style.transform =
|
|
944
|
+
`translate3d(${it.dx || 0}px, ${y + (it.dy || 0)}px, 0) scale(${sc})`;
|
|
945
|
+
it.el.style.opacity = i >= TOASTS.max ? '0' : '1';
|
|
946
|
+
it.el.style.zIndex = String(100 - i);
|
|
947
|
+
stacked += it.h + T_STACK.gap;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function toast(kind, html, opts = {}) {
|
|
952
|
+
const wrap = $('#toasts');
|
|
953
|
+
if (!wrap) return null;
|
|
954
|
+
|
|
955
|
+
const el = document.createElement('div');
|
|
956
|
+
el.className = `toast ${kind}`;
|
|
957
|
+
if (kind === 'err') el.setAttribute('role', 'alert');
|
|
958
|
+
el.innerHTML = `<span class="g" aria-hidden="true"></span><div class="msg"></div>`;
|
|
959
|
+
el.querySelector('.msg').innerHTML = html;
|
|
960
|
+
|
|
961
|
+
const life = opts.ms !== undefined ? opts.ms : TOASTS[kind];
|
|
962
|
+
const item = { el, gone: false, dx: 0, dy: 0, timer: null };
|
|
963
|
+
|
|
964
|
+
item.disarm = () => clearTimeout(item.timer);
|
|
965
|
+
item.arm = () => { if (life && !item.gone) { clearTimeout(item.timer); item.timer = setTimeout(item.close, life); } };
|
|
966
|
+
item.close = () => {
|
|
967
|
+
if (item.gone) return;
|
|
968
|
+
item.gone = true;
|
|
969
|
+
clearTimeout(item.timer);
|
|
970
|
+
el.dataset.gone = 'true';
|
|
971
|
+
el.style.pointerEvents = 'none';
|
|
972
|
+
toastItems = toastItems.filter(t => t !== item);
|
|
973
|
+
layoutToasts(toastHover > 0); // survivors glide into place
|
|
974
|
+
setTimeout(() => el.remove(), 420);
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
const x = document.createElement('button');
|
|
978
|
+
x.className = 'x'; x.type = 'button'; x.setAttribute('aria-label', 'Dismiss');
|
|
979
|
+
x.textContent = '×'; x.onclick = item.close;
|
|
980
|
+
el.appendChild(x);
|
|
981
|
+
|
|
982
|
+
/* Swipe to dismiss. Centred at the top, the nearest edges are up and to
|
|
983
|
+
either side, so all three dismiss; dragging DOWN rubber-bands, because
|
|
984
|
+
there is nowhere down for it to go. */
|
|
985
|
+
let startX = 0, startY = 0, dragging = false;
|
|
986
|
+
el.addEventListener('pointerdown', e => {
|
|
987
|
+
if (e.target.closest('.x')) return;
|
|
988
|
+
dragging = true; startX = e.clientX; startY = e.clientY;
|
|
989
|
+
el.dataset.dragging = 'true';
|
|
990
|
+
try { el.setPointerCapture(e.pointerId); } catch (err) {}
|
|
991
|
+
});
|
|
992
|
+
el.addEventListener('pointermove', e => {
|
|
993
|
+
if (!dragging) return;
|
|
994
|
+
const rawY = e.clientY - startY;
|
|
995
|
+
item.dx = e.clientX - startX;
|
|
996
|
+
item.dy = rawY < 0 ? rawY : rawY * .25;
|
|
997
|
+
layoutToasts(toastHover > 0);
|
|
998
|
+
});
|
|
999
|
+
const endDrag = () => {
|
|
1000
|
+
if (!dragging) return;
|
|
1001
|
+
dragging = false;
|
|
1002
|
+
delete el.dataset.dragging;
|
|
1003
|
+
if (item.dy < -T_STACK.swipe || Math.abs(item.dx) > T_STACK.swipe) {
|
|
1004
|
+
if (item.dy < -T_STACK.swipe) item.dy = -200;
|
|
1005
|
+
else item.dx = item.dx > 0 ? 440 : -440;
|
|
1006
|
+
layoutToasts(toastHover > 0); item.close();
|
|
1007
|
+
} else { item.dx = 0; item.dy = 0; layoutToasts(toastHover > 0); }
|
|
1008
|
+
};
|
|
1009
|
+
el.addEventListener('pointerup', endDrag);
|
|
1010
|
+
el.addEventListener('pointercancel', endDrag);
|
|
1011
|
+
|
|
1012
|
+
// Hover expands the stack and pauses every countdown in it.
|
|
1013
|
+
el.addEventListener('pointerenter', () => {
|
|
1014
|
+
toastHover++; toastItems.forEach(t => t.disarm()); layoutToasts(true);
|
|
1015
|
+
});
|
|
1016
|
+
el.addEventListener('pointerleave', () => {
|
|
1017
|
+
toastHover = Math.max(0, toastHover - 1);
|
|
1018
|
+
if (!toastHover) { toastItems.forEach(t => t.arm()); layoutToasts(false); }
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
wrap.appendChild(el);
|
|
1022
|
+
toastItems.unshift(item);
|
|
1023
|
+
// Start just above the baseline and let the first layout carry it down.
|
|
1024
|
+
el.style.transform = 'translate3d(0,-14px,0) scale(.96)';
|
|
1025
|
+
el.style.opacity = '0';
|
|
1026
|
+
requestAnimationFrame(() => layoutToasts(toastHover > 0));
|
|
1027
|
+
|
|
1028
|
+
// Trim past the visible depth, oldest first.
|
|
1029
|
+
while (toastItems.length > TOASTS.max + 1) toastItems[toastItems.length - 1].close();
|
|
1030
|
+
|
|
1031
|
+
item.arm();
|
|
1032
|
+
return item;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const api = async (p, body, raw) => {
|
|
1036
|
+
const r = await fetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
|
|
1037
|
+
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
|
|
1038
|
+
};
|
|
1039
|
+
const fileURL = p => '/file?path=' + encodeURIComponent(p);
|
|
1040
|
+
const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null, presets:[]};
|
|
1041
|
+
const remember = (k,v) => { try{ localStorage.setItem('sg.'+k, v); }catch(e){} };
|
|
1042
|
+
const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v===null?d:v; }catch(e){ return d; } };
|
|
1043
|
+
|
|
1044
|
+
/* ===== overlay drawing =====================================================
|
|
1045
|
+
A flat stroke colour cannot survive an arbitrary photograph. Swept against
|
|
1046
|
+
greys 0-255, the old iOS red bottomed out at 1.00:1 on mid-grey and the
|
|
1047
|
+
yellow at 1.02:1 on a bright wall — invisible on exactly the material this
|
|
1048
|
+
tool is for. So every overlay strokes TWICE: a dark casing, then a light
|
|
1049
|
+
core. Whatever is underneath, one of the two contrasts. State is carried by
|
|
1050
|
+
WEIGHT (thicker = active), not by hue alone. */
|
|
1051
|
+
/* Figma 33:856 draws the whole quad in the accent, so the accent is the core
|
|
1052
|
+
everywhere and STATE IS CARRIED BY WEIGHT, not hue — which is what v0.6.0
|
|
1053
|
+
concluded anyway. CORE_IDLE stays for the rectified strip's own rule. */
|
|
1054
|
+
/* Overlay cores, from Figma 33:856 (5 Sep). That frame carries TWO accent
|
|
1055
|
+
tokens, and the overlay has two jobs, so they map one to one:
|
|
1056
|
+
accent/acc #f23b0d the quad you are placing — edges and handles
|
|
1057
|
+
accent/acc_light #f5623d the secondary aids — dashed guide, corner dots,
|
|
1058
|
+
the strip's own rule
|
|
1059
|
+
The dark casing underneath is NOT optional and stays: flat accent measures
|
|
1060
|
+
~1.06:1 against mid-tone photography, which is the failure the casing was
|
|
1061
|
+
introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
|
|
1062
|
+
const CASE_A = 'rgba(0,0,0,.62)', CORE_IDLE = 'rgba(255,255,255,.92)';
|
|
1063
|
+
const CORE_ACTIVE = '#f5623d';
|
|
1064
|
+
const CORE_QUAD = '#f23b0d';
|
|
1065
|
+
function cased(c, path, coreW, coreColor, dash){
|
|
1066
|
+
c.save();
|
|
1067
|
+
if (dash) c.setLineDash(dash);
|
|
1068
|
+
c.lineCap = 'round'; c.lineJoin = 'round';
|
|
1069
|
+
c.strokeStyle = CASE_A; c.lineWidth = coreW + 2.5; path(); c.stroke();
|
|
1070
|
+
c.strokeStyle = coreColor; c.lineWidth = coreW; path(); c.stroke();
|
|
1071
|
+
c.restore();
|
|
1072
|
+
}
|
|
1073
|
+
/* A FILLED handle, still cased. cased() strokes twice; a disc has to fill the
|
|
1074
|
+
core and put the dark casing around its rim, or the handle loses the
|
|
1075
|
+
readability guarantee that makes markers survive an arbitrary photograph. */
|
|
1076
|
+
function casedDisc(c, x, y, r, coreColor){
|
|
1077
|
+
c.save();
|
|
1078
|
+
c.strokeStyle = CASE_A; c.lineWidth = 2.5;
|
|
1079
|
+
c.beginPath(); c.arc(x, y, r + 0.75, 0, Math.PI*2); c.stroke();
|
|
1080
|
+
c.fillStyle = coreColor;
|
|
1081
|
+
c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.fill();
|
|
1082
|
+
c.restore();
|
|
1083
|
+
}
|
|
1084
|
+
function casedText(c, text, x, y, color){
|
|
1085
|
+
c.save();
|
|
1086
|
+
c.lineWidth = 3; c.strokeStyle = 'rgba(0,0,0,.75)'; c.lineJoin = 'round';
|
|
1087
|
+
c.strokeText(text, x, y);
|
|
1088
|
+
c.fillStyle = color; c.fillText(text, x, y);
|
|
1089
|
+
c.restore();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/* ===== pickers (popovers over the canvas) ===== */
|
|
1093
|
+
async function loadRecent(){
|
|
1094
|
+
const {items} = await api('/api/recent?days=14&limit=40');
|
|
1095
|
+
for (const id of ['recent1','recent2']){
|
|
1096
|
+
const g = $('#'+id); g.innerHTML='';
|
|
1097
|
+
for (const it of items){
|
|
1098
|
+
const d = document.createElement('div'); d.className='th'; d.title = it.path;
|
|
1099
|
+
d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap">${it.name}</div>`;
|
|
1100
|
+
d.onclick = () => choose(id==='recent1'?'photo':'screenshot', it.path, d);
|
|
1101
|
+
g.appendChild(d);
|
|
1102
|
+
}
|
|
1103
|
+
if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute)">No recent images on Desktop/Downloads (last 14 days).</span>';
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
async function choose(role, path, el){
|
|
1107
|
+
try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el); }
|
|
1108
|
+
catch(e){ alert(e.message); }
|
|
1109
|
+
}
|
|
1110
|
+
function setChosen(role, path, size, el){
|
|
1111
|
+
const n = role==='photo'?1:2;
|
|
1112
|
+
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1113
|
+
if (el) el.classList.add('sel');
|
|
1114
|
+
$('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
|
|
1115
|
+
const chip = $('#chip'+n);
|
|
1116
|
+
chip.classList.remove('is-empty');
|
|
1117
|
+
chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`;
|
|
1118
|
+
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
|
|
1119
|
+
if (role==='photo'){ st.photo = path; st.corners = null; } else st.shot = path;
|
|
1120
|
+
closePop();
|
|
1121
|
+
maybeStart();
|
|
1122
|
+
}
|
|
1123
|
+
async function upload(role, file){
|
|
1124
|
+
const buf = await file.arrayBuffer();
|
|
1125
|
+
const r = await api('/api/upload', null, {headers:{'X-Filename':encodeURIComponent(file.name),'X-Role':role}, body:buf});
|
|
1126
|
+
setChosen(role, r.path, r.size, null);
|
|
1127
|
+
}
|
|
1128
|
+
for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
1129
|
+
const drop = $('#drop'+n), file = $('#file'+n);
|
|
1130
|
+
drop.onclick = () => file.click();
|
|
1131
|
+
file.onchange = () => file.files[0] && upload(role, file.files[0]);
|
|
1132
|
+
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
|
|
1133
|
+
drop.ondragleave = () => drop.classList.remove('over');
|
|
1134
|
+
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
|
|
1135
|
+
$('#chip'+n).onclick = () => openPop(n);
|
|
1136
|
+
}
|
|
1137
|
+
/* One popover mechanic for every popover: anchor it under whatever opened it,
|
|
1138
|
+
clamped into the viewport. The pickers and the preview both use it. */
|
|
1139
|
+
function openPopAt(pop, anchor){
|
|
1140
|
+
closePop();
|
|
1141
|
+
pop.classList.add('on'); $('#scrim').classList.add('on');
|
|
1142
|
+
const w = pop.offsetWidth;
|
|
1143
|
+
pop.style.left = Math.max(12, Math.min(window.innerWidth - w - 12, anchor.left)) + 'px';
|
|
1144
|
+
pop.style.top = (anchor.bottom + 8) + 'px';
|
|
1145
|
+
}
|
|
1146
|
+
function openPop(n){ openPopAt($('#pop'+n), $('#chip'+n).getBoundingClientRect()); }
|
|
1147
|
+
function closePop(){
|
|
1148
|
+
document.querySelectorAll('.pop').forEach(p=>p.classList.remove('on'));
|
|
1149
|
+
$('#scrim').classList.remove('on');
|
|
1150
|
+
}
|
|
1151
|
+
$('#scrim').onclick = closePop;
|
|
1152
|
+
|
|
1153
|
+
/* The Figma export is the one step the page cannot do itself — it has no
|
|
1154
|
+
Figma credentials, the agent does. So this enqueues a request and waits.
|
|
1155
|
+
|
|
1156
|
+
Two things this used to get wrong. It claimed "(it watches this job)",
|
|
1157
|
+
which was false: the agent runs in turns and was not watching anything, so
|
|
1158
|
+
the user waited on something that would never happen unless they typed in
|
|
1159
|
+
chat. And it polled forever with no escape, so there was no point at which
|
|
1160
|
+
the page admitted it was stuck. Both are fixed here: the agent now parks in
|
|
1161
|
+
the MCP server's wait_for_job and is reached within ~150ms, and if that is
|
|
1162
|
+
somehow not running we say so and offer the manual route rather than
|
|
1163
|
+
spinning indefinitely. */
|
|
1164
|
+
const NUDGE_AFTER = 12; // seconds before we stop assuming and start explaining
|
|
1165
|
+
|
|
1166
|
+
/* Wait for the agent to answer the current job.
|
|
1167
|
+
|
|
1168
|
+
LONG POLL, deliberately not setInterval. Chrome throttles timers to about
|
|
1169
|
+
once a minute in a hidden tab, and this page is hidden for exactly the
|
|
1170
|
+
workflow it serves: paste a Figma link, switch to Figma. Measured 4 Sep
|
|
1171
|
+
2026 — the agent answered in ~200ms and the page kept spinning, because its
|
|
1172
|
+
poll had been throttled. A pending fetch is not throttled, so the answer
|
|
1173
|
+
arrives when it exists. /api/job/wait blocks server-side and returns early
|
|
1174
|
+
the moment the status changes. */
|
|
1175
|
+
async function awaitJob({onDone, onError, cancelled}) {
|
|
1176
|
+
while (!cancelled()) {
|
|
1177
|
+
let j;
|
|
1178
|
+
try {
|
|
1179
|
+
j = await api('/api/job/wait?timeout=25');
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
// Server restarted or a transient blip: pause, then keep waiting rather
|
|
1182
|
+
// than declaring failure at the user.
|
|
1183
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
if (cancelled()) return;
|
|
1187
|
+
if (j.status === 'done') return onDone(j);
|
|
1188
|
+
if (j.status === 'error') return onError(j);
|
|
1189
|
+
if (j.status === 'none') {
|
|
1190
|
+
// The job file is gone — a wiped session, or a restarted server. The
|
|
1191
|
+
// server answers 'none' instantly, so looping on it would spin as fast
|
|
1192
|
+
// as the network allows. Stop and say so instead of hammering.
|
|
1193
|
+
return onError({message: 'the request disappeared (was the UI restarted?). Try again.'});
|
|
1194
|
+
}
|
|
1195
|
+
// 'pending' with waited:true — the long poll timed out server-side; go again.
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
$('#figmaBtn').onclick = async () => {
|
|
1199
|
+
const url = $('#figma').value.trim(); if (!url) return;
|
|
1200
|
+
const s = $('#figmaSt'), hint = $('#figmaHint');
|
|
1201
|
+
const btn = $('#figmaBtn'); btn.disabled = true;
|
|
1202
|
+
const t0 = Date.now();
|
|
1203
|
+
let finished = false;
|
|
1204
|
+
const paint = () => {
|
|
1205
|
+
const el = Math.round((Date.now() - t0) / 1000);
|
|
1206
|
+
s.className = 'status sm busy';
|
|
1207
|
+
s.innerHTML = `Exporting from Figma <span class="el">${el}s</span>`;
|
|
1208
|
+
if (el >= NUDGE_AFTER && hint.hidden) {
|
|
1209
|
+
hint.hidden = false;
|
|
1210
|
+
hint.innerHTML = 'Taking longer than usual. Claude picks this up automatically while it\'s ' +
|
|
1211
|
+
'waiting on the job — if it\'s busy in another task it will arrive when that ' +
|
|
1212
|
+
'finishes. <b>Or export the frame as PNG yourself and drop it above</b> — ' +
|
|
1213
|
+
'that always works and needs nobody.';
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
paint();
|
|
1217
|
+
const tick = setInterval(paint, 1000);
|
|
1218
|
+
// The elapsed counter is a timer and so is throttled while hidden; repaint on
|
|
1219
|
+
// return so the number is right the moment the designer looks at it again.
|
|
1220
|
+
document.addEventListener('visibilitychange', paint);
|
|
1221
|
+
const stop = (cls, text) => {
|
|
1222
|
+
finished = true;
|
|
1223
|
+
clearInterval(tick);
|
|
1224
|
+
document.removeEventListener('visibilitychange', paint);
|
|
1225
|
+
btn.disabled = false; hint.hidden = true;
|
|
1226
|
+
s.className = 'status sm ' + cls; s.textContent = text;
|
|
1227
|
+
// The popover is usually closed by now — the in-place line would go unread.
|
|
1228
|
+
toast(cls === 'ok' ? 'ok' : 'err', cls === 'ok' ? 'Screenshot exported from Figma.' : text);
|
|
1229
|
+
};
|
|
1230
|
+
try { await api('/api/figma', {url}); }
|
|
1231
|
+
catch (e) { return stop('err', 'Could not queue the export: ' + e.message); }
|
|
1232
|
+
await awaitJob({
|
|
1233
|
+
cancelled: () => finished,
|
|
1234
|
+
onDone: async () => {
|
|
1235
|
+
try {
|
|
1236
|
+
const r = await api('/api/job/adopt', {});
|
|
1237
|
+
stop('ok', 'Exported');
|
|
1238
|
+
setChosen('screenshot', r.path, r.size, null);
|
|
1239
|
+
} catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
|
|
1240
|
+
},
|
|
1241
|
+
onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
|
|
1242
|
+
});
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
/* ===== fitting the quad ====================================================
|
|
1246
|
+
EDGES are the grab targets. A rounded screen corner has no point to aim at;
|
|
1247
|
+
the two straight edges either side of it are unambiguous. Dragging edge i
|
|
1248
|
+
moves only its LINE — corners i and i+1 are recomputed as intersections
|
|
1249
|
+
with the two UNCHANGED adjacent lines. Corner handles stay for edges that
|
|
1250
|
+
run off-frame or sit behind an occluder. */
|
|
1251
|
+
const cv = $('#c'), ctx = cv.getContext('2d');
|
|
1252
|
+
const strip = $('#strip'), sctx = strip.getContext('2d');
|
|
1253
|
+
const stripF = $('#stripF'), fctx = stripF.getContext('2d');
|
|
1254
|
+
const img = new Image();
|
|
1255
|
+
let scale = 1, fitScale = 1, drag = null, hover = null, pick = null;
|
|
1256
|
+
let stripHalf = 14, loupeMode = recall('loupe','dock'), lastPointer = [0,0];
|
|
1257
|
+
const LBL = ['TL','TR','BR','BL'], EDGE_LBL = ['top','right','bottom','left'];
|
|
1258
|
+
|
|
1259
|
+
const sub=(a,b)=>[a[0]-b[0],a[1]-b[1]], add=(a,b)=>[a[0]+b[0],a[1]+b[1]];
|
|
1260
|
+
const mul=(a,k)=>[a[0]*k,a[1]*k], vlen=a=>Math.hypot(a[0],a[1]);
|
|
1261
|
+
const unit=a=>{const l=vlen(a)||1;return [a[0]/l,a[1]/l];};
|
|
1262
|
+
function interLines(p,d,q,e){
|
|
1263
|
+
const den = d[0]*e[1]-d[1]*e[0];
|
|
1264
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
1265
|
+
const t = ((q[0]-p[0])*e[1]-(q[1]-p[1])*e[0])/den;
|
|
1266
|
+
return [p[0]+d[0]*t, p[1]+d[1]*t];
|
|
1267
|
+
}
|
|
1268
|
+
function distToSeg(p,a,b){
|
|
1269
|
+
const ab=sub(b,a), L2=ab[0]*ab[0]+ab[1]*ab[1];
|
|
1270
|
+
let t = L2 ? ((p[0]-a[0])*ab[0]+(p[1]-a[1])*ab[1])/L2 : 0;
|
|
1271
|
+
t = Math.max(0, Math.min(1, t));
|
|
1272
|
+
return {d: vlen(sub(p, add(a, mul(ab,t)))), t};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function maybeStart(){
|
|
1276
|
+
if (!st.photo) return;
|
|
1277
|
+
const ready = !!st.shot; // the screenshot gates the fit, not the photo
|
|
1278
|
+
img.onload = () => {
|
|
1279
|
+
computeFit(); setCanvasZoom(fitScale);
|
|
1280
|
+
if (!ready){
|
|
1281
|
+
// Photo only: show it immediately so choosing one visibly does
|
|
1282
|
+
// something. No quad yet — there is nothing to fit into it.
|
|
1283
|
+
draw();
|
|
1284
|
+
$('#cvbar').hidden = true;
|
|
1285
|
+
$('#detSt').className = 'status';
|
|
1286
|
+
$('#detSt').textContent = 'Now choose the screenshot to place on the screen.';
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
$('#cvbar').hidden = false;
|
|
1290
|
+
if (!st.corners) detect(); else { draw(); drawStrip(); }
|
|
1291
|
+
};
|
|
1292
|
+
img.src = fileURL(st.photo);
|
|
1293
|
+
}
|
|
1294
|
+
function computeFit(){
|
|
1295
|
+
const box = $('#scroller');
|
|
1296
|
+
const w = box.clientWidth || 800, h = box.clientHeight || 600;
|
|
1297
|
+
fitScale = Math.min(1, Math.min(w / img.naturalWidth, h / img.naturalHeight));
|
|
1298
|
+
}
|
|
1299
|
+
function setCanvasZoom(z, focus){
|
|
1300
|
+
const prev = scale;
|
|
1301
|
+
scale = Math.max(0.05, Math.min(4, z));
|
|
1302
|
+
cv.width = Math.round(img.naturalWidth * scale);
|
|
1303
|
+
cv.height = Math.round(img.naturalHeight * scale);
|
|
1304
|
+
$('#czSt').textContent = Math.round(scale*100) + '%';
|
|
1305
|
+
draw();
|
|
1306
|
+
const sc = $('#scroller');
|
|
1307
|
+
if (focus){
|
|
1308
|
+
sc.scrollLeft = focus[0]*scale - sc.clientWidth/2;
|
|
1309
|
+
sc.scrollTop = focus[1]*scale - sc.clientHeight/2;
|
|
1310
|
+
} else if (prev && prev !== scale){
|
|
1311
|
+
const cx = (sc.scrollLeft + sc.clientWidth/2)/prev, cy = (sc.scrollTop + sc.clientHeight/2)/prev;
|
|
1312
|
+
sc.scrollLeft = cx*scale - sc.clientWidth/2;
|
|
1313
|
+
sc.scrollTop = cy*scale - sc.clientHeight/2;
|
|
1314
|
+
}
|
|
1315
|
+
syncOut();
|
|
1316
|
+
}
|
|
1317
|
+
$('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
|
|
1318
|
+
$('#cz100').onclick = () => setCanvasZoom(1);
|
|
1319
|
+
$('#czIn').onclick = () => setCanvasZoom(scale*1.5);
|
|
1320
|
+
$('#czOut').onclick = () => setCanvasZoom(scale/1.5);
|
|
1321
|
+
document.querySelectorAll('[data-jump]').forEach(b => {
|
|
1322
|
+
b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
|
|
1323
|
+
pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
async function detect(){
|
|
1327
|
+
const s = $('#detSt'); s.className='status busy'; s.textContent = 'Detecting…';
|
|
1328
|
+
const r = await api('/api/detect', {});
|
|
1329
|
+
if (r.found){
|
|
1330
|
+
st.corners = r.corners; st.measured = r.corner_radius;
|
|
1331
|
+
const corroborated = r.confidence === 'corroborated';
|
|
1332
|
+
s.className = 'status ' + (corroborated ? 'ok' : 'warn');
|
|
1333
|
+
s.textContent = corroborated ? `Both detectors agree (${r.method})`
|
|
1334
|
+
: `Unconfirmed ${r.method} guess — check all four edges`;
|
|
1335
|
+
if (!st.type) setType(r.type_guess || 'phone');
|
|
1336
|
+
if (r.corner_radius && r.corner_radius.confident){ setFrac(r.corner_radius.frac_of_width, 'measured from the photo'); }
|
|
1337
|
+
else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
|
|
1338
|
+
} else {
|
|
1339
|
+
const w = img.naturalWidth, h = img.naturalHeight;
|
|
1340
|
+
st.corners = [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
|
|
1341
|
+
s.className='status warn'; s.textContent = 'No screen found — place the edges by hand';
|
|
1342
|
+
if (!st.type) setType('phone'); applyPreset();
|
|
1343
|
+
}
|
|
1344
|
+
pick = {kind:'edge', i:0};
|
|
1345
|
+
draw(); drawStrip();
|
|
1346
|
+
autoPreview(); // if the compare pane is open, fill it straight away
|
|
1347
|
+
}
|
|
1348
|
+
$('#redetect').onclick = detect;
|
|
1349
|
+
|
|
1350
|
+
function draw(){
|
|
1351
|
+
ctx.clearRect(0,0,cv.width,cv.height);
|
|
1352
|
+
if (!img.naturalWidth) return;
|
|
1353
|
+
ctx.drawImage(img, 0,0, cv.width, cv.height);
|
|
1354
|
+
if (!st.corners) return;
|
|
1355
|
+
const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
|
|
1356
|
+
const active = drag || hover || pick;
|
|
1357
|
+
|
|
1358
|
+
/* Overlay geometry ported from Figma 33:856 — 1px edge, 9px hollow ring
|
|
1359
|
+
handles, 0.8px dashed guide, and the edge BREAKING 13px around each
|
|
1360
|
+
handle so the ring reads as a grab target rather than a bead on a wire.
|
|
1361
|
+
|
|
1362
|
+
The colour is the one deliberate deviation. The frame draws every element
|
|
1363
|
+
flat #EC4C13 over one photo that suits it; measured against real material
|
|
1364
|
+
the accent alone is 1.06:1 on mid grey, 1.07:1 on terracotta and 1.35:1 on
|
|
1365
|
+
the wood table — invisible on exactly the photos this tool is for, which
|
|
1366
|
+
is the same trap v0.6.0 found with the old yellow at 1.02:1. So the accent
|
|
1367
|
+
is the CORE and the dark casing stays underneath: the casing carries light
|
|
1368
|
+
backgrounds (6.19:1 on white), the accent carries dark ones (5.29:1 on
|
|
1369
|
+
black), and one of the two always separates. Deliberate. */
|
|
1370
|
+
// Handle sizing, 5 Sep. Corners went 4.5 -> 5.5 and are now FILLED rather than
|
|
1371
|
+
// hollow; the pivot pips went 2.5 -> 3.5 and are filled too, staying visibly
|
|
1372
|
+
// smaller than a corner so the two never read as the same control. The gap the
|
|
1373
|
+
// edge leaves for each corner grows with it, or the disc sits on the line.
|
|
1374
|
+
const HANDLE_R = 5.5, PIVOT_R = 3.5, HANDLE_GAP = 7.5;
|
|
1375
|
+
|
|
1376
|
+
// Guide: the active edge extended to infinity, so you can see the line it
|
|
1377
|
+
// is really on. Thinner and dashed — it is a reference, not a handle.
|
|
1378
|
+
if (active && active.kind === 'edge'){
|
|
1379
|
+
const a=P[active.i], b=P[(active.i+1)%4], d=unit(sub(b,a)), big=4000;
|
|
1380
|
+
cased(ctx, () => { ctx.beginPath();
|
|
1381
|
+
ctx.moveTo(a[0]-d[0]*big, a[1]-d[1]*big); ctx.lineTo(b[0]+d[0]*big, b[1]+d[1]*big); },
|
|
1382
|
+
0.8, CORE_ACTIVE, [2,6]);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
for (let i=0;i<4;i++){
|
|
1386
|
+
const a=P[i], b=P[(i+1)%4];
|
|
1387
|
+
const on = active && active.kind==='edge' && active.i===i;
|
|
1388
|
+
const d = unit(sub(b,a)), L = vlen(sub(b,a));
|
|
1389
|
+
// Break the edge around both of its corner handles. Guard the degenerate
|
|
1390
|
+
// case where the edge is shorter than the two gaps, or the trimmed start
|
|
1391
|
+
// would overshoot the end and draw the segment backwards.
|
|
1392
|
+
const gap = Math.min(HANDLE_GAP, L/2 - 0.5);
|
|
1393
|
+
const s0 = add(a, mul(d, Math.max(0, gap)));
|
|
1394
|
+
const s1 = add(a, mul(d, Math.max(0, L - gap)));
|
|
1395
|
+
if (L > 1){
|
|
1396
|
+
cased(ctx, () => { ctx.beginPath(); ctx.moveTo(s0[0],s0[1]); ctx.lineTo(s1[0],s1[1]); },
|
|
1397
|
+
on ? 2 : 1, CORE_QUAD);
|
|
1398
|
+
}
|
|
1399
|
+
// Pivot pips on the active edge keep their meaning: grab near an end to
|
|
1400
|
+
// rotate. Filled, like the corners, but deliberately smaller — size is what
|
|
1401
|
+
// separates "rotate this edge" from "move this corner".
|
|
1402
|
+
if (on){
|
|
1403
|
+
for (const t of [0.16, 0.84]){
|
|
1404
|
+
const q = add(a, mul(d, L*t));
|
|
1405
|
+
casedDisc(ctx, q[0], q[1], PIVOT_R, CORE_ACTIVE);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Corner handles: FILLED discs, 11px, 13px while dragging.
|
|
1411
|
+
// These were hollow rings so the pixels under the handle stayed visible, which
|
|
1412
|
+
// matters when you are aiming at a point. filled was requested, and it holds
|
|
1413
|
+
// up because the ring was solving a problem the tool solves better elsewhere:
|
|
1414
|
+
// the strip loupe is the precision instrument, and it shows the boundary
|
|
1415
|
+
// rectified and magnified rather than through a 9px hole. A filled disc is
|
|
1416
|
+
// easier to see and to hit; the loupe is where you actually judge alignment.
|
|
1417
|
+
ctx.font='600 11px system-ui'; ctx.textBaseline='alphabetic';
|
|
1418
|
+
P.forEach(([x,y],i) => {
|
|
1419
|
+
const on = active && active.kind==='corner' && active.i===i;
|
|
1420
|
+
casedDisc(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
|
|
1421
|
+
casedText(ctx, LBL[i], x+13, y-11, 'rgba(255,255,255,.95)');
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function pos(e){
|
|
1426
|
+
const r = cv.getBoundingClientRect();
|
|
1427
|
+
return [(e.clientX-r.left)*cv.width/r.width/scale, (e.clientY-r.top)*cv.height/r.height/scale];
|
|
1428
|
+
}
|
|
1429
|
+
function hitTest(p){
|
|
1430
|
+
if (!st.corners) return null;
|
|
1431
|
+
const tolC = 12/scale, tolE = 10/scale;
|
|
1432
|
+
let best=null, bd=tolC;
|
|
1433
|
+
st.corners.forEach((c,i) => { const d=vlen(sub(c,p)); if (d<bd){ bd=d; best={kind:'corner', i}; } });
|
|
1434
|
+
if (best) return best;
|
|
1435
|
+
bd = tolE;
|
|
1436
|
+
for (let i=0;i<4;i++){
|
|
1437
|
+
const {d,t} = distToSeg(p, st.corners[i], st.corners[(i+1)%4]);
|
|
1438
|
+
if (d < bd){ bd = d; best = {kind:'edge', i, mode: t<0.25?'rotB':(t>0.75?'rotA':'translate')}; }
|
|
1439
|
+
}
|
|
1440
|
+
return best;
|
|
1441
|
+
}
|
|
1442
|
+
function moveEdge(C, i, mode, delta){
|
|
1443
|
+
const A=C[i], B=C[(i+1)%4], P=C[(i+3)%4], Q=C[(i+2)%4];
|
|
1444
|
+
const dPrev=sub(A,P), dNext=sub(Q,B);
|
|
1445
|
+
const u=unit(sub(B,A)), n=[-u[1],u[0]];
|
|
1446
|
+
let p0, d;
|
|
1447
|
+
if (mode==='translate'){
|
|
1448
|
+
const off = delta[0]*n[0]+delta[1]*n[1];
|
|
1449
|
+
p0 = add(A, mul(n,off)); d = u;
|
|
1450
|
+
} else {
|
|
1451
|
+
const pivot = mode==='rotA' ? A : B, moving = mode==='rotA' ? B : A;
|
|
1452
|
+
p0 = pivot; d = unit(sub(add(moving,delta), pivot));
|
|
1453
|
+
}
|
|
1454
|
+
const nA = interLines(p0,d,P,dPrev), nB = interLines(p0,d,Q,dNext);
|
|
1455
|
+
if (!nA || !nB || ![...nA,...nB].every(Number.isFinite)) return null;
|
|
1456
|
+
const out = C.map(c => c.slice());
|
|
1457
|
+
out[i] = nA; out[(i+1)%4] = nB;
|
|
1458
|
+
return out;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
cv.onpointerdown = e => {
|
|
1462
|
+
const p = pos(e), h = hitTest(p);
|
|
1463
|
+
if (!h){ pick = null; draw(); drawStrip(); return; }
|
|
1464
|
+
drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
|
|
1465
|
+
pick = {kind:h.kind, i:h.i};
|
|
1466
|
+
cv.setPointerCapture(e.pointerId);
|
|
1467
|
+
lastPointer = [e.clientX, e.clientY];
|
|
1468
|
+
draw(); drawStrip();
|
|
1469
|
+
};
|
|
1470
|
+
cv.onpointermove = e => {
|
|
1471
|
+
const p = pos(e);
|
|
1472
|
+
lastPointer = [e.clientX, e.clientY];
|
|
1473
|
+
if (!drag){
|
|
1474
|
+
const h = hitTest(p);
|
|
1475
|
+
const changed = JSON.stringify(h) !== JSON.stringify(hover);
|
|
1476
|
+
hover = h;
|
|
1477
|
+
cv.style.cursor = h ? (h.kind==='corner' ? 'grab' : (h.mode==='translate' ? 'move' : 'crosshair')) : 'default';
|
|
1478
|
+
if (changed){ draw(); drawStrip(); }
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const delta = sub(p, drag.grab);
|
|
1482
|
+
if (drag.kind === 'corner'){
|
|
1483
|
+
const C = drag.start.map(c => c.slice());
|
|
1484
|
+
C[drag.i] = [Math.max(0,Math.min(img.naturalWidth, p[0])), Math.max(0,Math.min(img.naturalHeight, p[1]))];
|
|
1485
|
+
st.corners = C;
|
|
1486
|
+
} else {
|
|
1487
|
+
const C = moveEdge(drag.start, drag.i, drag.mode, delta);
|
|
1488
|
+
if (C) st.corners = C;
|
|
1489
|
+
}
|
|
1490
|
+
draw(); drawStrip(); markStale();
|
|
1491
|
+
};
|
|
1492
|
+
cv.onpointerup = cv.onpointercancel = () => { drag = null; draw(); drawStrip(); autoPreview(); };
|
|
1493
|
+
cv.onpointerleave = () => { if (!drag){ hover = null; draw(); drawStrip(); } };
|
|
1494
|
+
|
|
1495
|
+
window.addEventListener('keydown', e => {
|
|
1496
|
+
if (e.key === 'Escape'){ closePop(); return; }
|
|
1497
|
+
if (!st.corners || document.querySelector('.pop.on')) return;
|
|
1498
|
+
if (e.key === 'Tab'){
|
|
1499
|
+
e.preventDefault();
|
|
1500
|
+
const i = pick && pick.kind==='edge' ? (pick.i+1)%4 : 0;
|
|
1501
|
+
pick = {kind:'edge', i}; draw(); drawStrip(); return;
|
|
1502
|
+
}
|
|
1503
|
+
if (!pick) return;
|
|
1504
|
+
const step = e.shiftKey ? 10 : 1;
|
|
1505
|
+
const d = {ArrowLeft:[-step,0], ArrowRight:[step,0], ArrowUp:[0,-step], ArrowDown:[0,step]}[e.key];
|
|
1506
|
+
if (!d) return;
|
|
1507
|
+
e.preventDefault();
|
|
1508
|
+
if (pick.kind === 'corner'){
|
|
1509
|
+
const C = st.corners.map(c => c.slice()); C[pick.i] = add(C[pick.i], d); st.corners = C;
|
|
1510
|
+
} else {
|
|
1511
|
+
const C = moveEdge(st.corners, pick.i, 'translate', d); if (C) st.corners = C;
|
|
1512
|
+
}
|
|
1513
|
+
draw(); drawStrip(); markStale(); autoPreview();
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
/* ===== the rectified strip: the selected edge mapped onto the horizontal and
|
|
1517
|
+
magnified perpendicular, so misalignment reads as a wedge across its whole
|
|
1518
|
+
length. One affine setTransform + drawImage. ===== */
|
|
1519
|
+
$('#stripIn').onclick = () => { stripHalf = Math.max(3, Math.round(stripHalf/1.6)); drawStrip(); };
|
|
1520
|
+
$('#stripOut').onclick = () => { stripHalf = Math.min(80, Math.round(stripHalf*1.6)); drawStrip(); };
|
|
1521
|
+
/* Edge view is a switch now, not a two-way segmented control: ON = floating,
|
|
1522
|
+
OFF = docked. The segmented control implied two peers; in practice docked is
|
|
1523
|
+
the resting state and floating is the thing you turn on while working a
|
|
1524
|
+
corner, which is what a switch says. */
|
|
1525
|
+
function setLoupeMode(m){
|
|
1526
|
+
loupeMode = m; remember('loupe', m);
|
|
1527
|
+
setSwitch($('#edgeBtn'), m === 'float');
|
|
1528
|
+
$('#dock').classList.toggle('hidden', m!=='dock');
|
|
1529
|
+
if (m!=='float') $('#floatLoupe').classList.remove('on');
|
|
1530
|
+
if (img.naturalWidth){
|
|
1531
|
+
const wasFit = Math.abs(scale-fitScale)<1e-6;
|
|
1532
|
+
computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
|
|
1533
|
+
}
|
|
1534
|
+
drawStrip();
|
|
1535
|
+
}
|
|
1536
|
+
/* One place that paints a switch and the section it governs, so a section's
|
|
1537
|
+
body cannot get out of step with its own switch. */
|
|
1538
|
+
function setSwitch(btn, on){
|
|
1539
|
+
if (!btn) return;
|
|
1540
|
+
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
1541
|
+
const sect = btn.closest('.sect');
|
|
1542
|
+
if (sect) setSectionOpen(sect, on);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/* Open/close a section's body by animating a MEASURED height. See the CSS note
|
|
1546
|
+
for why this isn't grid-template-rows. `booting` skips the animation on the
|
|
1547
|
+
first paint, so a section that starts closed doesn't visibly collapse itself
|
|
1548
|
+
in front of the user. */
|
|
1549
|
+
let sectionsBooted = false;
|
|
1550
|
+
function setSectionOpen(sect, on){
|
|
1551
|
+
const was = sect.dataset.on;
|
|
1552
|
+
sect.dataset.on = on ? '1' : '0';
|
|
1553
|
+
const body = sect.querySelector('.sect-body');
|
|
1554
|
+
if (!body) return;
|
|
1555
|
+
const inner = body.querySelector('.sect-body-in');
|
|
1556
|
+
const target = inner ? inner.scrollHeight : body.scrollHeight;
|
|
1557
|
+
clearTimeout(body._relax);
|
|
1558
|
+
|
|
1559
|
+
if (!sectionsBooted){ // no animation before the first paint
|
|
1560
|
+
body.style.transition = 'none';
|
|
1561
|
+
body.style.maxHeight = on ? 'none' : '0px';
|
|
1562
|
+
void body.offsetHeight;
|
|
1563
|
+
body.style.transition = '';
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
// Already open (or already closed) and settled: there is nothing to animate.
|
|
1567
|
+
// Without this the section re-plays its whole open animation — collapse to 0,
|
|
1568
|
+
// grow back to target — on every call. paintGrade() calls setSwitch() on every
|
|
1569
|
+
// `input` event, so dragging the Realism slider made the rail jump under the
|
|
1570
|
+
// cursor once per tick. Settled means the cap is at its resting value; a
|
|
1571
|
+
// pixel value means a previous animation was interrupted, so re-run it.
|
|
1572
|
+
const settled = body.style.maxHeight === (on ? 'none' : '0px');
|
|
1573
|
+
if (was === sect.dataset.on && settled) return;
|
|
1574
|
+
// Always animate FROM a real number. Going from `none` or from an interrupted
|
|
1575
|
+
// value is what left a section stuck: max-height:0 with data-on="1".
|
|
1576
|
+
if (on){
|
|
1577
|
+
body.style.maxHeight = '0px';
|
|
1578
|
+
void body.offsetHeight;
|
|
1579
|
+
body.style.maxHeight = target + 'px';
|
|
1580
|
+
// Release the cap once open so a body whose content grows isn't clipped by
|
|
1581
|
+
// a stale measurement. A timer, not transitionend — an interrupted
|
|
1582
|
+
// transition never fires it, and then the section stays capped for ever.
|
|
1583
|
+
body._relax = setTimeout(() => {
|
|
1584
|
+
if (sect.dataset.on === '1') body.style.maxHeight = 'none';
|
|
1585
|
+
}, 300);
|
|
1586
|
+
} else {
|
|
1587
|
+
body.style.maxHeight = target + 'px';
|
|
1588
|
+
void body.offsetHeight;
|
|
1589
|
+
body.style.maxHeight = '0px';
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/* Realism (M2). ON by default from 5 Sep — a composite that ignores the light it
|
|
1594
|
+
sits in is the thing this pass exists to fix, so the default should be the
|
|
1595
|
+
corrected one. Off stays a first-class choice: it keeps the screenshot's own
|
|
1596
|
+
colour exactly, which is what you want when the UI's colour is the thing under
|
|
1597
|
+
review. Strength is a slider because the right amount is a judgement about the
|
|
1598
|
+
photograph, not a constant. */
|
|
1599
|
+
let gradeOn = recall('grade','1') === '1';
|
|
1600
|
+
let gradeAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
|
|
1601
|
+
function gradeValue(){ return gradeOn ? gradeAmt : 0; }
|
|
1602
|
+
function paintGrade(){
|
|
1603
|
+
setSwitch($('#gradeBtn'), gradeOn); // hides the slider when off
|
|
1604
|
+
$('#gradeVal').textContent = Math.round(gradeAmt * 100) + '%';
|
|
1605
|
+
const sl = $('#gradeAmt');
|
|
1606
|
+
sl.value = gradeAmt;
|
|
1607
|
+
sl.style.setProperty('--p', (gradeAmt - 0.1) / 0.9);
|
|
1608
|
+
}
|
|
1609
|
+
function setGrade(on, amt){
|
|
1610
|
+
gradeOn = on;
|
|
1611
|
+
if (amt !== undefined) gradeAmt = amt;
|
|
1612
|
+
remember('grade', on ? '1' : '0'); remember('gradeAmt', String(gradeAmt));
|
|
1613
|
+
paintGrade();
|
|
1614
|
+
}
|
|
1615
|
+
$('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
|
|
1616
|
+
// Repaint live while dragging, re-render only on release: a full compose per
|
|
1617
|
+
// input event would queue renders faster than they finish.
|
|
1618
|
+
$('#gradeAmt').oninput = e => { gradeAmt = parseFloat(e.target.value); paintGrade(); };
|
|
1619
|
+
$('#gradeAmt').onchange = e => { setGrade(true, parseFloat(e.target.value)); autoPreview(); };
|
|
1620
|
+
|
|
1621
|
+
$('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
|
|
1622
|
+
|
|
1623
|
+
function paintStrip(c, W, H, i){
|
|
1624
|
+
c.setTransform(1,0,0,1,0,0);
|
|
1625
|
+
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
1626
|
+
const A = st.corners[i], B = st.corners[(i+1)%4];
|
|
1627
|
+
const L = vlen(sub(B,A));
|
|
1628
|
+
if (L < 2) return;
|
|
1629
|
+
const u = unit(sub(B,A)), n = [-u[1], u[0]];
|
|
1630
|
+
const k1 = W / L, k2 = H / (2*stripHalf);
|
|
1631
|
+
c.save();
|
|
1632
|
+
c.setTransform(k1*u[0], k2*n[0], k1*u[1], k2*n[1],
|
|
1633
|
+
-k1*(u[0]*A[0] + u[1]*A[1]),
|
|
1634
|
+
-k2*(n[0]*A[0] + n[1]*A[1]) + k2*stripHalf);
|
|
1635
|
+
c.imageSmoothingEnabled = true;
|
|
1636
|
+
c.drawImage(img, 0, 0);
|
|
1637
|
+
c.restore();
|
|
1638
|
+
cased(c, () => { c.beginPath(); c.moveTo(0, H/2); c.lineTo(W, H/2); }, 1.25, CORE_ACTIVE);
|
|
1639
|
+
cased(c, () => { c.beginPath();
|
|
1640
|
+
for (const f of [0.25,0.5,0.75]){ c.moveTo(W*f, H/2-9); c.lineTo(W*f, H/2+9); } },
|
|
1641
|
+
1, 'rgba(255,255,255,.7)');
|
|
1642
|
+
const centre = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
|
|
1643
|
+
const inwardIsDown = (n[0]*(centre[0]-A[0]) + n[1]*(centre[1]-A[1])) > 0;
|
|
1644
|
+
c.font = '600 11px system-ui'; c.textBaseline = 'middle';
|
|
1645
|
+
casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
|
|
1646
|
+
casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
|
|
1647
|
+
}
|
|
1648
|
+
function drawStrip(){
|
|
1649
|
+
const a = drag || hover || pick;
|
|
1650
|
+
const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
|
|
1651
|
+
$('#stripZ').textContent = '±' + stripHalf + 'px';
|
|
1652
|
+
if (loupeMode === 'dock'){
|
|
1653
|
+
$('#floatLoupe').classList.remove('on');
|
|
1654
|
+
const W = strip.width, H = strip.height;
|
|
1655
|
+
if (!has){
|
|
1656
|
+
sctx.setTransform(1,0,0,1,0,0); sctx.fillStyle='#0c0c0b'; sctx.fillRect(0,0,W,H);
|
|
1657
|
+
$('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
paintStrip(sctx, W, H, a.i);
|
|
1661
|
+
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — flat on the rule means aligned`;
|
|
1662
|
+
} else {
|
|
1663
|
+
const fl = $('#floatLoupe');
|
|
1664
|
+
if (!has){ fl.classList.remove('on'); return; }
|
|
1665
|
+
paintStrip(fctx, stripF.width, stripF.height, a.i);
|
|
1666
|
+
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — flat on the rule means aligned`;
|
|
1667
|
+
fl.classList.add('on');
|
|
1668
|
+
const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
|
|
1669
|
+
let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
|
|
1670
|
+
if (x + w > window.innerWidth - 8) x = lastPointer[0] - w - 26;
|
|
1671
|
+
if (y < 8) y = lastPointer[1] + 26;
|
|
1672
|
+
fl.style.left = Math.max(8, x) + 'px';
|
|
1673
|
+
fl.style.top = Math.max(8, Math.min(window.innerHeight - h - 8, y)) + 'px';
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/* ===== device / radius ===== */
|
|
1678
|
+
function setType(t){
|
|
1679
|
+
st.type = t;
|
|
1680
|
+
document.querySelectorAll('#types .chip').forEach(c => c.classList.toggle('on', c.dataset.t===t));
|
|
1681
|
+
const sel = $('#preset'); sel.innerHTML='';
|
|
1682
|
+
st.presets.filter(p=>p.type===t).forEach(p => { const o=document.createElement('option'); o.value=p.id; o.textContent=`${p.label} (${(p.frac*100).toFixed(1)}%)`; sel.appendChild(o); });
|
|
1683
|
+
st.preset = sel.value;
|
|
1684
|
+
}
|
|
1685
|
+
function applyPreset(){ const p = st.presets.find(p=>p.id===$('#preset').value); if (p) setFrac(p.frac, 'preset: '+p.label); }
|
|
1686
|
+
function setFrac(f, why){ st.frac = f; $('#radius').value = f; syncSlider();
|
|
1687
|
+
$('#radSt').textContent = `${(f*100).toFixed(1)}% of screen width — ${why}`; markStale(); }
|
|
1688
|
+
$('#preset').onchange = () => { applyPreset(); autoPreview(); };
|
|
1689
|
+
$('#radius').oninput = e => { setFrac(parseFloat(e.target.value), 'manual'); autoPreview(); };
|
|
1690
|
+
|
|
1691
|
+
/* Corner radius on/off. ON by default, because almost every device this tool is
|
|
1692
|
+
pointed at has rounded glass — square corners are the exception, not the
|
|
1693
|
+
resting state.
|
|
1694
|
+
Off does NOT throw the radius away: `st.frac` keeps its value and `radiusValue()`
|
|
1695
|
+
reports 0 while the switch is off, so turning it back on restores exactly the
|
|
1696
|
+
number you had rather than a preset guess. Same contract as Realism's
|
|
1697
|
+
strength — a toggle that forgets is a toggle you avoid using. */
|
|
1698
|
+
let radiusOn = recall('radiusOn','1') === '1';
|
|
1699
|
+
function radiusValue(){ return radiusOn ? st.frac : 0; }
|
|
1700
|
+
function setRadiusOn(on){
|
|
1701
|
+
radiusOn = on; remember('radiusOn', on ? '1' : '0');
|
|
1702
|
+
setSwitch($('#radBtn'), on);
|
|
1703
|
+
}
|
|
1704
|
+
$('#radBtn').onclick = () => { setRadiusOn(!radiusOn); autoPreview(); };
|
|
1705
|
+
function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value - r.min)/(r.max - r.min)); }
|
|
1706
|
+
|
|
1707
|
+
/* ===== preview / compare / save ===== */
|
|
1708
|
+
let outNat = 0, autoTimer = null;
|
|
1709
|
+
function markStale(){
|
|
1710
|
+
const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
|
|
1711
|
+
// Exactly one lit action at a time — the Figma Button component says the
|
|
1712
|
+
// accent marks the NEXT action and that only one may be on screen. With no
|
|
1713
|
+
// current render there is nothing to save, so nothing is lit: Preview stays
|
|
1714
|
+
// a neutral raised button, as it is in the design.
|
|
1715
|
+
b.classList.remove('primary');
|
|
1716
|
+
$('#imp').classList.remove('primary');
|
|
1717
|
+
}
|
|
1718
|
+
function syncOut(){
|
|
1719
|
+
const im = $('#outImg');
|
|
1720
|
+
if (!im.hidden && outNat){
|
|
1721
|
+
im.style.width = Math.round(img.naturalWidth * scale) + 'px';
|
|
1722
|
+
const a = $('#scroller'), b = $('#outWrap');
|
|
1723
|
+
b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
$('#scroller').addEventListener('scroll', () => {
|
|
1727
|
+
if ($('#stage').classList.contains('compare')) syncOut();
|
|
1728
|
+
});
|
|
1729
|
+
/* Compare is a switch now, not a chip. Its truth lives in aria-checked so the
|
|
1730
|
+
control is a real switch to assistive tech, and the label text follows it —
|
|
1731
|
+
a switch that says OFF while being on is worse than no label. */
|
|
1732
|
+
function setCompareUI(on){
|
|
1733
|
+
setSwitch($('#cmpBtn'), on);
|
|
1734
|
+
// No text label in the component (17:61) — aria-checked carries the state.
|
|
1735
|
+
// With compare on the result pane renders continuously, so a Preview button
|
|
1736
|
+
// would offer something already on screen. It belongs to the off state.
|
|
1737
|
+
$('#preview').hidden = on;
|
|
1738
|
+
}
|
|
1739
|
+
$('#cmpBtn').onclick = () => {
|
|
1740
|
+
const on = $('#stage').classList.toggle('compare');
|
|
1741
|
+
setCompareUI(on);
|
|
1742
|
+
remember('compare', on ? '1' : '0');
|
|
1743
|
+
if (img.naturalWidth){
|
|
1744
|
+
const wasFit = Math.abs(scale-fitScale)<1e-6;
|
|
1745
|
+
computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
|
|
1746
|
+
}
|
|
1747
|
+
if (on && $('#outImg').hidden) renderPreview(); else if (on) syncOut();
|
|
1748
|
+
};
|
|
1749
|
+
function autoPreview(){
|
|
1750
|
+
// Judge while you fit: with the compare pane open the composite re-renders
|
|
1751
|
+
// after each correction (compose() measures 0.06s server-side), so Preview
|
|
1752
|
+
// stops being a round trip.
|
|
1753
|
+
if (!$('#stage').classList.contains('compare')) return;
|
|
1754
|
+
clearTimeout(autoTimer);
|
|
1755
|
+
autoTimer = setTimeout(renderPreview, 350);
|
|
1756
|
+
}
|
|
1757
|
+
async function renderPreview(){
|
|
1758
|
+
if (!(st.photo && st.shot && st.corners)) return;
|
|
1759
|
+
const s = $('#outSt'); s.textContent = 'Rendering…';
|
|
1760
|
+
try{
|
|
1761
|
+
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
|
|
1762
|
+
const im = $('#outImg');
|
|
1763
|
+
im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
|
|
1764
|
+
im.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1765
|
+
s.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1766
|
+
const b = $('#save'); b.disabled = false; b.textContent = 'Save';
|
|
1767
|
+
b.classList.add('primary'); // Save is now the next action
|
|
1768
|
+
if (!previewHinted){ previewHinted = true;
|
|
1769
|
+
toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
|
|
1770
|
+
}
|
|
1771
|
+
}catch(e){ s.textContent = e.message; }
|
|
1772
|
+
}
|
|
1773
|
+
/* With compare off there is no result pane to draw into, so the composite
|
|
1774
|
+
opens in a popup over the canvas — the same surface the pickers use, so it
|
|
1775
|
+
is one modal idiom rather than two. */
|
|
1776
|
+
async function openPreviewPop(){
|
|
1777
|
+
const pop = $('#previewPop');
|
|
1778
|
+
const img = $('#previewImg'), st_ = $('#previewSt');
|
|
1779
|
+
openPopAt(pop, $('#preview').getBoundingClientRect());
|
|
1780
|
+
st_.textContent = 'Rendering…'; img.hidden = true;
|
|
1781
|
+
try{
|
|
1782
|
+
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
|
|
1783
|
+
img.onload = () => { img.hidden = false; };
|
|
1784
|
+
img.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1785
|
+
st_.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1786
|
+
const b = $('#save'); b.disabled = false; b.textContent = 'Save'; b.classList.add('primary');
|
|
1787
|
+
}catch(e){ st_.textContent = e.message; }
|
|
1788
|
+
}
|
|
1789
|
+
$('#preview').onclick = openPreviewPop;
|
|
1790
|
+
function prettyDir(p){
|
|
1791
|
+
// ~ for home, and show the last two segments of a long path: the interesting
|
|
1792
|
+
// part of a long output path is the end, not the volume.
|
|
1793
|
+
const home = st.home || '';
|
|
1794
|
+
let d = (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
|
|
1795
|
+
const seg = d.split('/');
|
|
1796
|
+
return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
$('#save').onclick = async () => {
|
|
1800
|
+
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
1801
|
+
try{
|
|
1802
|
+
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
|
|
1803
|
+
b.textContent = 'Save';
|
|
1804
|
+
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
1805
|
+
// land in the project folder now, and telling the user "~/Desktop" when
|
|
1806
|
+
// they aren't there is how you lose a file.
|
|
1807
|
+
toast('ok', `Saved <code>${r.output.split('/').pop()}</code> to <code>${prettyDir(st.outDir || '')}</code>`);
|
|
1808
|
+
const imp = $('#imp');
|
|
1809
|
+
imp.disabled = false; imp.textContent = 'Send to Claude';
|
|
1810
|
+
// Hand the accent on: the file exists, so sending it is what is next.
|
|
1811
|
+
imp.classList.add('primary'); b.classList.remove('primary');
|
|
1812
|
+
}catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent='Save'; }
|
|
1813
|
+
finally { b.disabled = false; }
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
/* Send to Claude. The page cannot draw in the chat panel — only the agent
|
|
1817
|
+
can, with present_files — so this enqueues a request the agent's blocking
|
|
1818
|
+
wait picks up. It works at all only because --out-dir puts the file inside
|
|
1819
|
+
the project folder: present_files refuses paths outside a connected folder,
|
|
1820
|
+
which is why this button was impossible while saves went to the Desktop. */
|
|
1821
|
+
$('#imp').onclick = async () => {
|
|
1822
|
+
const b = $('#imp'); const t0 = Date.now();
|
|
1823
|
+
b.disabled = true;
|
|
1824
|
+
let finished = false;
|
|
1825
|
+
const paint = () => { if (!finished) b.textContent = `Sending… ${Math.round((Date.now()-t0)/1000)}s`; };
|
|
1826
|
+
paint();
|
|
1827
|
+
const tick = setInterval(paint, 1000);
|
|
1828
|
+
document.addEventListener('visibilitychange', paint);
|
|
1829
|
+
const done = (txt, ok) => {
|
|
1830
|
+
finished = true;
|
|
1831
|
+
clearInterval(tick);
|
|
1832
|
+
document.removeEventListener('visibilitychange', paint);
|
|
1833
|
+
b.textContent = ok ? 'Sent to Claude' : 'Send to Claude';
|
|
1834
|
+
b.disabled = ok; // nothing to re-send until the next save
|
|
1835
|
+
toast(ok ? 'ok' : 'err', txt);
|
|
1836
|
+
};
|
|
1837
|
+
try { await api('/api/import', {}); }
|
|
1838
|
+
catch (e) { return done('Could not queue the import: ' + e.message, false); }
|
|
1839
|
+
await awaitJob({
|
|
1840
|
+
cancelled: () => finished,
|
|
1841
|
+
onDone: () => done('Sent — it should be in the chat now.', true),
|
|
1842
|
+
onError: (j) => done('Claude could not show it: ' + (j.message || ''), false),
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
/* ===== boot ===== */
|
|
1847
|
+
(async () => {
|
|
1848
|
+
const s = await api('/api/state');
|
|
1849
|
+
st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
|
|
1850
|
+
st.outDir = s.out_dir; st.home = s.home;
|
|
1851
|
+
// Say up front where Save will put things. With --out-dir this is usually the
|
|
1852
|
+
// project folder, and a designer should not have to press Save to find out.
|
|
1853
|
+
toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
|
|
1854
|
+
{ ms: 9000, dismissible: true });
|
|
1855
|
+
const types = $('#types');
|
|
1856
|
+
for (const t of ['phone','tablet','laptop','desktop']){
|
|
1857
|
+
const b=document.createElement('button'); b.className='chip'; b.dataset.t=t; b.textContent=t; /* Chip is one size in Figma (5:6): h28, 12px */
|
|
1858
|
+
b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
|
|
1859
|
+
}
|
|
1860
|
+
setLoupeMode(loupeMode);
|
|
1861
|
+
setGrade(gradeOn, gradeAmt);
|
|
1862
|
+
setRadiusOn(radiusOn);
|
|
1863
|
+
sectionsBooted = true; // animate from here on, not during the first paint
|
|
1864
|
+
// Compare is the DEFAULT view: judging a fit means comparing it with the
|
|
1865
|
+
// photo, so the side-by-side is the normal state and turning it off is the
|
|
1866
|
+
// deliberate act. recall() only overrides it if the user chose otherwise.
|
|
1867
|
+
if (recall('compare','1') === '1'){ $('#stage').classList.add('compare'); setCompareUI(true); }
|
|
1868
|
+
else setCompareUI(false);
|
|
1869
|
+
await loadRecent();
|
|
1870
|
+
// A session restored after a reload may already have a saved composite, in
|
|
1871
|
+
// which case Import is still meaningful — leaving it disabled would strand
|
|
1872
|
+
// a file the designer had already produced.
|
|
1873
|
+
if (s.output){ const imp = $('#imp'); imp.disabled = false; }
|
|
1874
|
+
// The server keeps the session; the page used to start blank on reload,
|
|
1875
|
+
// silently discarding a photo, a screenshot and any corner work.
|
|
1876
|
+
if (s.photo || s.screenshot){
|
|
1877
|
+
if (s.corners) st.corners = s.corners;
|
|
1878
|
+
if (s.radius_frac) st.frac = s.radius_frac;
|
|
1879
|
+
if (s.device) st.type = s.device;
|
|
1880
|
+
for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
|
|
1881
|
+
if (!path) continue;
|
|
1882
|
+
try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null); }
|
|
1883
|
+
catch(e){ /* file moved since: leave that slot empty */ }
|
|
1884
|
+
}
|
|
1885
|
+
// Populate the radius dropdown on the restore path too. detect() only
|
|
1886
|
+
// calls setType when st.type is unset, so a restored session used to come
|
|
1887
|
+
// back with an empty preset list and a zeroed slider.
|
|
1888
|
+
if (st.type) setType(st.type);
|
|
1889
|
+
if (s.radius_frac) setFrac(s.radius_frac, 'restored from this session');
|
|
1890
|
+
else applyPreset();
|
|
1891
|
+
if ($('#stage').classList.contains('compare') && st.corners) renderPreview();
|
|
1892
|
+
}
|
|
1893
|
+
if (!st.photo) openPop(1);
|
|
1894
|
+
window.addEventListener('resize', () => {
|
|
1895
|
+
if (!img.naturalWidth) return;
|
|
1896
|
+
const wasFit = Math.abs(scale - fitScale) < 1e-6;
|
|
1897
|
+
computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
|
|
1898
|
+
});
|
|
1899
|
+
})();
|
|
1900
|
+
</script>
|
|
1901
|
+
</body>
|
|
1902
|
+
</html>
|