claude-design-mode 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/bin/cli.mjs +148 -0
- package/package.json +74 -0
- package/skills/design-mode/SKILL.md +142 -0
- package/src/overlay.d.ts +6 -0
- package/src/overlay.js +2673 -0
- package/src/serve.mjs +70 -0
- package/src/server.d.ts +45 -0
- package/src/server.js +177 -0
- package/src/stamp.js +32 -0
- package/src/vite.d.ts +38 -0
- package/src/vite.js +95 -0
- package/src/watch.mjs +54 -0
package/src/overlay.js
ADDED
|
@@ -0,0 +1,2673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Mode overlay.
|
|
3
|
+
*
|
|
4
|
+
* Injected into the page either by @design-mode/vite-plugin (served same-origin,
|
|
5
|
+
* config in window.__CDM_CONFIG) or manually by a Claude Code session via
|
|
6
|
+
* javascript_tool (Tier 1: set window.__CDM_CONFIG first, then eval this file).
|
|
7
|
+
*
|
|
8
|
+
* Two ways to ask for a change:
|
|
9
|
+
* 1. Ask Claude: click an element, expand the "Ask Claude" section at the
|
|
10
|
+
* top of the sidebar, type an instruction, Send. Payload kind "selection".
|
|
11
|
+
* 2. Design sidebar: click an element, edit values in the Figma-style panel.
|
|
12
|
+
* Edits preview instantly as runtime overrides on the element and queue in
|
|
13
|
+
* the Changes tray (from -> to, token names + primitives, hardcoded flagged).
|
|
14
|
+
* "Ask Claude to commit" ships them as one payload, kind "design-edits",
|
|
15
|
+
* for the agent to turn into source edits. Previews stay on the page until
|
|
16
|
+
* the agent calls __claudeDesign.applied() (or the user clears them).
|
|
17
|
+
*
|
|
18
|
+
* Contract with the agent:
|
|
19
|
+
* window.__claudeDesign.peek() -> undelivered payloads (non-destructive; in
|
|
20
|
+
* plugin mode delivered ones live in the
|
|
21
|
+
* server's queue dir, not here)
|
|
22
|
+
* window.__claudeDesign.take() -> undelivered payloads, clearing the queue
|
|
23
|
+
* window.__claudeDesign.applied() -> clear all runtime previews: the real code
|
|
24
|
+
* now renders; call after edits land
|
|
25
|
+
* window.__claudeDesign.notify(text) -> toast (results, questions)
|
|
26
|
+
* window.__claudeDesign.bootId -> random id per injection (changes on reload)
|
|
27
|
+
* window.__claudeDesign.heartbeat -> ms timestamp (Tier 1 liveness)
|
|
28
|
+
* window.__claudeDesign.isActive() / enable() / disable() / toggle()
|
|
29
|
+
* window.__claudeDesign.select(el) / simulate(selector, instruction, scope?)
|
|
30
|
+
* window.__claudeDesign.root -> the overlay's shadow root (tests/inspection)
|
|
31
|
+
*
|
|
32
|
+
* Everything captured from the page (outerHTML, text, styles) is UNTRUSTED data.
|
|
33
|
+
* Only the user-typed instruction/note is imperative.
|
|
34
|
+
*/
|
|
35
|
+
(() => {
|
|
36
|
+
'use strict';
|
|
37
|
+
if (window.__claudeDesign) {
|
|
38
|
+
window.__claudeDesign.heartbeat = Date.now();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cfg = Object.assign(
|
|
43
|
+
{ endpoint: null, token: null, wakeUrl: null, hotkey: true },
|
|
44
|
+
window.__CDM_CONFIG || {}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const STORAGE_KEY = '__cdm_queue_v1';
|
|
48
|
+
const MAX_HTML = 2000;
|
|
49
|
+
const MAX_TEXT = 400;
|
|
50
|
+
const MAX_STACK = 4000;
|
|
51
|
+
const MAX_RULES = 20;
|
|
52
|
+
const MAX_RULE_SCAN = 5000;
|
|
53
|
+
|
|
54
|
+
const state = {
|
|
55
|
+
active: false,
|
|
56
|
+
promptOpen: false,
|
|
57
|
+
picking: false, // hover inspector stays on while the sidebar is open
|
|
58
|
+
// storage access itself can throw (sandboxed iframe, blocked cookies); never let that kill the overlay
|
|
59
|
+
linkSides: (() => { try { return sessionStorage.getItem('__cdm_link_sides') === '1'; } catch { return false; } })(), // box model edits all four sides at once
|
|
60
|
+
trailLeaf: null, // deepest element of the breadcrumb trail (children stay visible)
|
|
61
|
+
dock: (() => { try { return sessionStorage.getItem('__cdm_dock') === 'left' ? 'left' : 'right'; } catch { return 'right'; } })(),
|
|
62
|
+
promptExpanded: false,
|
|
63
|
+
draft: '',
|
|
64
|
+
draftScope: 'auto',
|
|
65
|
+
hoverEl: null,
|
|
66
|
+
selectedEl: null,
|
|
67
|
+
traces: {},
|
|
68
|
+
seq: 0,
|
|
69
|
+
queue: [],
|
|
70
|
+
pending: new Map(), // Element -> Map<prop, change> (previewed, not yet sent)
|
|
71
|
+
committed: [], // [{ el, props: [...] }] (sent, previews still on)
|
|
72
|
+
collapsed: new Set(), // section titles the user collapsed
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// the sessionStorage getter itself throws when storage is blocked; keep it inside the try
|
|
77
|
+
const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null');
|
|
78
|
+
if (saved && Array.isArray(saved.queue)) {
|
|
79
|
+
// delivered entries belong to the server queue; a fresh boot has a fresh
|
|
80
|
+
// token, so earlier 403 failures are retryable again
|
|
81
|
+
state.queue = saved.queue.filter((p) => !p.delivered).map((p) => ({ ...p, failed403: false, attempts: 0 }));
|
|
82
|
+
state.seq = saved.seq || state.queue.length;
|
|
83
|
+
}
|
|
84
|
+
} catch { /* corrupt storage: start fresh */ }
|
|
85
|
+
|
|
86
|
+
const persist = () => {
|
|
87
|
+
try {
|
|
88
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ seq: state.seq, queue: state.queue }));
|
|
89
|
+
} catch { /* storage full: queue lives in memory only */ }
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/* ----------------------------------------------------------- UI shell --- */
|
|
93
|
+
|
|
94
|
+
const Z = 2147483000;
|
|
95
|
+
const host = document.createElement('div');
|
|
96
|
+
host.setAttribute('data-cdm-ui', '');
|
|
97
|
+
host.style.cssText = `position:fixed;inset:0;z-index:${Z};pointer-events:none;`;
|
|
98
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
99
|
+
|
|
100
|
+
const style = document.createElement('style');
|
|
101
|
+
style.textContent = `
|
|
102
|
+
:host { all: initial; }
|
|
103
|
+
* { box-sizing: border-box; }
|
|
104
|
+
.ui { font: 11.5px/1.45 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif; color: #E8E8E8; -webkit-font-smoothing: antialiased; }
|
|
105
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
106
|
+
button { font: inherit; white-space: nowrap; cursor: pointer; }
|
|
107
|
+
.hi { position: fixed; display: none; pointer-events: none; border: 1.5px solid #0C8CE9; background: rgba(12,140,233,0.08); border-radius: 2px; }
|
|
108
|
+
.hi-label { position: fixed; display: none; pointer-events: none; background: #0C8CE9; color: #fff; font-size: 10.5px; line-height: 1; padding: 4px 7px; border-radius: 3px; white-space: nowrap; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; }
|
|
109
|
+
.ring { position: fixed; display: none; pointer-events: none; border: 1.5px solid #0C8CE9; box-shadow: 0 0 0 3px rgba(12,140,233,0.18); border-radius: 2px; }
|
|
110
|
+
.ta { display: block; width: 100%; background: #2B2B2B; color: #E8E8E8; border: 1px solid transparent; border-radius: 6px; padding: 7px 9px; font: inherit; resize: vertical; outline: none; min-height: 54px; }
|
|
111
|
+
.ta:focus { border-color: #0C8CE9; }
|
|
112
|
+
.sec-h .kbd { color: #9B9B9B; font-weight: 400; font-size: 10px; margin-left: auto; margin-right: 8px; }
|
|
113
|
+
.scopes { display: flex; gap: 5px; margin-top: 8px; flex-wrap: wrap; }
|
|
114
|
+
.scope { font-size: 10.5px; padding: 3px 9px; border-radius: 99px; border: 1px solid #3A3A3A; background: transparent; color: #E8E8E8; }
|
|
115
|
+
.scope.on { border-color: #0C8CE9; background: rgba(12,140,233,0.18); }
|
|
116
|
+
.card-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 8px; min-width: 0; }
|
|
117
|
+
.hint { color: #9B9B9B; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
118
|
+
.btn { height: 28px; padding: 0 12px; border-radius: 6px; border: 1px solid #3A3A3A; background: #2B2B2B; color: #E8E8E8; flex: none; }
|
|
119
|
+
.btn:hover { border-color: #4A4A4A; }
|
|
120
|
+
.btn.primary { background: #0C8CE9; border-color: #0C8CE9; color: #fff; }
|
|
121
|
+
.btn.primary:hover { background: #1D97F0; }
|
|
122
|
+
.btn.ghost { background: transparent; }
|
|
123
|
+
.btn.sm { height: 22px; padding: 0 8px; font-size: 10.5px; }
|
|
124
|
+
.btn.full { width: 100%; margin-top: 8px; }
|
|
125
|
+
.panel { position: fixed; top: 0; bottom: 0; right: 0; width: 300px; display: flex; flex-direction: column; pointer-events: none; visibility: hidden; transform: translateX(100%); transition: transform .22s ease, visibility 0s linear .22s; background: #1E1E1E; border-left: 1px solid #333; box-shadow: -8px 0 32px rgba(0,0,0,0.35); overflow: hidden; }
|
|
126
|
+
.panel.open { pointer-events: auto; visibility: visible; transform: none; transition: transform .22s ease, visibility 0s; }
|
|
127
|
+
.panel.left { right: auto; left: 0; border-left: none; border-right: 1px solid #333; box-shadow: 8px 0 32px rgba(0,0,0,0.35); transform: translateX(-100%); }
|
|
128
|
+
.panel.left.open { transform: none; }
|
|
129
|
+
.panel.dragging { transition: none; opacity: 0.92; }
|
|
130
|
+
.p-title { cursor: grab; }
|
|
131
|
+
.panel.dragging .p-title { cursor: grabbing; }
|
|
132
|
+
.panel-head { flex: none; padding: 10px 12px 0; user-select: none; }
|
|
133
|
+
.panel-scroll { flex: 1; overflow: auto; overscroll-behavior: contain; padding: 6px 12px 10px; }
|
|
134
|
+
.tray.empty { color: #6E6E6E; font-size: 10.5px; padding: 7px 12px; }
|
|
135
|
+
.count-btn { background: none; border: 0; padding: 0; color: inherit; font: inherit; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
136
|
+
.count-btn:hover { color: #8FB2FF; }
|
|
137
|
+
.pill .badge { color: #8FB2FF; font-weight: 600; }
|
|
138
|
+
.pill .badge:hover { text-decoration: underline; cursor: pointer; }
|
|
139
|
+
.p-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
140
|
+
.p-title .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
141
|
+
.p-title .tag { color: #9B9B9B; font-weight: 400; font-size: 11px; }
|
|
142
|
+
.p-sub { color: #9B9B9B; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
143
|
+
.p-src { color: #9B9B9B; font-size: 10.5px; margin-top: 6px; word-break: break-all; background: none; border: 0; padding: 0; text-align: left; cursor: pointer; white-space: normal; }
|
|
144
|
+
.p-src:hover { color: #E8E8E8; }
|
|
145
|
+
.p-classes { color: #9B9B9B; font-size: 10.5px; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
.flag { color: #E8963C; font-size: 10.5px; margin-top: 5px; display: flex; align-items: center; gap: 4px; }
|
|
147
|
+
.sec { border-top: 1px solid #2C2C2C; margin-top: 10px; padding-top: 8px; }
|
|
148
|
+
.sec-h { display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 11.5px; margin-bottom: 6px; cursor: pointer; user-select: none; }
|
|
149
|
+
.sec-h .chev { color: #9B9B9B; font-size: 10px; transition: transform .12s; }
|
|
150
|
+
.sec.closed .chev { transform: rotate(-90deg); }
|
|
151
|
+
.sec.closed .sec-body { display: none; }
|
|
152
|
+
.row { display: grid; grid-template-columns: 62px 1fr; gap: 6px; align-items: center; padding: 2.5px 0; min-width: 0; }
|
|
153
|
+
.lbl { color: #9B9B9B; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 5px; user-select: none; }
|
|
154
|
+
.lbl.mod { color: #8FB2FF; }
|
|
155
|
+
.seg { display: flex; gap: 2px; background: #2B2B2B; border-radius: 6px; padding: 2px; min-width: 0; }
|
|
156
|
+
.seg button { flex: 1; min-width: 0; height: 22px; border: none; border-radius: 4px; background: transparent; color: #9B9B9B; display: flex; align-items: center; justify-content: center; padding: 0; }
|
|
157
|
+
.seg button:hover { color: #E8E8E8; }
|
|
158
|
+
.seg button.on { background: #3F3F3F; color: #E8E8E8; }
|
|
159
|
+
.seg button.txt { flex: 0 0 auto; width: auto; padding: 0 7px; font-size: 10.5px; }
|
|
160
|
+
.seg svg { width: 14px; height: 14px; display: block; }
|
|
161
|
+
.bm { position: relative; background: #303030; border: 1px solid #3E3E3E; border-radius: 8px; padding: 24px 40px; margin-top: 4px; }
|
|
162
|
+
.bp { position: relative; background: #242424; border: 1px solid #383838; border-radius: 6px; padding: 24px 40px; }
|
|
163
|
+
.bc { height: 22px; display: flex; align-items: center; justify-content: center; color: #8A8A8A; font-size: 10px; white-space: nowrap; background: #181818; border: 1px solid #303030; border-radius: 4px; }
|
|
164
|
+
/* corner-to-corner guides between the rings, drawn with gradients so they cost no layout */
|
|
165
|
+
.diag { position: absolute; width: 40px; height: 24px; pointer-events: none; --dg: #454545; }
|
|
166
|
+
.bp > .diag { --dg: #363636; }
|
|
167
|
+
.diag.tl { top: 0; left: 0; background: linear-gradient(to bottom left, transparent calc(50% - .5px), var(--dg) calc(50% - .5px), var(--dg) calc(50% + .5px), transparent calc(50% + .5px)); }
|
|
168
|
+
.diag.tr { top: 0; right: 0; background: linear-gradient(to bottom right, transparent calc(50% - .5px), var(--dg) calc(50% - .5px), var(--dg) calc(50% + .5px), transparent calc(50% + .5px)); }
|
|
169
|
+
.diag.bl { bottom: 0; left: 0; background: linear-gradient(to bottom right, transparent calc(50% - .5px), var(--dg) calc(50% - .5px), var(--dg) calc(50% + .5px), transparent calc(50% + .5px)); }
|
|
170
|
+
.diag.br { bottom: 0; right: 0; background: linear-gradient(to bottom left, transparent calc(50% - .5px), var(--dg) calc(50% - .5px), var(--dg) calc(50% + .5px), transparent calc(50% + .5px)); }
|
|
171
|
+
.bm-l { position: absolute; top: 5px; left: 8px; font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; color: #8A8A8A; cursor: default; user-select: none; z-index: 1; }
|
|
172
|
+
.bm-l { display: inline-flex; align-items: center; gap: 4px; }
|
|
173
|
+
.bm-l.mod { color: #8FB2FF; }
|
|
174
|
+
.bm-l .dot { width: 5px; height: 5px; }
|
|
175
|
+
.bx { position: absolute; width: 34px; height: 18px; padding: 0; border: none; border-radius: 3px; background: transparent; color: #E8E8E8; font: inherit; font-size: 11px; text-align: center; outline: none; z-index: 1; }
|
|
176
|
+
.bx:hover { background: rgba(255,255,255,0.08); }
|
|
177
|
+
.bx:focus { background: rgba(255,255,255,0.08); box-shadow: inset 0 0 0 1px #0C8CE9; }
|
|
178
|
+
.bx.mod { color: #8FB2FF; }
|
|
179
|
+
.bx, .ctl.num, .ctl.scale { cursor: ew-resize; }
|
|
180
|
+
.bx:focus, .ctl.num:focus, .ctl.scale:focus { cursor: text; }
|
|
181
|
+
.scrubbing, .scrubbing * { user-select: none !important; cursor: ew-resize !important; }
|
|
182
|
+
.bx.t { top: 3px; left: 50%; transform: translateX(-50%); }
|
|
183
|
+
.bx.b { bottom: 3px; left: 50%; transform: translateX(-50%); }
|
|
184
|
+
.bx.l { left: 3px; top: 50%; transform: translateY(-50%); }
|
|
185
|
+
.bx.r { right: 3px; top: 50%; transform: translateY(-50%); }
|
|
186
|
+
.sec-h .acts { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; margin-right: 8px; }
|
|
187
|
+
.sec-h .sbtn { width: 20px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 4px; background: transparent; color: #9B9B9B; padding: 0; }
|
|
188
|
+
.sec-h .sbtn:hover { background: #2F2F2F; color: #E8E8E8; }
|
|
189
|
+
.sec-h .sbtn.on { color: #0C8CE9; background: rgba(12,140,233,0.15); }
|
|
190
|
+
.sec-h .sbtn svg { width: 14px; height: 14px; display: block; }
|
|
191
|
+
.ctl { height: 26px; width: 100%; min-width: 0; background: #2B2B2B; border: 1px solid transparent; border-radius: 6px; color: #E8E8E8; font: inherit; padding: 0 8px; outline: none; }
|
|
192
|
+
.ctl:hover { border-color: #3A3A3A; }
|
|
193
|
+
.ctl:focus { border-color: #0C8CE9; }
|
|
194
|
+
.ctl.bad { border-color: #E8963C; }
|
|
195
|
+
.ctl[disabled] { color: #9B9B9B; }
|
|
196
|
+
.ctl.sel { display: flex; align-items: center; justify-content: space-between; gap: 6px; text-align: left; cursor: pointer; }
|
|
197
|
+
.ctl.sel .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
198
|
+
.ctl.sel .chev { color: #9B9B9B; font-size: 8px; flex: none; }
|
|
199
|
+
.dd { position: absolute; z-index: 4; background: #2B2B2B; border: 1px solid #3A3A3A; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); padding: 4px; overflow: auto; min-width: 160px; }
|
|
200
|
+
.dd .it { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: none; background: none; color: #E8E8E8; border-radius: 5px; text-align: left; min-width: 0; cursor: pointer; }
|
|
201
|
+
.dd .it:hover, .dd .it.hl { background: #3A3A3A; }
|
|
202
|
+
.dd .it.cur .lab { color: #0C8CE9; }
|
|
203
|
+
.dd .it .sw { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid #3A3A3A; }
|
|
204
|
+
.dd .it .lab { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
.dd .it .pv { color: #9B9B9B; font-size: 10px; flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
206
|
+
.dd .empty { color: #9B9B9B; padding: 6px 8px; font-size: 10.5px; }
|
|
207
|
+
input[type=number].ctl { -moz-appearance: textfield; }
|
|
208
|
+
input[type=number].ctl::-webkit-inner-spin-button { opacity: 0.6; }
|
|
209
|
+
.unit { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px; min-width: 0; }
|
|
210
|
+
.unit .u { color: #9B9B9B; font-size: 10px; white-space: nowrap; }
|
|
211
|
+
.swatched { display: grid; grid-template-columns: auto 1fr; gap: 6px; align-items: center; min-width: 0; }
|
|
212
|
+
.sw { width: 16px; height: 16px; border-radius: 4px; border: 1px solid #3A3A3A; }
|
|
213
|
+
.prim { color: #9B9B9B; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; grid-column: 2; margin-top: -1px; }
|
|
214
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; background: #8FB2FF; display: inline-block; flex: none; }
|
|
215
|
+
.hc { display: inline-flex; width: 9px; height: 9px; color: #E8963C; flex: none; vertical-align: -1px; }
|
|
216
|
+
.hc svg { width: 100%; height: 100%; display: block; }
|
|
217
|
+
.tray { border-top: 1px solid #333; background: #232323; padding: 10px 12px; }
|
|
218
|
+
.tray-h { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; min-width: 0; }
|
|
219
|
+
.tray-h .count { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
220
|
+
.tray-actions { display: flex; gap: 6px; flex: none; }
|
|
221
|
+
.modal-bg { position: fixed; inset: 0; z-index: 3; pointer-events: auto; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
|
|
222
|
+
.modal { width: 460px; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); display: flex; flex-direction: column; background: #1E1E1E; border: 1px solid #333; border-radius: 10px; box-shadow: 0 24px 64px rgba(0,0,0,0.6); overflow: hidden; }
|
|
223
|
+
.modal-h { padding: 12px 14px 10px; font-weight: 600; font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
224
|
+
.modal-h .muted, .chg .muted { color: #9B9B9B; font-weight: 400; font-size: 11px; }
|
|
225
|
+
.modal-list { overflow: auto; padding: 0 14px; max-height: 42vh; }
|
|
226
|
+
.modal-list .chg { padding: 5px 0; }
|
|
227
|
+
.modal-body { padding: 10px 14px 14px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid #2C2C2C; }
|
|
228
|
+
.modal-foot { display: flex; align-items: center; gap: 8px; }
|
|
229
|
+
.modal-foot .ctl.sel { width: auto; flex: 1; min-width: 0; }
|
|
230
|
+
.modal { position: relative; }
|
|
231
|
+
.modal.confirm { width: 360px; }
|
|
232
|
+
.modal.confirm .modal-h { padding: 14px 14px 10px; font-size: 12.5px; }
|
|
233
|
+
.modal.confirm .modal-foot { padding: 0 14px 12px; justify-content: flex-end; }
|
|
234
|
+
.modal-empty { color: #9B9B9B; padding: 8px 0 12px; }
|
|
235
|
+
.tray-h .muted { color: #9B9B9B; font-weight: 400; font-size: 10.5px; }
|
|
236
|
+
.chg { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; padding: 3px 0; border-bottom: 1px solid #2C2C2C; min-width: 0; }
|
|
237
|
+
.chg:last-of-type { border-bottom: none; }
|
|
238
|
+
.chg .what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
239
|
+
.chg .what b { font-weight: 600; }
|
|
240
|
+
.chg .who { color: #9B9B9B; font-size: 10px; }
|
|
241
|
+
.chg .arrow { color: #9B9B9B; margin: 0 4px; }
|
|
242
|
+
.x { background: none; border: none; color: #9B9B9B; padding: 0 6px; min-width: 22px; height: 22px; font-size: 12px; border-radius: 4px; }
|
|
243
|
+
.x:hover { background: #3A3A3A; color: #E8E8E8; }
|
|
244
|
+
.x:hover { color: #E8E8E8; }
|
|
245
|
+
.tray-note { width: 100%; margin-top: 8px; }
|
|
246
|
+
.tray-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 8px; }
|
|
247
|
+
.tray-foot select.ctl { width: auto; height: 26px; flex: 1; min-width: 0; }
|
|
248
|
+
.tray-status { color: #9B9B9B; font-size: 10.5px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
249
|
+
.tray-status button { background: none; border: none; color: #8FB2FF; padding: 0; font-size: 10.5px; }
|
|
250
|
+
.toast { position: fixed; display: none; pointer-events: none; bottom: 14px; left: 14px; background: #1E1E1E; border: 1px solid #333; border-radius: 8px; padding: 8px 12px; max-width: 46vw; }
|
|
251
|
+
.pill { position: fixed; display: none; pointer-events: auto; top: 12px; right: 12px; align-items: center; gap: 10px; background: #1E1E1E; border: 1px solid #333; border-radius: 99px; padding: 5px 6px 5px 12px; box-shadow: 0 6px 24px rgba(0,0,0,0.35); font-weight: 600; }
|
|
252
|
+
.pill .muted { color: #9B9B9B; font-weight: 400; }
|
|
253
|
+
.hdr-btns { margin-left: auto; display: flex; align-items: center; gap: 4px; flex: none; }
|
|
254
|
+
.kbtn { flex: none; height: 22px; display: inline-flex; align-items: center; font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.02em; padding: 0 6px; border-radius: 4px; border: 1px solid #3A3A3A; border-bottom-width: 2px; background: #2B2B2B; color: #9B9B9B; }
|
|
255
|
+
.kbtn:hover { color: #E8E8E8; border-color: #4A4A4A; }
|
|
256
|
+
.kbtn svg { width: 12px; height: 12px; display: block; }
|
|
257
|
+
.kbtn.on { color: #0C8CE9; border-color: #0C8CE9; background: rgba(12,140,233,0.15); }
|
|
258
|
+
.crumbs { display: none; align-items: center; gap: 1px; border-top: 1px solid #333; background: #232323; padding: 7px 10px; white-space: nowrap; overflow-x: auto; overflow-y: hidden; font-size: 11px; scrollbar-width: none; flex: none; }
|
|
259
|
+
.crumbs::-webkit-scrollbar { display: none; }
|
|
260
|
+
.crumbs { position: relative; cursor: grab; }
|
|
261
|
+
.crumbs.dragging { cursor: grabbing; user-select: none; }
|
|
262
|
+
.crumbs.dragging button { pointer-events: none; }
|
|
263
|
+
.crumbs button.kids { color: #9B9B9B; padding: 3px 7px; min-width: 22px; }
|
|
264
|
+
.crumbs button.kids:hover, .crumbs button.kids.on { color: #E8E8E8; background: #2B2B2B; }
|
|
265
|
+
.menu { position: absolute; right: 8px; z-index: 2; background: #2B2B2B; border: 1px solid #3A3A3A; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); padding: 4px; min-width: 170px; max-width: 250px; max-height: 45%; overflow: auto; }
|
|
266
|
+
.menu .mh { color: #9B9B9B; font-size: 10px; padding: 3px 8px 5px; }
|
|
267
|
+
.menu button { display: flex; width: 100%; justify-content: space-between; align-items: center; gap: 8px; padding: 5px 8px; border: none; background: none; color: #E8E8E8; border-radius: 5px; text-align: left; min-width: 0; }
|
|
268
|
+
.menu button:hover { background: #3A3A3A; }
|
|
269
|
+
.menu .ml { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
270
|
+
.menu .mc { color: #9B9B9B; font-size: 10px; flex: none; }
|
|
271
|
+
.menu .more { color: #9B9B9B; font-size: 10px; padding: 4px 8px; }
|
|
272
|
+
.crumbs .sep, .crumbs .dots { color: #9B9B9B; padding: 0 2px; flex: none; }
|
|
273
|
+
.crumbs button { background: none; border: none; padding: 2px 5px; border-radius: 4px; color: #E8E8E8; flex: none; }
|
|
274
|
+
.crumbs button.cur { color: #0C8CE9; font-weight: 600; }
|
|
275
|
+
.crumbs button:hover { background: #2B2B2B; }
|
|
276
|
+
`;
|
|
277
|
+
shadow.append(style);
|
|
278
|
+
|
|
279
|
+
const mk = (cls, tag = 'div') => { const n = document.createElement(tag); n.className = cls; return n; };
|
|
280
|
+
const hi = mk('hi');
|
|
281
|
+
const hiLabel = mk('hi-label ui');
|
|
282
|
+
const ring = mk('ring');
|
|
283
|
+
const panel = mk('panel ui');
|
|
284
|
+
const panelHead = mk('panel-head');
|
|
285
|
+
const panelScroll = mk('panel-scroll');
|
|
286
|
+
const tray = mk('tray');
|
|
287
|
+
const crumbs = mk('crumbs ui');
|
|
288
|
+
panel.append(panelHead, panelScroll, crumbs, tray);
|
|
289
|
+
const toast = mk('toast ui');
|
|
290
|
+
const pill = mk('pill ui');
|
|
291
|
+
pill.innerHTML = '<span>Design Mode</span><span class="muted" title="Click an element to select it. Alt+click selects its parent">click an element</span>';
|
|
292
|
+
const pillBadge = mk('badge', 'button');
|
|
293
|
+
pillBadge.style.cssText = 'background:none;border:0;padding:0;font:inherit;display:none';
|
|
294
|
+
pillBadge.title = 'Unsent changes. Click to go back to them';
|
|
295
|
+
pillBadge.addEventListener('click', () => {
|
|
296
|
+
const first = [...state.pending.keys()].find((elx) => elx.isConnected && state.pending.get(elx).size);
|
|
297
|
+
if (first) { openPrompt(first); first.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
|
|
298
|
+
});
|
|
299
|
+
pill.append(pillBadge);
|
|
300
|
+
const pillEsc = mk('kbtn', 'button');
|
|
301
|
+
pillEsc.textContent = 'esc';
|
|
302
|
+
pillEsc.title = 'Exit Design Mode (Esc)';
|
|
303
|
+
pillEsc.addEventListener('click', () => requestDisable());
|
|
304
|
+
pill.append(pillEsc);
|
|
305
|
+
const syncPill = () => {
|
|
306
|
+
pill.style.display = state.active && !state.promptOpen ? 'flex' : 'none';
|
|
307
|
+
const n = pendingCount();
|
|
308
|
+
pillBadge.style.display = n ? '' : 'none';
|
|
309
|
+
pillBadge.textContent = n ? `${n} unsent` : '';
|
|
310
|
+
};
|
|
311
|
+
shadow.append(hi, hiLabel, ring, panel, toast, pill);
|
|
312
|
+
|
|
313
|
+
// Docking: the panel takes real space by pushing the page with an html margin
|
|
314
|
+
// on its side (animated together with the slide-in), so nothing hides under it.
|
|
315
|
+
const DOCK_W = 300;
|
|
316
|
+
const htmlStyle = document.documentElement.style;
|
|
317
|
+
let savedHtml = null;
|
|
318
|
+
const applyDock = () => {
|
|
319
|
+
const open = state.promptOpen;
|
|
320
|
+
if (savedHtml === null) savedHtml = { l: htmlStyle.marginLeft, r: htmlStyle.marginRight, t: htmlStyle.transition };
|
|
321
|
+
htmlStyle.transition = open ? 'margin-left .22s ease, margin-right .22s ease' : savedHtml.t;
|
|
322
|
+
htmlStyle.marginLeft = open && state.dock === 'left' ? `${DOCK_W}px` : savedHtml.l;
|
|
323
|
+
htmlStyle.marginRight = open && state.dock === 'right' ? `${DOCK_W}px` : savedHtml.r;
|
|
324
|
+
panel.classList.toggle('left', state.dock === 'left');
|
|
325
|
+
panel.classList.toggle('open', open);
|
|
326
|
+
toast.style.left = open && state.dock === 'left' ? `${DOCK_W + 14}px` : '14px';
|
|
327
|
+
// keep the ring and hover box glued while the page reflows
|
|
328
|
+
const t0 = performance.now();
|
|
329
|
+
const loop = () => { reposition(); if (performance.now() - t0 < 320) requestAnimationFrame(loop); };
|
|
330
|
+
requestAnimationFrame(loop);
|
|
331
|
+
};
|
|
332
|
+
const setDock = (side, fromRect = null) => {
|
|
333
|
+
const next = side === 'left' ? 'left' : 'right';
|
|
334
|
+
// glide across instead of teleporting: remember where the panel is, switch edges, then animate the difference away
|
|
335
|
+
const before = fromRect || (state.promptOpen && next !== state.dock ? panel.getBoundingClientRect() : null);
|
|
336
|
+
state.dock = next;
|
|
337
|
+
try { sessionStorage.setItem('__cdm_dock', state.dock); } catch { /* fine */ }
|
|
338
|
+
applyDock();
|
|
339
|
+
if (before) {
|
|
340
|
+
panel.classList.add('dragging');
|
|
341
|
+
panel.style.transform = 'none';
|
|
342
|
+
const after = panel.getBoundingClientRect();
|
|
343
|
+
const dx = before.left - after.left;
|
|
344
|
+
panel.style.transform = `translateX(${dx}px)`;
|
|
345
|
+
void panel.offsetWidth; // commit the start position
|
|
346
|
+
panel.classList.remove('dragging');
|
|
347
|
+
panel.style.transform = '';
|
|
348
|
+
}
|
|
349
|
+
if (state.dockBtn) state.dockBtn.title = `Docked ${state.dock}: click to dock ${state.dock === 'left' ? 'right' : 'left'}, or drag the header`;
|
|
350
|
+
};
|
|
351
|
+
// drag the header to snap the panel to the other edge
|
|
352
|
+
let panelDrag = null;
|
|
353
|
+
panel.addEventListener('pointerdown', (e) => {
|
|
354
|
+
if (e.button !== 0 || !e.target.closest || !e.target.closest('.p-title') || e.target.closest('button')) return;
|
|
355
|
+
panelDrag = { x: e.clientX, moved: false, id: e.pointerId };
|
|
356
|
+
e.preventDefault(); // no text selection while dragging the handle
|
|
357
|
+
try { panel.setPointerCapture(e.pointerId); } catch { /* fine */ }
|
|
358
|
+
});
|
|
359
|
+
panel.addEventListener('pointermove', (e) => {
|
|
360
|
+
if (!panelDrag || e.pointerId !== panelDrag.id) return;
|
|
361
|
+
const dx = e.clientX - panelDrag.x;
|
|
362
|
+
if (!panelDrag.moved && Math.abs(dx) > 6) { panelDrag.moved = true; panel.classList.add('dragging'); }
|
|
363
|
+
if (panelDrag.moved) panel.style.transform = `translateX(${dx}px)`;
|
|
364
|
+
});
|
|
365
|
+
const endPanelDrag = (e) => {
|
|
366
|
+
if (!panelDrag || (e && e.pointerId !== panelDrag.id)) return;
|
|
367
|
+
const { moved } = panelDrag;
|
|
368
|
+
const here = panel.getBoundingClientRect();
|
|
369
|
+
panelDrag = null;
|
|
370
|
+
panel.classList.remove('dragging');
|
|
371
|
+
panel.style.transform = '';
|
|
372
|
+
if (moved && e) setDock(e.clientX < innerWidth / 2 ? 'left' : 'right', here);
|
|
373
|
+
};
|
|
374
|
+
panel.addEventListener('pointerup', endPanelDrag);
|
|
375
|
+
panel.addEventListener('pointercancel', endPanelDrag);
|
|
376
|
+
panel.addEventListener('wheel', (e) => {
|
|
377
|
+
// let a scroller inside the panel take the wheel when it can; otherwise swallow it so the
|
|
378
|
+
// page (and the selected element) never scroll away under the sidebar
|
|
379
|
+
let n = e.target instanceof Element ? e.target : null;
|
|
380
|
+
while (n && n !== panel) {
|
|
381
|
+
const cs = getComputedStyle(n);
|
|
382
|
+
if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight) {
|
|
383
|
+
const up = e.deltaY < 0;
|
|
384
|
+
if ((up && n.scrollTop > 0) || (!up && n.scrollTop + n.clientHeight < n.scrollHeight - 1)) return;
|
|
385
|
+
}
|
|
386
|
+
n = n.parentElement;
|
|
387
|
+
}
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
}, { passive: false });
|
|
390
|
+
|
|
391
|
+
// One light-DOM rule so the page itself shows the inspect cursor while picking; our UI keeps its own.
|
|
392
|
+
const pageStyle = document.createElement('style');
|
|
393
|
+
pageStyle.setAttribute('data-cdm-style', '');
|
|
394
|
+
pageStyle.textContent = 'html[data-cdm-inspecting], html[data-cdm-inspecting] * { cursor: crosshair !important; } html[data-cdm-inspecting] [data-cdm-ui] { cursor: auto !important; }';
|
|
395
|
+
const ensureMounted = () => {
|
|
396
|
+
if (!host.isConnected) document.documentElement.append(host);
|
|
397
|
+
if (!pageStyle.isConnected) (document.head || document.documentElement).append(pageStyle);
|
|
398
|
+
};
|
|
399
|
+
ensureMounted();
|
|
400
|
+
// Inspecting = hover highlights and clicks select. True while nothing is selected, or while
|
|
401
|
+
// the pick toggle is on with the sidebar open. Otherwise the page is a normal, interactive page.
|
|
402
|
+
const inspecting = () => state.active && (!state.promptOpen || state.picking);
|
|
403
|
+
const syncCursor = () => document.documentElement.toggleAttribute('data-cdm-inspecting', inspecting());
|
|
404
|
+
|
|
405
|
+
let toastTimer = null;
|
|
406
|
+
const showToast = (text, ms = 3500) => {
|
|
407
|
+
ensureMounted();
|
|
408
|
+
toast.textContent = text;
|
|
409
|
+
toast.style.display = 'block';
|
|
410
|
+
clearTimeout(toastTimer);
|
|
411
|
+
toastTimer = setTimeout(() => { toast.style.display = 'none'; }, ms);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const box = (target, node, pad = 0) => {
|
|
415
|
+
const r = target.getBoundingClientRect();
|
|
416
|
+
node.style.display = 'block';
|
|
417
|
+
node.style.left = `${r.left - pad}px`;
|
|
418
|
+
node.style.top = `${r.top - pad}px`;
|
|
419
|
+
node.style.width = `${r.width + pad * 2}px`;
|
|
420
|
+
node.style.height = `${r.height + pad * 2}px`;
|
|
421
|
+
return r;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/* ------------------------------------------------------ introspection --- */
|
|
425
|
+
|
|
426
|
+
const isOurs = (n) => n instanceof Element && (n === host || !!n.closest('[data-cdm-ui]'));
|
|
427
|
+
|
|
428
|
+
const cssPath = (target) => {
|
|
429
|
+
const parts = [];
|
|
430
|
+
let n = target;
|
|
431
|
+
let depth = 0;
|
|
432
|
+
while (n && n !== document.body && depth < 24) {
|
|
433
|
+
if (n.id) { parts.unshift(`#${CSS.escape(n.id)}`); break; }
|
|
434
|
+
const tag = n.tagName.toLowerCase();
|
|
435
|
+
const sibs = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
|
|
436
|
+
parts.unshift(sibs.length > 1 ? `${tag}:nth-of-type(${sibs.indexOf(n) + 1})` : tag);
|
|
437
|
+
n = n.parentElement;
|
|
438
|
+
depth++;
|
|
439
|
+
}
|
|
440
|
+
return parts.join(' > ');
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const ownFiber = (n) => {
|
|
444
|
+
for (const k of Object.keys(n)) {
|
|
445
|
+
if (k.startsWith('__reactFiber$')) return n[k];
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const findFiber = (target) => {
|
|
451
|
+
let n = target;
|
|
452
|
+
while (n) {
|
|
453
|
+
const f = ownFiber(n);
|
|
454
|
+
if (f) return f;
|
|
455
|
+
n = n.parentElement;
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const fiberName = (f) => {
|
|
461
|
+
const t = f && f.type;
|
|
462
|
+
if (typeof t === 'function') return t.displayName || t.name || null;
|
|
463
|
+
if (t && typeof t === 'object') {
|
|
464
|
+
return t.displayName || (t.render && (t.render.displayName || t.render.name)) || null;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const componentChain = (fiber) => {
|
|
470
|
+
const names = [];
|
|
471
|
+
let f = fiber;
|
|
472
|
+
let hops = 0;
|
|
473
|
+
while (f && hops < 50 && names.length < 8) {
|
|
474
|
+
const n = fiberName(f);
|
|
475
|
+
if (n && names[names.length - 1] !== n) names.push(n);
|
|
476
|
+
f = f._debugOwner || f.return;
|
|
477
|
+
hops++;
|
|
478
|
+
}
|
|
479
|
+
return names;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const resolveSource = (target) => {
|
|
483
|
+
const stamped = target.closest('[data-claude-source]');
|
|
484
|
+
if (stamped) {
|
|
485
|
+
const m = (stamped.getAttribute('data-claude-source') || '').match(/^(.*):(\d+):(\d+)$/);
|
|
486
|
+
if (m) return { via: stamped === target ? 'stamp' : 'stamp-ancestor', file: m[1], line: +m[2], col: +m[3] };
|
|
487
|
+
}
|
|
488
|
+
let n = target;
|
|
489
|
+
while (n) {
|
|
490
|
+
if (n.__svelte_meta && n.__svelte_meta.loc) {
|
|
491
|
+
const l = n.__svelte_meta.loc;
|
|
492
|
+
return { via: 'svelte', file: l.file, line: l.line, col: l.column };
|
|
493
|
+
}
|
|
494
|
+
n = n.parentElement;
|
|
495
|
+
}
|
|
496
|
+
const vue = target.closest('[data-v-inspector]');
|
|
497
|
+
if (vue) {
|
|
498
|
+
const m = (vue.getAttribute('data-v-inspector') || '').match(/^(.*):(\d+):(\d+)$/);
|
|
499
|
+
if (m) return { via: 'vue', file: m[1], line: +m[2], col: +m[3] };
|
|
500
|
+
}
|
|
501
|
+
const fiber = findFiber(target);
|
|
502
|
+
if (fiber) {
|
|
503
|
+
let f = fiber;
|
|
504
|
+
let hops = 0;
|
|
505
|
+
while (f && hops < 10) {
|
|
506
|
+
if (f._debugSource) {
|
|
507
|
+
const s = f._debugSource;
|
|
508
|
+
return { via: 'debugSource', file: s.fileName, line: s.lineNumber, col: s.columnNumber || 1 };
|
|
509
|
+
}
|
|
510
|
+
f = f.return;
|
|
511
|
+
hops++;
|
|
512
|
+
}
|
|
513
|
+
const stack = fiber._debugStack && (fiber._debugStack.stack || String(fiber._debugStack));
|
|
514
|
+
if (stack) return { via: 'debugStack', stack: String(stack).slice(0, MAX_STACK) };
|
|
515
|
+
}
|
|
516
|
+
return { via: 'none' };
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const COMPUTED_PROPS = [
|
|
520
|
+
'display', 'position', 'width', 'height', 'margin', 'padding', 'gap',
|
|
521
|
+
'flexDirection', 'alignItems', 'justifyContent', 'gridTemplateColumns',
|
|
522
|
+
'fontSize', 'fontWeight', 'fontFamily', 'lineHeight', 'letterSpacing', 'textAlign',
|
|
523
|
+
'color', 'backgroundColor', 'borderRadius', 'border', 'boxShadow',
|
|
524
|
+
'opacity', 'overflow', 'zIndex', 'transform',
|
|
525
|
+
];
|
|
526
|
+
const computedSubset = (target) => {
|
|
527
|
+
const cs = getComputedStyle(target);
|
|
528
|
+
const out = {};
|
|
529
|
+
for (const p of COMPUTED_PROPS) out[p] = cs[p];
|
|
530
|
+
return out;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// Does a conditional at-rule apply right now? 'yes' / 'no' / 'maybe' (container/scope
|
|
534
|
+
// queries we cannot cheaply evaluate). Non-applying subtrees are skipped so a md: or
|
|
535
|
+
// dark: variant is never read as the element's current value on the wrong viewport.
|
|
536
|
+
const condCache = new Map();
|
|
537
|
+
const condState = (rule) => {
|
|
538
|
+
if (typeof CSSMediaRule !== 'undefined' && rule instanceof CSSMediaRule) {
|
|
539
|
+
const q = rule.conditionText || rule.media.mediaText;
|
|
540
|
+
if (!condCache.has(q)) { try { condCache.set(q, matchMedia(q).matches ? 'yes' : 'no'); } catch { condCache.set(q, 'maybe'); } }
|
|
541
|
+
return condCache.get(q);
|
|
542
|
+
}
|
|
543
|
+
if (typeof CSSSupportsRule !== 'undefined' && rule instanceof CSSSupportsRule) {
|
|
544
|
+
const q = 's:' + rule.conditionText;
|
|
545
|
+
if (!condCache.has(q)) { try { condCache.set(q, CSS.supports(rule.conditionText) ? 'yes' : 'no'); } catch { condCache.set(q, 'maybe'); } }
|
|
546
|
+
return condCache.get(q);
|
|
547
|
+
}
|
|
548
|
+
if ((typeof CSSContainerRule !== 'undefined' && rule instanceof CSSContainerRule)
|
|
549
|
+
|| (typeof CSSScopeRule !== 'undefined' && rule instanceof CSSScopeRule)
|
|
550
|
+
|| (typeof CSSStartingStyleRule !== 'undefined' && rule instanceof CSSStartingStyleRule)) return 'maybe';
|
|
551
|
+
return 'yes';
|
|
552
|
+
};
|
|
553
|
+
const walkRules = (cb) => {
|
|
554
|
+
condCache.clear();
|
|
555
|
+
for (const sheet of document.styleSheets) {
|
|
556
|
+
let rules;
|
|
557
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
558
|
+
const source = sheet.href
|
|
559
|
+
|| (sheet.ownerNode && sheet.ownerNode.getAttribute && sheet.ownerNode.getAttribute('data-vite-dev-id'))
|
|
560
|
+
|| 'inline';
|
|
561
|
+
const visit = (list, layer, uncertain) => {
|
|
562
|
+
for (const rule of list) {
|
|
563
|
+
if (cb(rule, source, layer, uncertain) === false) return false;
|
|
564
|
+
if (rule.cssRules && rule.cssRules.length) {
|
|
565
|
+
const nextLayer = (typeof CSSLayerBlockRule !== 'undefined' && rule instanceof CSSLayerBlockRule) ? rule.name : layer;
|
|
566
|
+
const c = condState(rule);
|
|
567
|
+
if (c === 'no') continue; // a non-matching @media/@supports subtree does not apply
|
|
568
|
+
if (visit(rule.cssRules, nextLayer, uncertain || c === 'maybe') === false) return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
};
|
|
573
|
+
if (visit(rules, null, false) === false) return;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const matchedRuleObjects = (target) => {
|
|
578
|
+
const out = [];
|
|
579
|
+
let scanned = 0;
|
|
580
|
+
walkRules((rule, source, layer, uncertain) => {
|
|
581
|
+
if (scanned++ > MAX_RULE_SCAN || out.length >= MAX_RULES) return false;
|
|
582
|
+
if (rule.selectorText) {
|
|
583
|
+
try {
|
|
584
|
+
if (target.matches(rule.selectorText)) {
|
|
585
|
+
// :hover/:active/:focus rules match while the pointer is still on the element,
|
|
586
|
+
// and rules under @container/@scope may or may not apply; both are listed for
|
|
587
|
+
// the agent but never read as the element's resting value
|
|
588
|
+
const transient = uncertain || /:(hover|active|focus|focus-visible|focus-within)\b/.test(rule.selectorText);
|
|
589
|
+
out.push({ rule, source, layer, transient });
|
|
590
|
+
}
|
|
591
|
+
} catch { /* unsupported selector */ }
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return out;
|
|
595
|
+
};
|
|
596
|
+
const matchedRules = (ruleObjs) => ruleObjs.map(({ rule, source }) => ({ selector: rule.selectorText, source }));
|
|
597
|
+
|
|
598
|
+
let propIndex = null;
|
|
599
|
+
let propIndexAt = 0;
|
|
600
|
+
let propIndexSheets = 0;
|
|
601
|
+
const customProps = () => {
|
|
602
|
+
if (propIndex && Date.now() - propIndexAt < 10000 && propIndexSheets === document.styleSheets.length) return propIndex;
|
|
603
|
+
const index = Object.create(null);
|
|
604
|
+
let scanned = 0;
|
|
605
|
+
walkRules((rule) => {
|
|
606
|
+
if (scanned++ > 12000) return false;
|
|
607
|
+
const s = rule.style;
|
|
608
|
+
if (!s) return;
|
|
609
|
+
for (let i = 0; i < s.length; i++) {
|
|
610
|
+
const name = s[i];
|
|
611
|
+
if (name && name.startsWith('--')) index[name] = s.getPropertyValue(name).trim();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
propIndex = index;
|
|
615
|
+
propIndexAt = Date.now();
|
|
616
|
+
propIndexSheets = document.styleSheets.length;
|
|
617
|
+
return index;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/;
|
|
621
|
+
const TRACE_PROPS = [
|
|
622
|
+
'color', 'background-color', 'border-color', 'font-family', 'font-size', 'font-weight',
|
|
623
|
+
'line-height', 'letter-spacing', 'text-align', 'padding', 'padding-inline', 'padding-block',
|
|
624
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
625
|
+
'margin', 'margin-inline', 'margin-block', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
626
|
+
'gap', 'border-radius', 'box-shadow', 'opacity',
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
/* token: var() chain · utility: framework class with a literal (rounded-full)
|
|
630
|
+
* hardcoded: inline/user literal or arbitrary-value utility · reset: preflight · keyword: inherit/none/0 etc. */
|
|
631
|
+
// Plain CSS writes shorthands (background: var(--brand), padding: var(--s2) var(--s4),
|
|
632
|
+
// border: 1px solid var(--line), font: ...). The CSSOM cannot expand a shorthand that
|
|
633
|
+
// contains var(), so longhands are traced through their shorthands too.
|
|
634
|
+
const SHORTHANDS = {
|
|
635
|
+
'background-color': ['background'],
|
|
636
|
+
'border-color': ['border', 'border-top', 'border-right', 'border-bottom', 'border-left'],
|
|
637
|
+
'font-size': ['font'], 'font-family': ['font'], 'font-weight': ['font'], 'line-height': ['font'],
|
|
638
|
+
'padding-top': ['padding-block', 'padding'], 'padding-bottom': ['padding-block', 'padding'],
|
|
639
|
+
'padding-left': ['padding-inline', 'padding'], 'padding-right': ['padding-inline', 'padding'],
|
|
640
|
+
'padding-inline': ['padding'], 'padding-block': ['padding'],
|
|
641
|
+
'margin-top': ['margin-block', 'margin'], 'margin-bottom': ['margin-block', 'margin'],
|
|
642
|
+
'margin-left': ['margin-inline', 'margin'], 'margin-right': ['margin-inline', 'margin'],
|
|
643
|
+
'margin-inline': ['margin'], 'margin-block': ['margin'],
|
|
644
|
+
};
|
|
645
|
+
const FAMILY_OF = {
|
|
646
|
+
color: 'color', 'background-color': 'color', 'border-color': 'color', 'font-family': 'fontFamily',
|
|
647
|
+
'font-size': 'fontSize', 'font-weight': 'fontWeight', 'line-height': 'lineHeight', 'letter-spacing': 'tracking',
|
|
648
|
+
'border-radius': 'radius', 'box-shadow': 'shadow',
|
|
649
|
+
};
|
|
650
|
+
const splitTop = (v) => {
|
|
651
|
+
const out = []; let depth = 0; let cur = '';
|
|
652
|
+
for (const ch of v) {
|
|
653
|
+
if (ch === '(') depth++;
|
|
654
|
+
if (ch === ')') depth--;
|
|
655
|
+
if (/\s/.test(ch) && depth === 0) { if (cur) out.push(cur); cur = ''; } else cur += ch;
|
|
656
|
+
}
|
|
657
|
+
if (cur) out.push(cur);
|
|
658
|
+
return out;
|
|
659
|
+
};
|
|
660
|
+
// the piece of a box shorthand that applies to one longhand; null when it cannot be told
|
|
661
|
+
const sidePiece = (sh, prop, value) => {
|
|
662
|
+
const parts = splitTop(value);
|
|
663
|
+
if (!parts.length || parts.length > 4) return null;
|
|
664
|
+
const n = parts.length;
|
|
665
|
+
const sub = prop.slice(prop.indexOf('-') + 1); // top | right | bottom | left | inline | block
|
|
666
|
+
if (/-(inline|block)$/.test(sh)) {
|
|
667
|
+
// 2-value: start end
|
|
668
|
+
if (sub === 'top' || sub === 'left') return parts[0];
|
|
669
|
+
if (sub === 'bottom' || sub === 'right') return parts[n > 1 ? 1 : 0];
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const idx = { top: 0, right: n > 1 ? 1 : 0, bottom: n > 2 ? 2 : 0, left: n > 3 ? 3 : n > 1 ? 1 : 0 };
|
|
673
|
+
if (sub in idx) return parts[idx[sub]];
|
|
674
|
+
if (sub === 'inline') return parts[idx.left] === parts[idx.right] ? parts[idx.right] : null;
|
|
675
|
+
if (sub === 'block') return parts[idx.top] === parts[idx.bottom] ? parts[idx.top] : null;
|
|
676
|
+
return null;
|
|
677
|
+
};
|
|
678
|
+
const noBorder = (cs) => ['top', 'right', 'bottom', 'left'].every((sd) =>
|
|
679
|
+
cs.getPropertyValue(`border-${sd}-style`) === 'none' || parseFloat(cs.getPropertyValue(`border-${sd}-width`)) === 0);
|
|
680
|
+
|
|
681
|
+
// prop -> the inline value the element had BEFORE the overlay previewed it (pending or sent)
|
|
682
|
+
const overriddenBy = (elx) => {
|
|
683
|
+
const m = new Map();
|
|
684
|
+
const pend = state.pending.get(elx);
|
|
685
|
+
if (pend) for (const c of pend.values()) { m.set(c.prop, c.from.inline); (c.companions || []).forEach((cp) => m.set(cp.prop, cp.before)); }
|
|
686
|
+
for (const entry of state.committed) if (entry.el === elx) for (const c of entry.changes) { if (!m.has(c.prop)) m.set(c.prop, c.from.inline); (c.companions || []).forEach((cp) => { if (!m.has(cp.prop)) m.set(cp.prop, cp.before); }); }
|
|
687
|
+
return m;
|
|
688
|
+
};
|
|
689
|
+
const tokenTrace = (target, ruleObjs) => {
|
|
690
|
+
const cs = getComputedStyle(target);
|
|
691
|
+
const index = customProps();
|
|
692
|
+
const out = {};
|
|
693
|
+
const ours = overriddenBy(target); // props the overlay itself set as previews
|
|
694
|
+
const authoredIn = (name) => {
|
|
695
|
+
const inline = ours.has(name) ? String(ours.get(name) || '').trim() : target.style.getPropertyValue(name).trim();
|
|
696
|
+
if (inline) return { v: inline, layer: 'inline', selector: null, from: 'inline style' };
|
|
697
|
+
let best = null;
|
|
698
|
+
for (const obj of ruleObjs) {
|
|
699
|
+
if (obj.transient) continue;
|
|
700
|
+
const v = obj.rule.style && obj.rule.style.getPropertyValue(name).trim();
|
|
701
|
+
if (!v) continue;
|
|
702
|
+
if (!best || obj.layer !== 'base' || best.layer === 'base') best = { ...obj, v };
|
|
703
|
+
}
|
|
704
|
+
return best ? { v: best.v, layer: best.layer, selector: best.rule.selectorText, from: `${best.rule.selectorText} · ${String(best.source).split('/').pop()}` } : null;
|
|
705
|
+
};
|
|
706
|
+
for (const prop of TRACE_PROPS) {
|
|
707
|
+
if (prop === 'border-color' && noBorder(cs)) continue; // no border: its colour is moot
|
|
708
|
+
let hit = authoredIn(prop);
|
|
709
|
+
let viaShorthand = null;
|
|
710
|
+
if (!hit && SHORTHANDS[prop]) {
|
|
711
|
+
for (const sh of SHORTHANDS[prop]) {
|
|
712
|
+
const h = authoredIn(sh);
|
|
713
|
+
if (!h) continue;
|
|
714
|
+
if (/^(padding|margin)/.test(sh)) {
|
|
715
|
+
const piece = sidePiece(sh, prop, h.v);
|
|
716
|
+
if (piece === null) continue;
|
|
717
|
+
hit = { ...h, v: piece };
|
|
718
|
+
} else {
|
|
719
|
+
hit = h;
|
|
720
|
+
}
|
|
721
|
+
viaShorthand = sh;
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (!hit) continue;
|
|
726
|
+
let { v: authored, layer, selector, from } = hit;
|
|
727
|
+
if (viaShorthand) from = `${from} (via ${viaShorthand})`;
|
|
728
|
+
const chain = [];
|
|
729
|
+
let cur = authored;
|
|
730
|
+
let guard = 0;
|
|
731
|
+
let skip = false;
|
|
732
|
+
while (guard++ < 6) {
|
|
733
|
+
let names = [...cur.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
|
734
|
+
if (viaShorthand && !/^(padding|margin)/.test(viaShorthand) && guard === 1) {
|
|
735
|
+
// a mixed shorthand (font, border, background): keep only tokens that fit this longhand
|
|
736
|
+
const fam = FAMILY_OF[prop];
|
|
737
|
+
const fitting = names.filter((nm) => fam && tokenFits(fam, nm));
|
|
738
|
+
if (names.length && !fitting.length) { skip = true; break; } // tokens there, none for this longhand
|
|
739
|
+
if (!names.length) authored = cs.getPropertyValue(prop).trim(); // literal shorthand: show the longhand's value
|
|
740
|
+
names = fitting;
|
|
741
|
+
}
|
|
742
|
+
if (!names.length) break;
|
|
743
|
+
let picked = names[0];
|
|
744
|
+
let def = '';
|
|
745
|
+
let resolved = '';
|
|
746
|
+
const KW = /^(initial|unset|revert|revert-layer|inherit)$/i;
|
|
747
|
+
for (const name of names) {
|
|
748
|
+
if (NOISE.test(name) && names.length > 1) continue; // --tw-* fallbacks hide the real token
|
|
749
|
+
const d = index[name] || '';
|
|
750
|
+
const rv = cs.getPropertyValue(name).trim();
|
|
751
|
+
if ((d && !KW.test(d)) || (rv && !KW.test(rv))) { picked = name; def = d; resolved = rv; break; }
|
|
752
|
+
}
|
|
753
|
+
chain.push({ name: picked, value: def || resolved || '(unset)' });
|
|
754
|
+
if (!def || !VAR_RE.test(def)) break;
|
|
755
|
+
cur = def;
|
|
756
|
+
}
|
|
757
|
+
if (skip) continue;
|
|
758
|
+
let computed = cs.getPropertyValue(prop).trim();
|
|
759
|
+
if (!computed) computed = cs.getPropertyValue(`${prop}-start`).trim();
|
|
760
|
+
let status;
|
|
761
|
+
if (chain.length) status = 'token';
|
|
762
|
+
else if (/^(inherit|initial|unset|revert|revert-layer|currentcolor|transparent|none|normal|auto|0)$/i.test(authored)) status = 'keyword'; // not a design decision to flag
|
|
763
|
+
else if (layer === 'base') status = 'reset';
|
|
764
|
+
else if (layer === 'utilities') status = selector && selector.includes('\\[') ? 'hardcoded' : 'utility';
|
|
765
|
+
else status = 'hardcoded';
|
|
766
|
+
out[prop] = { computed, authored, from, selector, layer, chain, status };
|
|
767
|
+
}
|
|
768
|
+
return out;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const semanticName = (t) => {
|
|
772
|
+
if (!t) return null;
|
|
773
|
+
if (t.chain.length) return t.chain[0].name.replace(/^--/, '');
|
|
774
|
+
if (t.status === 'utility' && t.selector) {
|
|
775
|
+
return t.selector.split(',')[0].trim().replace(/^\./, '').replace(/\\/g, '').replace(/:.*$/, '');
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
};
|
|
779
|
+
// the concrete value behind a trace: the end of the var() chain when the property IS that
|
|
780
|
+
// var, the computed value when the var sits inside calc()/color-mix()/etc.
|
|
781
|
+
const bareVar = (v) => /^var\(\s*--[A-Za-z0-9_-]+\s*(,[^)]*)?\)$/.test(String(v || '').trim());
|
|
782
|
+
const primitiveOf = (t, fallback = '') => {
|
|
783
|
+
if (!t) return fallback;
|
|
784
|
+
if (t.chain.length && !bareVar(t.authored) && t.computed) return t.computed;
|
|
785
|
+
return t.chain.length ? t.chain[t.chain.length - 1].value : (t.computed || t.authored);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const resolveVar = (name, elx) => {
|
|
789
|
+
const idx = customProps();
|
|
790
|
+
let cur = idx[name] || '';
|
|
791
|
+
let guard = 0;
|
|
792
|
+
while (guard++ < 6 && VAR_RE.test(cur)) {
|
|
793
|
+
const m = VAR_RE.exec(cur);
|
|
794
|
+
cur = idx[m[1]] || '';
|
|
795
|
+
}
|
|
796
|
+
return cur || getComputedStyle(elx || document.documentElement).getPropertyValue(name).trim() || '';
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
// ---- token discovery (app-agnostic) -------------------------------------------
|
|
800
|
+
// Every --* custom property the page defines is indexed (customProps). Pickers group
|
|
801
|
+
// them into families by, in order: the project's own patterns (plugin option `tokens`,
|
|
802
|
+
// arriving as regex sources in cfg.tokens), common naming conventions, and finally the
|
|
803
|
+
// value's type when the name says nothing. Nothing about a specific framework is required.
|
|
804
|
+
const FAMILY_KEYS = ['color', 'fontFamily', 'fontWeight', 'fontSize', 'lineHeight', 'tracking', 'radius', 'shadow', 'spacing'];
|
|
805
|
+
const DEFAULT_HINTS = {
|
|
806
|
+
color: /^--(?:color|colou?rs?|palette|brand|accent|primary|secondary|tertiary|surface|bg|background|fg|foreground|text-color|border-color|fill|stroke|neutral|gray|grey|slate|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-|$)/,
|
|
807
|
+
fontFamily: /^--(?:font-family|font(?!-weight|-size|-style|-stretch)|ff|typeface|family)(?:-|$)/,
|
|
808
|
+
fontWeight: /^--(?:font-weight|fw|weight)(?:-|$)/,
|
|
809
|
+
fontSize: /^--(?:font-size|fs|text(?!-shadow)|type-scale|type|size-text|heading|body)(?:-|$)/,
|
|
810
|
+
lineHeight: /^--(?:line-height|leading|lh)(?:-|$)/,
|
|
811
|
+
tracking: /^--(?:letter-spacing|tracking|ls)(?:-|$)/,
|
|
812
|
+
radius: /^--(?:radius|radii|rounded|corner|border-radius|br)(?:-|$)/,
|
|
813
|
+
shadow: /^--(?:shadow|elevation|box-shadow)(?:-|$)/,
|
|
814
|
+
spacing: /^--(?:spacing|space|sp|gap|inset|size|spacer)(?:-|$)/,
|
|
815
|
+
};
|
|
816
|
+
// internals and non-design families that should not pollute pickers
|
|
817
|
+
const NOISE = /^--(?:tw-|default-|animate-|ease-|blur-|perspective-|aspect-|breakpoint-|container-|drop-shadow-|inset-shadow-|inset-ring|ring-|text-shadow-|vite-|cdm-)|--line-height$|--font-weight$|--letter-spacing$/;
|
|
818
|
+
const userHints = (() => {
|
|
819
|
+
const out = {};
|
|
820
|
+
for (const [k, v] of Object.entries(cfg.tokens || {})) {
|
|
821
|
+
if (!FAMILY_KEYS.includes(k) || !v) continue;
|
|
822
|
+
try { out[k] = v instanceof RegExp ? v : new RegExp(String(v)); } catch { /* bad pattern: ignore */ }
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
})();
|
|
826
|
+
const hintFor = (name) => {
|
|
827
|
+
for (const k of FAMILY_KEYS) if (userHints[k] && userHints[k].test(name)) return k;
|
|
828
|
+
for (const k of FAMILY_KEYS) if (DEFAULT_HINTS[k].test(name)) return k;
|
|
829
|
+
return null;
|
|
830
|
+
};
|
|
831
|
+
const rootFontPx = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
832
|
+
const LENGTH_RE = /^-?(?:\d+|\d*\.\d+)(px|rem|em|%|vw|vh|vmin|vmax|ch|ex|cqw|cqh|cqi|svh|lvh|dvh|svw|lvw|dvw)?$/;
|
|
833
|
+
const lengthToPx = (v) => {
|
|
834
|
+
const m = LENGTH_RE.exec(String(v || '').trim());
|
|
835
|
+
if (!m) return null;
|
|
836
|
+
const n = parseFloat(m[0]);
|
|
837
|
+
if (!m[1]) return n === 0 ? 0 : null;
|
|
838
|
+
if (m[1] === 'px') return n;
|
|
839
|
+
if (m[1] === 'rem' || m[1] === 'em') return n * rootFontPx();
|
|
840
|
+
return null; // viewport/percent units: real, but not convertible here
|
|
841
|
+
};
|
|
842
|
+
const supports = (prop, v) => { try { return CSS.supports(prop, v); } catch { return false; } };
|
|
843
|
+
const LENGTH_PROPS = new Set(['font-size', 'letter-spacing', 'border-radius', 'gap', 'padding', 'margin', 'width', 'height', 'line-height']);
|
|
844
|
+
// value type: color | length | weight | lineHeight | tracking | shadow | fontFamily | number | other
|
|
845
|
+
const classifyValue = (raw) => {
|
|
846
|
+
const v = String(raw || '').trim();
|
|
847
|
+
if (!v || /^(inherit|initial|unset|revert|revert-layer|currentcolor)$/i.test(v)) return 'other';
|
|
848
|
+
if (/^-?(?:\d+|\d*\.\d+)$/.test(v)) {
|
|
849
|
+
const n = parseFloat(v);
|
|
850
|
+
if (Number.isInteger(n) && n >= 100 && n <= 1000 && n % 50 === 0) return 'weight';
|
|
851
|
+
if (n > 0.5 && n < 4) return 'lineHeight';
|
|
852
|
+
return 'number';
|
|
853
|
+
}
|
|
854
|
+
if (LENGTH_RE.test(v)) {
|
|
855
|
+
const m = LENGTH_RE.exec(v);
|
|
856
|
+
if (m[1] === 'em' && Math.abs(parseFloat(v)) < 0.25) return 'tracking';
|
|
857
|
+
return 'length';
|
|
858
|
+
}
|
|
859
|
+
if (/^calc\(|^clamp\(|^min\(|^max\(/.test(v) && supports('width', v)) return 'length';
|
|
860
|
+
if (supports('color', v)) return 'color';
|
|
861
|
+
if (v !== 'none' && /\d/.test(v) && supports('box-shadow', v)) return 'shadow';
|
|
862
|
+
if (supports('font-family', v) && /[a-z]/i.test(v) && (v.includes(',') || /serif|sans|mono|system-ui|ui-|"|'/.test(v))) return 'fontFamily';
|
|
863
|
+
return 'other';
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const natural = (a, b) => a.localeCompare(b, undefined, { numeric: true });
|
|
867
|
+
let catalogCache = null;
|
|
868
|
+
let catalogAt = 0;
|
|
869
|
+
let catalogSheets = 0;
|
|
870
|
+
const tokenCatalog = () => {
|
|
871
|
+
if (catalogCache && Date.now() - catalogAt < 10000 && catalogSheets === document.styleSheets.length) return catalogCache;
|
|
872
|
+
const idx = customProps();
|
|
873
|
+
const fam = Object.fromEntries(FAMILY_KEYS.map((k) => [k, []]));
|
|
874
|
+
const lengthPool = [];
|
|
875
|
+
const types = {};
|
|
876
|
+
for (const name of Object.keys(idx).sort(natural)) {
|
|
877
|
+
if (NOISE.test(name)) continue;
|
|
878
|
+
const hint = hintFor(name);
|
|
879
|
+
if (hint === 'spacing' && name === '--spacing') continue; // Tailwind's base unit, handled as the spacing base
|
|
880
|
+
const prim = resolveVar(name);
|
|
881
|
+
const type = classifyValue(prim);
|
|
882
|
+
types[name] = type;
|
|
883
|
+
if (hint) { fam[hint].push(name); continue; }
|
|
884
|
+
// unnamed families: the value decides
|
|
885
|
+
if (type === 'color') fam.color.push(name);
|
|
886
|
+
else if (type === 'weight') fam.fontWeight.push(name);
|
|
887
|
+
else if (type === 'lineHeight') fam.lineHeight.push(name);
|
|
888
|
+
else if (type === 'tracking') fam.tracking.push(name);
|
|
889
|
+
else if (type === 'shadow') fam.shadow.push(name);
|
|
890
|
+
else if (type === 'fontFamily') fam.fontFamily.push(name);
|
|
891
|
+
else if (type === 'length') lengthPool.push(name);
|
|
892
|
+
}
|
|
893
|
+
// generic lengths are offered wherever a length fits, after the named families
|
|
894
|
+
for (const k of ['fontSize', 'radius', 'spacing']) fam[k] = fam[k].concat(lengthPool.filter((n) => !fam[k].includes(n)));
|
|
895
|
+
catalogCache = { ...fam, types, spacingBase: idx['--spacing'] || null, source: Object.keys(userHints).length ? 'project+conventions+values' : 'conventions+values' };
|
|
896
|
+
catalogAt = Date.now();
|
|
897
|
+
catalogSheets = document.styleSheets.length;
|
|
898
|
+
return catalogCache;
|
|
899
|
+
};
|
|
900
|
+
const typeOfToken = (name) => { const cat = tokenCatalog(); return cat.types[name] || classifyValue(resolveVar(name)); };
|
|
901
|
+
// what a field accepts beyond its own family list (a real token typed by name)
|
|
902
|
+
const FIELD_TYPES = { color: ['color'], fontFamily: ['fontFamily'], fontWeight: ['weight', 'number'], fontSize: ['length'], lineHeight: ['lineHeight', 'number', 'length'], tracking: ['tracking', 'length'], radius: ['length'], shadow: ['shadow'], spacing: ['length'] };
|
|
903
|
+
const tokenFits = (key, name) => {
|
|
904
|
+
const cat = tokenCatalog();
|
|
905
|
+
if ((cat[key] || []).includes(name)) return true;
|
|
906
|
+
return (FIELD_TYPES[key] || []).includes(typeOfToken(name));
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
// Spacing model: Tailwind-style base unit when the app has one (--spacing: 0.25rem),
|
|
910
|
+
// otherwise the app's own spacing tokens (--space-4: 1rem ...), otherwise plain px.
|
|
911
|
+
const spacingBasePx = () => {
|
|
912
|
+
const v = tokenCatalog().spacingBase;
|
|
913
|
+
if (!v) return null;
|
|
914
|
+
const px = lengthToPx(v);
|
|
915
|
+
return px && px > 0 ? px : null;
|
|
916
|
+
};
|
|
917
|
+
const spacingTokens = () => tokenCatalog().spacing
|
|
918
|
+
.map((n) => ({ name: n, px: lengthToPx(resolveVar(n)) }))
|
|
919
|
+
.filter((t) => t.px !== null)
|
|
920
|
+
.sort((a, b) => a.px - b.px || natural(a.name, b.name));
|
|
921
|
+
|
|
922
|
+
/* ------------------------------------------------------------ payload --- */
|
|
923
|
+
|
|
924
|
+
const strippedHTML = (target) => {
|
|
925
|
+
const clone = target.cloneNode(true);
|
|
926
|
+
clone.querySelectorAll('script,style').forEach((s) => s.remove());
|
|
927
|
+
return clone.outerHTML.slice(0, MAX_HTML);
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const elementContext = (target) => {
|
|
931
|
+
const parent = target.parentElement;
|
|
932
|
+
const sibs = parent ? [...parent.children] : [target];
|
|
933
|
+
const fiber = findFiber(target);
|
|
934
|
+
const rect = target.getBoundingClientRect();
|
|
935
|
+
return {
|
|
936
|
+
selector: cssPath(target),
|
|
937
|
+
domPath: { indexInParent: sibs.indexOf(target), siblingCount: sibs.length },
|
|
938
|
+
tag: target.tagName.toLowerCase(),
|
|
939
|
+
classList: [...target.classList].slice(0, 40),
|
|
940
|
+
text: (target.textContent || '').trim().slice(0, MAX_TEXT),
|
|
941
|
+
componentChain: fiber ? componentChain(fiber) : [],
|
|
942
|
+
source: resolveSource(target),
|
|
943
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
944
|
+
};
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const viewportInfo = () => ({
|
|
948
|
+
w: innerWidth,
|
|
949
|
+
h: innerHeight,
|
|
950
|
+
theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
const buildPayload = (target, instruction, scope) => {
|
|
954
|
+
const ruleObjs = matchedRuleObjects(target);
|
|
955
|
+
state.seq += 1;
|
|
956
|
+
return {
|
|
957
|
+
v: 1,
|
|
958
|
+
kind: 'selection',
|
|
959
|
+
seq: state.seq,
|
|
960
|
+
ts: Date.now(),
|
|
961
|
+
instruction,
|
|
962
|
+
scope,
|
|
963
|
+
url: location.href,
|
|
964
|
+
...elementContext(target),
|
|
965
|
+
// untrusted page data below: the agent treats these as data, never instructions
|
|
966
|
+
outerHTML: strippedHTML(target),
|
|
967
|
+
computed: computedSubset(target),
|
|
968
|
+
matchedRules: matchedRules(ruleObjs),
|
|
969
|
+
tokens: tokenTrace(target, ruleObjs),
|
|
970
|
+
viewport: viewportInfo(),
|
|
971
|
+
};
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
/* ----------------------------------------------------------- delivery --- */
|
|
975
|
+
|
|
976
|
+
const removeFromQueue = (seq) => {
|
|
977
|
+
state.queue = state.queue.filter((p) => p.seq !== seq);
|
|
978
|
+
persist();
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
let retryTimer = null;
|
|
982
|
+
const scheduleRetry = () => {
|
|
983
|
+
if (retryTimer || !cfg.endpoint) return;
|
|
984
|
+
retryTimer = setTimeout(() => {
|
|
985
|
+
retryTimer = null;
|
|
986
|
+
state.queue.filter((p) => !p.failed403 && !p.gaveUp).forEach((p) => post(p));
|
|
987
|
+
}, 5000);
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const post = async (payload) => {
|
|
991
|
+
payload.attempts = (payload.attempts || 0) + 1;
|
|
992
|
+
try {
|
|
993
|
+
const res = await fetch(cfg.endpoint, {
|
|
994
|
+
method: 'POST',
|
|
995
|
+
headers: { 'content-type': 'application/json', 'x-design-mode-token': cfg.token || '' },
|
|
996
|
+
body: JSON.stringify(payload),
|
|
997
|
+
});
|
|
998
|
+
if (res.ok) {
|
|
999
|
+
removeFromQueue(payload.seq);
|
|
1000
|
+
showToast('Sent to Claude');
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
if (res.status === 403) {
|
|
1004
|
+
payload.failed403 = true;
|
|
1005
|
+
persist();
|
|
1006
|
+
showToast('Not sent: the dev server restarted. Reload the page to reconnect.', 8000);
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
if (payload.attempts >= 3) {
|
|
1010
|
+
payload.gaveUp = true;
|
|
1011
|
+
persist();
|
|
1012
|
+
showToast(`Not sent: the dev server rejected it (${res.status}). Check its log.`, 8000);
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
showToast(`Send failed (${res.status}), retrying…`, 6000);
|
|
1016
|
+
} catch {
|
|
1017
|
+
showToast('Dev server unreachable, retrying…', 6000);
|
|
1018
|
+
}
|
|
1019
|
+
persist();
|
|
1020
|
+
scheduleRetry();
|
|
1021
|
+
return false;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const deliver = async (payload) => {
|
|
1025
|
+
state.queue.push(payload);
|
|
1026
|
+
persist();
|
|
1027
|
+
// Tiny signal only: console truncates silently above ~4KB, so never the payload.
|
|
1028
|
+
console.log(`[design-mode] ${payload.kind} #${payload.seq} ready${payload.source ? ` (source via ${payload.source.via})` : ''}`);
|
|
1029
|
+
if (cfg.endpoint) return (await post(payload)) ? 'sent' : 'failed';
|
|
1030
|
+
if (cfg.wakeUrl) {
|
|
1031
|
+
try { fetch(`${cfg.wakeUrl}?token=${encodeURIComponent(cfg.token || '')}`, { mode: 'no-cors' }); } catch { /* not armed */ }
|
|
1032
|
+
}
|
|
1033
|
+
showToast('Queued for Claude');
|
|
1034
|
+
return 'queued';
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/* ------------------------------------------------------ live previews --- */
|
|
1038
|
+
|
|
1039
|
+
const pendingFor = (elx) => {
|
|
1040
|
+
if (!state.pending.has(elx)) state.pending.set(elx, new Map());
|
|
1041
|
+
return state.pending.get(elx);
|
|
1042
|
+
};
|
|
1043
|
+
const pendingCount = () => [...state.pending.values()].reduce((n, m) => n + m.size, 0);
|
|
1044
|
+
|
|
1045
|
+
// the CSSOM serialises literal colours as rgb(); show hex, which is what people write
|
|
1046
|
+
const rgbToHex = (v) => {
|
|
1047
|
+
const m = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(String(v || '').trim());
|
|
1048
|
+
if (!m) return v;
|
|
1049
|
+
const h = m.slice(1, 4).map((x) => Number(x).toString(16).padStart(2, '0')).join('');
|
|
1050
|
+
return '#' + (/^(.)\1(.)\2(.)\3$/.test(h) ? h[0] + h[2] + h[4] : h);
|
|
1051
|
+
};
|
|
1052
|
+
const labelFor = (side) => side.label || (side.token ? side.token.replace(/^--/, '') : rgbToHex(side.primitive || side.css || ''));
|
|
1053
|
+
|
|
1054
|
+
const SPACING_RE = /calc\(\s*var\(--spacing\)\s*\*\s*(-?[\d.]+)\s*\)/;
|
|
1055
|
+
const fromLabel = (t) => {
|
|
1056
|
+
if (!t) return null;
|
|
1057
|
+
const m = t.authored && SPACING_RE.exec(t.authored);
|
|
1058
|
+
if (m) return `spacing × ${m[1]}`;
|
|
1059
|
+
if (!t.chain.length && /^[a-z-]+$/.test(t.authored)) return t.authored; // transparent, none, inherit
|
|
1060
|
+
return t.chain.length ? null : semanticName(t);
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
// Apply a runtime override and record it. meta: { token?, primitive?, label?, system? }
|
|
1064
|
+
// system=true means the value maps to a framework utility (display:flex, spacing units),
|
|
1065
|
+
// so it is not a hardcoded literal even though it carries no token.
|
|
1066
|
+
const applyPreview = (prop, css, meta = {}) => {
|
|
1067
|
+
const elx = state.selectedEl;
|
|
1068
|
+
if (!elx) return;
|
|
1069
|
+
const t = state.traces[prop];
|
|
1070
|
+
const map = pendingFor(elx);
|
|
1071
|
+
const existing = map.get(prop);
|
|
1072
|
+
const from = existing ? existing.from : {
|
|
1073
|
+
inline: elx.style.getPropertyValue(prop),
|
|
1074
|
+
authored: t ? t.authored : null,
|
|
1075
|
+
token: t && t.chain.length ? t.chain[0].name : null,
|
|
1076
|
+
label: fromLabel(t),
|
|
1077
|
+
primitive: primitiveOf(t, getComputedStyle(elx).getPropertyValue(prop).trim()),
|
|
1078
|
+
};
|
|
1079
|
+
if (existing) (existing.companions || []).forEach((c) => c.before ? elx.style.setProperty(c.prop, c.before) : elx.style.removeProperty(c.prop));
|
|
1080
|
+
elx.style.setProperty(prop, css);
|
|
1081
|
+
const companions = [];
|
|
1082
|
+
if (prop === 'font-size' && meta.token && customProps()[`${meta.token}--line-height`] !== undefined) {
|
|
1083
|
+
const before = existing && existing.companions && existing.companions[0] ? existing.companions[0].before : elx.style.getPropertyValue('line-height');
|
|
1084
|
+
companions.push({ prop: 'line-height', before, css: `var(${meta.token}--line-height)` });
|
|
1085
|
+
elx.style.setProperty('line-height', `var(${meta.token}--line-height)`);
|
|
1086
|
+
}
|
|
1087
|
+
// Back where it started (same authored value, or nothing authored and the same result):
|
|
1088
|
+
// lift the override instead of recording a no-op change
|
|
1089
|
+
const backToStart = (from.authored && css === from.authored)
|
|
1090
|
+
|| (!from.authored && !meta.token && !from.inline && getComputedStyle(elx).getPropertyValue(prop).trim() === String(from.primitive || '').trim());
|
|
1091
|
+
if (backToStart) {
|
|
1092
|
+
companions.forEach((c) => c.before ? elx.style.setProperty(c.prop, c.before) : elx.style.removeProperty(c.prop));
|
|
1093
|
+
from.inline ? elx.style.setProperty(prop, from.inline) : elx.style.removeProperty(prop);
|
|
1094
|
+
map.delete(prop);
|
|
1095
|
+
if (!map.size) state.pending.delete(elx);
|
|
1096
|
+
renderTray();
|
|
1097
|
+
refreshModMarks();
|
|
1098
|
+
onScrollOrResize();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
map.set(prop, {
|
|
1102
|
+
prop,
|
|
1103
|
+
from,
|
|
1104
|
+
to: { css, token: meta.token || null, label: meta.label || null, primitive: meta.primitive || css, hardcoded: !meta.token && !meta.system },
|
|
1105
|
+
companions,
|
|
1106
|
+
});
|
|
1107
|
+
renderTray();
|
|
1108
|
+
if (companions.length) renderPanel(elx, true); // a companion (line-height with a text token) must show as changed too
|
|
1109
|
+
else refreshModMarks();
|
|
1110
|
+
onScrollOrResize();
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// re-applies a recorded change's preview onto an element (used when the page re-renders a twin)
|
|
1114
|
+
const reapplyOverride = (elx, c) => {
|
|
1115
|
+
elx.style.setProperty(c.prop, c.to.css);
|
|
1116
|
+
(c.companions || []).forEach((cp) => elx.style.setProperty(cp.prop, cp.css));
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
const liftOverride = (elx, c) => {
|
|
1120
|
+
c.from.inline ? elx.style.setProperty(c.prop, c.from.inline) : elx.style.removeProperty(c.prop);
|
|
1121
|
+
(c.companions || []).forEach((cp) => cp.before ? elx.style.setProperty(cp.prop, cp.before) : elx.style.removeProperty(cp.prop));
|
|
1122
|
+
onScrollOrResize();
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const revertChange = (elx, prop) => {
|
|
1126
|
+
const map = state.pending.get(elx);
|
|
1127
|
+
if (!map || !map.has(prop)) return;
|
|
1128
|
+
liftOverride(elx, map.get(prop));
|
|
1129
|
+
map.delete(prop);
|
|
1130
|
+
if (!map.size) state.pending.delete(elx);
|
|
1131
|
+
renderTray();
|
|
1132
|
+
if (elx === state.selectedEl) renderPanel(elx, true);
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
const discardAll = () => {
|
|
1136
|
+
for (const [elx, map] of state.pending) for (const c of map.values()) liftOverride(elx, c);
|
|
1137
|
+
state.pending = new Map();
|
|
1138
|
+
renderTray();
|
|
1139
|
+
if (state.selectedEl) renderPanel(state.selectedEl, true);
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
// lifts only the previews that were already sent; unsent work stays
|
|
1143
|
+
const clearPreviews = () => {
|
|
1144
|
+
for (const { el: elx, changes } of state.committed) for (const c of changes) liftOverride(elx, c);
|
|
1145
|
+
state.committed = [];
|
|
1146
|
+
renderTray();
|
|
1147
|
+
if (state.selectedEl) renderPanel(state.selectedEl, true);
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
const commitChanges = (note, scope) => {
|
|
1151
|
+
const targets = [];
|
|
1152
|
+
const sentEls = [];
|
|
1153
|
+
for (const [elx, map] of state.pending) {
|
|
1154
|
+
if (!map.size || !elx.isConnected) continue;
|
|
1155
|
+
sentEls.push(elx);
|
|
1156
|
+
targets.push({
|
|
1157
|
+
...elementContext(elx),
|
|
1158
|
+
edits: [...map.values()].map((c) => ({
|
|
1159
|
+
prop: c.prop,
|
|
1160
|
+
from: { authored: c.from.authored, token: c.from.token, label: c.from.label, primitive: c.from.primitive },
|
|
1161
|
+
to: { css: c.to.css, token: c.to.token, label: c.to.label, primitive: c.to.primitive, hardcoded: c.to.hardcoded },
|
|
1162
|
+
})),
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
if (!targets.length) return;
|
|
1166
|
+
const entry = { status: 'sending' };
|
|
1167
|
+
// kept per element for the preview lift; the shared entry carries delivery status
|
|
1168
|
+
state.committed.push(...sentEls.map((elx) => ({ el: elx, changes: [...state.pending.get(elx).values()], entry })));
|
|
1169
|
+
const summary = targets.map((tg) => `${tg.componentChain[0] || tg.tag}: ${tg.edits.map((e) => `${e.prop} ${labelFor(e.from)} → ${labelFor(e.to)}`).join(', ')}`).join('; ');
|
|
1170
|
+
state.seq += 1;
|
|
1171
|
+
const payload = {
|
|
1172
|
+
v: 1,
|
|
1173
|
+
kind: 'design-edits',
|
|
1174
|
+
seq: state.seq,
|
|
1175
|
+
ts: Date.now(),
|
|
1176
|
+
instruction: note ? `${note} (design edits: ${summary})` : `Apply these design edits to the source: ${summary}`,
|
|
1177
|
+
note,
|
|
1178
|
+
scope,
|
|
1179
|
+
url: location.href,
|
|
1180
|
+
viewport: viewportInfo(),
|
|
1181
|
+
targets,
|
|
1182
|
+
};
|
|
1183
|
+
for (const elx of sentEls) state.pending.delete(elx); // detached elements keep their unsent edits listed
|
|
1184
|
+
renderTray();
|
|
1185
|
+
refreshModMarks();
|
|
1186
|
+
deliver(payload).then((r) => { entry.status = r; renderTray(); });
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
/* ------------------------------------------------------------ tray UI --- */
|
|
1190
|
+
|
|
1191
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
1192
|
+
|
|
1193
|
+
const elLabel = (elx) => {
|
|
1194
|
+
const f = findFiber(elx);
|
|
1195
|
+
const chain = f ? componentChain(f) : [];
|
|
1196
|
+
return chain[0] || elx.tagName.toLowerCase();
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
let trayNoteValue = '';
|
|
1200
|
+
let trayScopeValue = 'auto';
|
|
1201
|
+
const SCOPES = [
|
|
1202
|
+
{ value: 'auto', label: 'Auto', title: 'Let Claude decide from the request' },
|
|
1203
|
+
{ value: 'instance', label: 'This element', title: 'Change only this element (its call site), not the shared component' },
|
|
1204
|
+
{ value: 'component', label: 'All instances', title: 'Change the shared component so every instance follows' },
|
|
1205
|
+
{ value: 'token', label: 'Token', title: 'Change the design token itself, everywhere it is used' },
|
|
1206
|
+
];
|
|
1207
|
+
|
|
1208
|
+
const changeRow = (elx, c, onRevert) => {
|
|
1209
|
+
const row = mk('chg');
|
|
1210
|
+
const what = mk('what');
|
|
1211
|
+
const gone = !elx.isConnected;
|
|
1212
|
+
what.innerHTML = `<span class="who">${esc(elLabel(elx))}</span> <b>${esc(c.prop)}</b> ${esc(labelFor(c.from))}<span class="arrow">→</span>${esc(labelFor(c.to))}${c.to.hardcoded ? ' ' + HC_HTML('hardcoded value') : ''}${gone ? ' <span class="muted">(element no longer on the page, will not be sent)</span>' : ''}`;
|
|
1213
|
+
what.title = `${c.from.primitive} → ${c.to.primitive}${c.to.hardcoded ? ' (hardcoded)' : ''}`;
|
|
1214
|
+
if (gone) row.style.opacity = '0.6';
|
|
1215
|
+
const x = mk('x', 'button');
|
|
1216
|
+
x.textContent = '✕';
|
|
1217
|
+
x.title = 'Revert this change';
|
|
1218
|
+
x.addEventListener('click', onRevert);
|
|
1219
|
+
row.append(what, x);
|
|
1220
|
+
return row;
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// Compact footer: a change total plus actions. The list lives in the confirm modal.
|
|
1224
|
+
const renderTray = () => {
|
|
1225
|
+
const n = pendingCount();
|
|
1226
|
+
const committed = state.committed.reduce((k, c) => k + c.changes.length, 0);
|
|
1227
|
+
const wasEmpty = tray.classList.contains('empty');
|
|
1228
|
+
tray.innerHTML = '';
|
|
1229
|
+
tray.style.display = 'block';
|
|
1230
|
+
tray.classList.toggle('empty', !n && !committed);
|
|
1231
|
+
if (!n && !committed) { tray.textContent = 'No changes yet'; syncPill(); return; }
|
|
1232
|
+
if (n) {
|
|
1233
|
+
const hard = [...state.pending.values()].reduce((k, m) => k + [...m.values()].filter((c) => c.to.hardcoded).length, 0);
|
|
1234
|
+
const stale = [...state.pending.keys()].filter((elx) => !elx.isConnected).length;
|
|
1235
|
+
const h = mk('tray-h');
|
|
1236
|
+
const count = mk('count-btn count', 'button');
|
|
1237
|
+
count.innerHTML = `${n} change${n > 1 ? 's' : ''}${hard ? ' ' + HC_HTML(`${hard} hardcoded value${hard > 1 ? 's' : ''}`) : ''}`;
|
|
1238
|
+
count.title = 'Review the changes' + (hard ? ` · ${hard} hardcoded value${hard > 1 ? 's' : ''}` : '') + (stale ? ` · ${stale} element${stale > 1 ? 's' : ''} no longer on the page` : '');
|
|
1239
|
+
count.addEventListener('click', openCommitModal);
|
|
1240
|
+
const discard = mk('btn sm ghost', 'button');
|
|
1241
|
+
discard.textContent = 'Discard';
|
|
1242
|
+
discard.title = 'Drop all unsent changes and their previews';
|
|
1243
|
+
discard.addEventListener('click', () => { if (n > 1) confirmBox({ text: `Discard ${n} unsent changes?`, ok: 'Discard', onOk: discardAll }); else discardAll(); });
|
|
1244
|
+
h.append(count, discard);
|
|
1245
|
+
const commit = mk('btn primary full', 'button');
|
|
1246
|
+
commit.textContent = 'Ask Claude to commit';
|
|
1247
|
+
commit.title = 'Review, add a note, and send the changes to Claude';
|
|
1248
|
+
commit.addEventListener('click', openCommitModal);
|
|
1249
|
+
tray.append(h, commit);
|
|
1250
|
+
}
|
|
1251
|
+
if (committed) {
|
|
1252
|
+
const statuses = new Set(state.committed.map((c) => (c.entry ? c.entry.status : 'sent')));
|
|
1253
|
+
const word = statuses.has('sending') ? 'Sending to Claude' : statuses.has('failed') ? 'Not sent (see message)' : statuses.has('queued') ? 'Queued for Claude' : 'Sent to Claude';
|
|
1254
|
+
const st = mk('tray-status');
|
|
1255
|
+
st.innerHTML = `<span title="Previews stay on the page until Claude applies the edits and the page reloads">${word} · ${committed} previewing</span>`;
|
|
1256
|
+
const clear = document.createElement('button');
|
|
1257
|
+
clear.textContent = 'Clear previews';
|
|
1258
|
+
clear.title = 'Lift the sent previews from the page (unsent changes stay)';
|
|
1259
|
+
clear.addEventListener('click', clearPreviews);
|
|
1260
|
+
st.append(clear);
|
|
1261
|
+
if (n) st.style.marginTop = '8px';
|
|
1262
|
+
tray.append(st);
|
|
1263
|
+
}
|
|
1264
|
+
syncPill();
|
|
1265
|
+
// the tray just grew over the bottom of the panel: keep the field being edited in view
|
|
1266
|
+
if (wasEmpty && shadow.activeElement && panelScroll.contains(shadow.activeElement)) {
|
|
1267
|
+
const a = shadow.activeElement;
|
|
1268
|
+
requestAnimationFrame(() => { if (a.isConnected) { a.scrollIntoView({ block: 'nearest' }); if (dd && dd.anchor === a) dd.place(); } });
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// A small yes/no box on top of the sidebar, for the two destructive moments
|
|
1273
|
+
let confirmEl = null;
|
|
1274
|
+
const closeConfirm = () => { if (confirmEl) { confirmEl.remove(); confirmEl = null; } };
|
|
1275
|
+
const confirmBox = ({ text, ok, onOk, cancel = 'Keep editing' }) => {
|
|
1276
|
+
closeConfirm();
|
|
1277
|
+
const bg = mk('modal-bg ui');
|
|
1278
|
+
const box_ = mk('modal confirm');
|
|
1279
|
+
const h = mk('modal-h'); h.textContent = text;
|
|
1280
|
+
const foot = mk('modal-foot');
|
|
1281
|
+
const no = mk('btn ghost', 'button'); no.textContent = cancel; no.addEventListener('click', closeConfirm);
|
|
1282
|
+
const yes = mk('btn primary', 'button'); yes.textContent = ok; yes.addEventListener('click', () => { closeConfirm(); onOk(); });
|
|
1283
|
+
foot.append(no, yes);
|
|
1284
|
+
box_.append(h, foot);
|
|
1285
|
+
bg.addEventListener('click', (e) => { if (e.target === bg) closeConfirm(); });
|
|
1286
|
+
bg.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); closeConfirm(); } if (e.key === 'Enter') { e.preventDefault(); yes.click(); } });
|
|
1287
|
+
bg.append(box_);
|
|
1288
|
+
shadow.append(bg);
|
|
1289
|
+
confirmEl = bg;
|
|
1290
|
+
setTimeout(() => yes.focus(), 0);
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
let modal = null;
|
|
1294
|
+
const closeCommitModal = () => { if (modal) { if (dd && modal.contains(dd.el)) closeDropdown(); modal.remove(); modal = null; } };
|
|
1295
|
+
const openCommitModal = () => {
|
|
1296
|
+
closeCommitModal();
|
|
1297
|
+
const bg = mk('modal-bg ui');
|
|
1298
|
+
const box_ = mk('modal');
|
|
1299
|
+
const render = () => {
|
|
1300
|
+
const n = pendingCount();
|
|
1301
|
+
box_.innerHTML = '';
|
|
1302
|
+
const h = mk('modal-h');
|
|
1303
|
+
h.innerHTML = `<span>Ask Claude to commit <span class="muted">· ${n} change${n === 1 ? '' : 's'}</span></span>`;
|
|
1304
|
+
const close = mk('kbtn', 'button');
|
|
1305
|
+
close.textContent = 'esc';
|
|
1306
|
+
close.title = 'Cancel (Esc)';
|
|
1307
|
+
close.addEventListener('click', closeCommitModal);
|
|
1308
|
+
h.append(close);
|
|
1309
|
+
box_.append(h);
|
|
1310
|
+
const list = mk('modal-list');
|
|
1311
|
+
if (!n) { const e = mk('modal-empty'); e.textContent = 'Nothing left to commit.'; list.append(e); }
|
|
1312
|
+
for (const [elx, map] of state.pending) {
|
|
1313
|
+
for (const c of map.values()) list.append(changeRow(elx, c, () => { revertChange(elx, c.prop); pendingCount() ? render() : closeCommitModal(); }));
|
|
1314
|
+
}
|
|
1315
|
+
box_.append(list);
|
|
1316
|
+
const body = mk('modal-body');
|
|
1317
|
+
const note = mk('ctl', 'input');
|
|
1318
|
+
note.type = 'text';
|
|
1319
|
+
note.placeholder = 'Note for Claude (optional): intent, constraints, anything the values do not say';
|
|
1320
|
+
note.value = trayNoteValue;
|
|
1321
|
+
note.setAttribute('data-cdm-field', '');
|
|
1322
|
+
note.addEventListener('input', () => { trayNoteValue = note.value; });
|
|
1323
|
+
note.addEventListener('keydown', (e) => {
|
|
1324
|
+
e.stopPropagation();
|
|
1325
|
+
if (e.key === 'Enter') { e.preventDefault(); confirm.click(); }
|
|
1326
|
+
if (e.key === 'Escape') { e.preventDefault(); closeCommitModal(); }
|
|
1327
|
+
});
|
|
1328
|
+
const foot = mk('modal-foot');
|
|
1329
|
+
const scope = selectInput({
|
|
1330
|
+
options: SCOPES.map((sc) => ({ value: sc.value, label: `Scope: ${sc.label}`, title: sc.title })),
|
|
1331
|
+
current: trayScopeValue,
|
|
1332
|
+
onPick: (v) => { trayScopeValue = v; },
|
|
1333
|
+
container: box_,
|
|
1334
|
+
});
|
|
1335
|
+
const cancel = mk('btn ghost', 'button');
|
|
1336
|
+
cancel.textContent = 'Cancel';
|
|
1337
|
+
cancel.addEventListener('click', closeCommitModal);
|
|
1338
|
+
const confirm = mk('btn primary', 'button');
|
|
1339
|
+
confirm.textContent = 'Send to Claude';
|
|
1340
|
+
confirm.disabled = !n;
|
|
1341
|
+
confirm.addEventListener('click', () => {
|
|
1342
|
+
if (!pendingCount()) return;
|
|
1343
|
+
commitChanges(trayNoteValue.trim(), trayScopeValue);
|
|
1344
|
+
trayNoteValue = '';
|
|
1345
|
+
closeCommitModal();
|
|
1346
|
+
});
|
|
1347
|
+
foot.append(scope, cancel, confirm);
|
|
1348
|
+
body.append(note, foot);
|
|
1349
|
+
box_.append(body);
|
|
1350
|
+
setTimeout(() => note.focus(), 0);
|
|
1351
|
+
};
|
|
1352
|
+
render();
|
|
1353
|
+
bg.addEventListener('click', (e) => { if (e.target === bg) closeCommitModal(); });
|
|
1354
|
+
bg.append(box_);
|
|
1355
|
+
shadow.append(bg);
|
|
1356
|
+
modal = bg;
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
/* ----------------------------------------------------------- controls --- */
|
|
1360
|
+
|
|
1361
|
+
// One in-panel dropdown at a time, drawn by the overlay (native <select>/<datalist>
|
|
1362
|
+
// popups render outside the page and drift in scaled/embedded viewports).
|
|
1363
|
+
let dd = null;
|
|
1364
|
+
const closeDropdown = () => { if (dd) { dd.el.remove(); dd = null; } };
|
|
1365
|
+
const openDropdown = ({ anchor, items, current, onPick, container = panel, emptyText = 'No match. Enter keeps what you typed' }) => {
|
|
1366
|
+
closeDropdown();
|
|
1367
|
+
const el = mk('dd ui');
|
|
1368
|
+
el.addEventListener('mousedown', (e) => e.preventDefault()); // keep the field focused
|
|
1369
|
+
const d = { el, anchor, items, shown: items, hl: -1, filter: '', navigated: false };
|
|
1370
|
+
const place = () => {
|
|
1371
|
+
const c = container.getBoundingClientRect();
|
|
1372
|
+
const a = anchor.getBoundingClientRect();
|
|
1373
|
+
const innerW = c.width - 16;
|
|
1374
|
+
const width = Math.min(innerW, Math.max(a.width, 276));
|
|
1375
|
+
let left = a.left - c.left;
|
|
1376
|
+
if (left + width > c.width - 8) left = Math.max(8, c.width - 8 - width);
|
|
1377
|
+
const wanted = Math.min(260, d.shown.length * 27 + 10);
|
|
1378
|
+
const below = c.bottom - a.bottom - 12;
|
|
1379
|
+
const above = a.top - c.top - 12;
|
|
1380
|
+
const up = below < Math.min(wanted, 140) && above > below;
|
|
1381
|
+
const maxH = Math.max(80, Math.min(wanted, up ? above : below));
|
|
1382
|
+
el.style.left = `${left}px`;
|
|
1383
|
+
el.style.width = `${width}px`;
|
|
1384
|
+
el.style.maxHeight = `${maxH}px`;
|
|
1385
|
+
if (up) { el.style.bottom = `${c.bottom - a.top + 4}px`; el.style.top = 'auto'; }
|
|
1386
|
+
else { el.style.top = `${a.bottom - c.top + 4}px`; el.style.bottom = 'auto'; }
|
|
1387
|
+
};
|
|
1388
|
+
const render = () => {
|
|
1389
|
+
el.innerHTML = '';
|
|
1390
|
+
if (!d.shown.length) { const e = mk('empty'); e.textContent = emptyText; el.append(e); return; }
|
|
1391
|
+
d.shown.forEach((it, i) => {
|
|
1392
|
+
const b = mk('it' + (i === d.hl ? ' hl' : '') + (it.value === current ? ' cur' : ''), 'button');
|
|
1393
|
+
b.type = 'button';
|
|
1394
|
+
if (it.swatch) { const sw = mk('sw', 'span'); sw.style.background = it.swatch; b.append(sw); }
|
|
1395
|
+
const lab = mk('lab', 'span'); lab.textContent = it.label; b.append(lab);
|
|
1396
|
+
if (it.primitive) { const pv = mk('pv', 'span'); pv.textContent = it.primitive; pv.title = it.primitive; b.append(pv); }
|
|
1397
|
+
b.title = it.primitive ? `${it.label} · ${it.primitive}` : it.label;
|
|
1398
|
+
b.addEventListener('click', () => onPick(it));
|
|
1399
|
+
b.addEventListener('mousemove', () => { if (d.hl !== i) { d.hl = i; d.navigated = true; [...el.children].forEach((c, k) => c.classList.toggle('hl', k === i)); } });
|
|
1400
|
+
el.append(b);
|
|
1401
|
+
});
|
|
1402
|
+
};
|
|
1403
|
+
const reveal = () => {
|
|
1404
|
+
const row = el.children[d.hl];
|
|
1405
|
+
if (!row) return;
|
|
1406
|
+
const top = row.offsetTop, bottom = top + row.offsetHeight;
|
|
1407
|
+
if (top < el.scrollTop) el.scrollTop = top - 4;
|
|
1408
|
+
else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight + 4;
|
|
1409
|
+
};
|
|
1410
|
+
// Highlight means "Enter picks this". With no filter it sits on the current value; while
|
|
1411
|
+
// typing it only lands on an exact name match or the single remaining match, so Enter on
|
|
1412
|
+
// free text applies what was typed instead of a lookalike token. Arrows/hover move it.
|
|
1413
|
+
d.setFilter = (f) => {
|
|
1414
|
+
d.filter = f.toLowerCase();
|
|
1415
|
+
d.navigated = false;
|
|
1416
|
+
d.shown = d.filter ? items.filter((it) => it.label.toLowerCase().includes(d.filter) || (it.primitive || '').toLowerCase().includes(d.filter)) : items;
|
|
1417
|
+
if (!d.filter) d.hl = Math.max(0, d.shown.findIndex((it) => it.value === current));
|
|
1418
|
+
else {
|
|
1419
|
+
const exact = d.shown.findIndex((it) => it.label.toLowerCase() === d.filter || String(it.value).toLowerCase() === d.filter);
|
|
1420
|
+
d.hl = exact >= 0 ? exact : d.shown.length === 1 ? 0 : -1;
|
|
1421
|
+
}
|
|
1422
|
+
render(); place(); reveal();
|
|
1423
|
+
};
|
|
1424
|
+
d.move = (delta) => {
|
|
1425
|
+
if (!d.shown.length) return;
|
|
1426
|
+
d.navigated = true;
|
|
1427
|
+
d.hl = d.hl < 0 ? (delta > 0 ? 0 : d.shown.length - 1) : (d.hl + delta + d.shown.length) % d.shown.length;
|
|
1428
|
+
[...el.children].forEach((c, k) => c.classList.toggle('hl', k === d.hl));
|
|
1429
|
+
reveal();
|
|
1430
|
+
};
|
|
1431
|
+
d.pickHighlighted = () => { const it = d.shown[d.hl]; if (!it) return false; onPick(it); return true; };
|
|
1432
|
+
d.place = place;
|
|
1433
|
+
container.append(el);
|
|
1434
|
+
dd = d;
|
|
1435
|
+
d.setFilter('');
|
|
1436
|
+
return d;
|
|
1437
|
+
};
|
|
1438
|
+
// the field moves when the panel scrolls (focus can scroll it into view): follow it,
|
|
1439
|
+
// and only let go once the field has left the visible area
|
|
1440
|
+
shadow.addEventListener('focusout', (e) => {
|
|
1441
|
+
if (!dd || e.target !== dd.anchor) return;
|
|
1442
|
+
const to = e.relatedTarget;
|
|
1443
|
+
if (to && dd.el.contains(to)) return;
|
|
1444
|
+
setTimeout(() => { if (dd && dd.anchor === e.target && shadow.activeElement !== e.target) closeDropdown(); }, 0);
|
|
1445
|
+
});
|
|
1446
|
+
panelScroll.addEventListener('scroll', () => {
|
|
1447
|
+
if (!dd || !panel.contains(dd.el)) return;
|
|
1448
|
+
const ar = dd.anchor.getBoundingClientRect();
|
|
1449
|
+
const sr = panelScroll.getBoundingClientRect();
|
|
1450
|
+
if (ar.bottom < sr.top || ar.top > sr.bottom) closeDropdown();
|
|
1451
|
+
else dd.place();
|
|
1452
|
+
}, { passive: true });
|
|
1453
|
+
|
|
1454
|
+
const hasPending = (prop) => {
|
|
1455
|
+
const m = state.selectedEl && state.pending.get(state.selectedEl);
|
|
1456
|
+
if (!m) return false;
|
|
1457
|
+
if (m.has(prop)) return true;
|
|
1458
|
+
for (const c of m.values()) if ((c.companions || []).some((cp) => cp.prop === prop)) return true;
|
|
1459
|
+
return false;
|
|
1460
|
+
};
|
|
1461
|
+
// the changed dot beside a label: present exactly when the row has a pending change
|
|
1462
|
+
const markDot = (l) => {
|
|
1463
|
+
const mod = l.classList.contains('mod');
|
|
1464
|
+
let d = l.querySelector(':scope > .dot');
|
|
1465
|
+
if (mod && !d) { d = mk('dot', 'span'); d.title = 'Changed'; l.append(d); }
|
|
1466
|
+
if (!mod && d) d.remove();
|
|
1467
|
+
};
|
|
1468
|
+
const refreshModMarks = () => {
|
|
1469
|
+
panelScroll.querySelectorAll('[data-props]').forEach((l) => {
|
|
1470
|
+
const mod = l.dataset.props.split(' ').some(hasPending);
|
|
1471
|
+
l.classList.toggle('mod', mod);
|
|
1472
|
+
if (l.classList.contains('lbl')) { l.title = (mod ? 'Changed · ' : '') + 'Double-click to reset'; markDot(l); }
|
|
1473
|
+
else if (l.classList.contains('bm-l')) { l.title = (mod ? 'Changed · ' : '') + `Double-click to reset ${l.textContent.toLowerCase()}`; markDot(l); }
|
|
1474
|
+
});
|
|
1475
|
+
panelScroll.querySelectorAll('.bx[data-prop]').forEach((i) => i.classList.toggle('mod', hasPending(i.dataset.prop)));
|
|
1476
|
+
};
|
|
1477
|
+
const revertProps = (props) => {
|
|
1478
|
+
const elx = state.selectedEl;
|
|
1479
|
+
const map = elx && state.pending.get(elx);
|
|
1480
|
+
if (!map) return;
|
|
1481
|
+
let n = 0;
|
|
1482
|
+
for (const prop of props) {
|
|
1483
|
+
if (map.has(prop)) { liftOverride(elx, map.get(prop)); map.delete(prop); n++; continue; }
|
|
1484
|
+
for (const [owner, c] of map) if ((c.companions || []).some((cp) => cp.prop === prop)) { liftOverride(elx, c); map.delete(owner); n++; break; }
|
|
1485
|
+
}
|
|
1486
|
+
if (!n) return;
|
|
1487
|
+
if (!map.size) state.pending.delete(elx);
|
|
1488
|
+
renderTray();
|
|
1489
|
+
renderPanel(elx, true);
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// opts.props: the CSS props this row edits; double-click the label to reset them,
|
|
1493
|
+
// and the label tints while any of them has a pending change
|
|
1494
|
+
// hardcoded marker: a literal with no token behind it
|
|
1495
|
+
const HC_SVG = '<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M5 1.5 9 8.5H1z"/><path d="M5 4v2.2" stroke-linecap="round"/></svg>';
|
|
1496
|
+
const hcGlyph = (title = 'Hardcoded value (no token behind it)') => { const g = mk('hc', 'span'); g.innerHTML = HC_SVG; g.title = title; g.setAttribute('aria-label', 'hardcoded'); return g; };
|
|
1497
|
+
const HC_HTML = (title = 'hardcoded value') => `<span class="hc" title="${title}">${HC_SVG}</span>`;
|
|
1498
|
+
|
|
1499
|
+
const row = (label, control, opts = {}) => {
|
|
1500
|
+
const r = mk('row');
|
|
1501
|
+
const l = mk('lbl');
|
|
1502
|
+
l.textContent = label;
|
|
1503
|
+
if (opts.tip) r.title = opts.tip;
|
|
1504
|
+
if (opts.props && opts.props.length) {
|
|
1505
|
+
l.dataset.props = opts.props.join(' ');
|
|
1506
|
+
const mod = opts.props.some(hasPending);
|
|
1507
|
+
if (mod) l.classList.add('mod');
|
|
1508
|
+
l.title = (mod ? 'Changed · ' : '') + 'Double-click to reset';
|
|
1509
|
+
l.addEventListener('dblclick', (e) => { e.preventDefault(); revertProps(opts.props); });
|
|
1510
|
+
}
|
|
1511
|
+
if (opts.hardcoded) l.append(hcGlyph());
|
|
1512
|
+
markDot(l);
|
|
1513
|
+
r.append(l, control);
|
|
1514
|
+
return r;
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
const ICON = {
|
|
1518
|
+
block: '<rect x="2" y="4" width="12" height="8" rx="1"/>',
|
|
1519
|
+
'inline-block': '<rect x="2" y="5" width="7" height="6" rx="1"/><path d="M11 6h3M11 8h3M11 10h3" stroke="currentColor" stroke-width="1.3" fill="none"/>',
|
|
1520
|
+
flex: '<rect x="2" y="3" width="3" height="10" rx="0.5"/><rect x="6.5" y="3" width="3" height="10" rx="0.5"/><rect x="11" y="3" width="3" height="10" rx="0.5"/>',
|
|
1521
|
+
grid: '<rect x="2" y="2" width="5" height="5" rx="0.5"/><rect x="9" y="2" width="5" height="5" rx="0.5"/><rect x="2" y="9" width="5" height="5" rx="0.5"/><rect x="9" y="9" width="5" height="5" rx="0.5"/>',
|
|
1522
|
+
none: '<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M4 4l8 8" stroke="currentColor" stroke-width="1.3"/>',
|
|
1523
|
+
row: '<path d="M2 8h11M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
1524
|
+
column: '<path d="M8 2v11M4 9l4 4 4-4" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
1525
|
+
'a-start': '<path d="M2 2.5h12" stroke="currentColor" stroke-width="1.3"/><rect x="4" y="4.5" width="3" height="6" rx="0.5"/><rect x="9" y="4.5" width="3" height="9" rx="0.5"/>',
|
|
1526
|
+
'a-center': '<path d="M2 8h12" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1.5"/><rect x="4" y="5" width="3" height="6" rx="0.5"/><rect x="9" y="3.5" width="3" height="9" rx="0.5"/>',
|
|
1527
|
+
'a-end': '<path d="M2 13.5h12" stroke="currentColor" stroke-width="1.3"/><rect x="4" y="5.5" width="3" height="6" rx="0.5"/><rect x="9" y="2.5" width="3" height="9" rx="0.5"/>',
|
|
1528
|
+
'a-stretch': '<path d="M2 2.5h12M2 13.5h12" stroke="currentColor" stroke-width="1.3"/><rect x="4" y="4.5" width="3" height="7" rx="0.5"/><rect x="9" y="4.5" width="3" height="7" rx="0.5"/>',
|
|
1529
|
+
'a-baseline': '<path d="M2 10.5h12" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1.5"/><rect x="4" y="5" width="3" height="5.5" rx="0.5"/><rect x="9" y="3" width="3" height="7.5" rx="0.5"/>',
|
|
1530
|
+
'j-start': '<path d="M2.5 2v12" stroke="currentColor" stroke-width="1.3"/><rect x="4.5" y="4" width="3" height="8" rx="0.5"/><rect x="8.5" y="4" width="3" height="8" rx="0.5"/>',
|
|
1531
|
+
'j-center': '<path d="M8 2v12" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1.5"/><rect x="3" y="4" width="3" height="8" rx="0.5"/><rect x="10" y="4" width="3" height="8" rx="0.5"/>',
|
|
1532
|
+
'j-end': '<path d="M13.5 2v12" stroke="currentColor" stroke-width="1.3"/><rect x="4.5" y="4" width="3" height="8" rx="0.5"/><rect x="8.5" y="4" width="3" height="8" rx="0.5"/>',
|
|
1533
|
+
'j-between': '<path d="M2 2v12M14 2v12" stroke="currentColor" stroke-width="1.3"/><rect x="3.5" y="4" width="3" height="8" rx="0.5"/><rect x="9.5" y="4" width="3" height="8" rx="0.5"/>',
|
|
1534
|
+
'j-around': '<path d="M2 2v12M14 2v12" stroke="currentColor" stroke-width="1.3"/><rect x="4.75" y="4" width="2.5" height="8" rx="0.5"/><rect x="8.75" y="4" width="2.5" height="8" rx="0.5"/>',
|
|
1535
|
+
't-left': '<path d="M2 4h12M2 7h8M2 10h12M2 13h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
|
|
1536
|
+
't-center': '<path d="M2 4h12M4 7h8M2 10h12M4 13h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
|
|
1537
|
+
't-right': '<path d="M2 4h12M6 7h8M2 10h12M6 13h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
|
|
1538
|
+
't-justify': '<path d="M2 4h12M2 7h12M2 10h12M2 13h12" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
|
|
1539
|
+
};
|
|
1540
|
+
const svg = (name) => `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">${ICON[name] || ''}</svg>`;
|
|
1541
|
+
|
|
1542
|
+
// Icon segmented control (Webflow-style): options [{ value, icon, title }]
|
|
1543
|
+
const segmented = ({ prop, options, current, system = true, map }) => {
|
|
1544
|
+
const seg = mk('seg');
|
|
1545
|
+
seg.setAttribute('data-cdm-field', '');
|
|
1546
|
+
let cur = (map && map[current]) || current;
|
|
1547
|
+
const paint = () => seg.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.v === cur));
|
|
1548
|
+
const opts = options.slice();
|
|
1549
|
+
if (!opts.some((o) => o.value === cur)) opts.push({ value: cur, label: cur, text: true }); // e.g. display: inline, table, contents
|
|
1550
|
+
for (const o of opts) {
|
|
1551
|
+
const b = mk(o.text ? 'txt' : '', 'button');
|
|
1552
|
+
b.type = 'button';
|
|
1553
|
+
b.dataset.v = o.value;
|
|
1554
|
+
if (o.text) b.textContent = o.label; else b.innerHTML = svg(o.icon);
|
|
1555
|
+
b.title = `${prop}: ${o.value}${o.title ? ' · ' + o.title : ''}`;
|
|
1556
|
+
b.addEventListener('click', () => {
|
|
1557
|
+
if (o.value === cur) return;
|
|
1558
|
+
cur = o.value;
|
|
1559
|
+
paint();
|
|
1560
|
+
applyPreview(prop, o.value, { label: o.value, primitive: o.value, system });
|
|
1561
|
+
});
|
|
1562
|
+
seg.append(b);
|
|
1563
|
+
}
|
|
1564
|
+
paint();
|
|
1565
|
+
return seg;
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
// Click-and-drag scrubbing on a value field. A press without movement is a click
|
|
1569
|
+
// (onClick: focus + reveal options); movement scrubs: onDelta(steps) gets the whole
|
|
1570
|
+
// offset since the press in steps of `step` screen px (Shift multiplies by 4).
|
|
1571
|
+
// A field that already has focus keeps native caret/selection behavior.
|
|
1572
|
+
const scrub = (inp, { step = 1, onDelta, onClick, onEnd }) => {
|
|
1573
|
+
let st = null;
|
|
1574
|
+
inp.addEventListener('pointerdown', (e) => {
|
|
1575
|
+
if (e.button !== 0 || shadow.activeElement === inp) return;
|
|
1576
|
+
e.preventDefault();
|
|
1577
|
+
st = { x: e.clientX, moved: false, id: e.pointerId, last: 0 };
|
|
1578
|
+
try { inp.setPointerCapture(e.pointerId); } catch { /* fine */ }
|
|
1579
|
+
});
|
|
1580
|
+
inp.addEventListener('mousedown', (e) => { if (st) e.preventDefault(); });
|
|
1581
|
+
inp.addEventListener('pointermove', (e) => {
|
|
1582
|
+
if (!st || e.pointerId !== st.id) return;
|
|
1583
|
+
const dx = e.clientX - st.x;
|
|
1584
|
+
if (!st.moved && Math.abs(dx) > 3) { st.moved = true; inp.classList.add('scrubbing'); panel.classList.add('scrubbing'); closeDropdown(); }
|
|
1585
|
+
if (!st.moved) return;
|
|
1586
|
+
const steps = Math.trunc(dx / step) * (e.shiftKey ? 4 : 1);
|
|
1587
|
+
if (steps !== st.last) { st.last = steps; onDelta(steps); }
|
|
1588
|
+
});
|
|
1589
|
+
const end = (e) => {
|
|
1590
|
+
if (!st || (e && e.pointerId !== st.id)) return;
|
|
1591
|
+
const { moved } = st;
|
|
1592
|
+
const st_last = st.last;
|
|
1593
|
+
st = null;
|
|
1594
|
+
inp.classList.remove('scrubbing');
|
|
1595
|
+
panel.classList.remove('scrubbing');
|
|
1596
|
+
if (moved && st_last !== 0) { if (onEnd) onEnd(); } else if (onClick) onClick();
|
|
1597
|
+
};
|
|
1598
|
+
inp.addEventListener('pointerup', end);
|
|
1599
|
+
inp.addEventListener('pointercancel', end);
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
// Spacing is written three ways depending on what the app defines:
|
|
1603
|
+
// base a Tailwind-style unit (--spacing: 0.25rem) → calc(var(--spacing) * n), quarter-unit snapping
|
|
1604
|
+
// tokens the app's own spacing tokens (--space-4: 1rem, ...) → var(--space-4) when one matches, else a px literal
|
|
1605
|
+
// px nothing to lean on → plain px
|
|
1606
|
+
const spacingMode = () => (spacingBasePx() ? 'base' : spacingTokens().length ? 'tokens' : 'px');
|
|
1607
|
+
// Tailwind's conventional spacing scale, offered as options when a base unit exists
|
|
1608
|
+
const SPACING_SCALE = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96];
|
|
1609
|
+
const PX_SCALE = [0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48, 56, 64, 80, 96, 128];
|
|
1610
|
+
const spacingItems = () => {
|
|
1611
|
+
const mode = spacingMode();
|
|
1612
|
+
if (mode === 'base') { const b = spacingBasePx(); return SPACING_SCALE.map((n) => ({ value: n * b, label: `spacing × ${n}`, primitive: `${Math.round(n * b)}px` })); }
|
|
1613
|
+
if (mode === 'tokens') return spacingTokens().map((t) => ({ value: t.px, label: t.name.replace(/^--/, ''), primitive: `${Math.round(t.px)}px` }));
|
|
1614
|
+
return PX_SCALE.map((v) => ({ value: v, label: `${v}px` }));
|
|
1615
|
+
};
|
|
1616
|
+
const openNumericOptions = (inp, items, currentValue, onPick, emptyText) =>
|
|
1617
|
+
openDropdown({ anchor: inp, items, current: currentValue, emptyText, onPick: (it) => { onPick(it.value); closeDropdown(); inp.blur(); } });
|
|
1618
|
+
// what Enter does with a px value that matches no preset, in the app's own terms
|
|
1619
|
+
const spacingEmptyText = () => {
|
|
1620
|
+
const mode = spacingMode();
|
|
1621
|
+
if (mode === 'base') return 'No preset. Enter keeps the value, snapped to the spacing scale';
|
|
1622
|
+
if (mode === 'tokens') return 'No spacing token at that size. Enter keeps the px value (hardcoded)';
|
|
1623
|
+
return 'No preset. Enter keeps the px value';
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
// px-valued field that previews in the app's spacing vocabulary
|
|
1627
|
+
const toUnits = (px) => { const b = spacingBasePx(); return b ? Math.round((px / b) * 4) / 4 : Math.round(px); };
|
|
1628
|
+
const spacingTokenAt = (px) => spacingTokens().find((t) => Math.abs(t.px - px) < 0.5) || null;
|
|
1629
|
+
// the next spacing token above (dir>0) or below (dir<0) a px value; null when off the ends
|
|
1630
|
+
const spacingTokenNext = (px, dir) => {
|
|
1631
|
+
const scale = [...new Set(spacingTokens().map((t) => Math.round(t.px)))].sort((a, b) => a - b);
|
|
1632
|
+
return dir > 0 ? scale.find((v) => v > px + 0.5) ?? null : [...scale].reverse().find((v) => v < px - 0.5) ?? null;
|
|
1633
|
+
};
|
|
1634
|
+
// the px the preview will actually produce for a requested px
|
|
1635
|
+
const normPx = (px) => { const b = spacingBasePx(); return b ? Math.round(toUnits(px) * b) : Math.round(px); };
|
|
1636
|
+
// short hint beside a px value: "×4" with a base unit, "space-4" when a token matches, nothing otherwise
|
|
1637
|
+
const spacingHint = (px) => {
|
|
1638
|
+
const mode = spacingMode();
|
|
1639
|
+
if (mode === 'base') return `×${toUnits(px)}`;
|
|
1640
|
+
if (mode === 'tokens') { const t = spacingTokenAt(px); return t ? t.name.replace(/^--/, '') : ''; }
|
|
1641
|
+
return '';
|
|
1642
|
+
};
|
|
1643
|
+
const previewSpacingPx = (prop, px) => {
|
|
1644
|
+
const mode = spacingMode();
|
|
1645
|
+
if (mode === 'base') {
|
|
1646
|
+
const n = toUnits(px);
|
|
1647
|
+
applyPreview(prop, `calc(var(--spacing) * ${n})`, { label: `spacing × ${n}`, primitive: `${Math.round(n * spacingBasePx())}px`, system: true });
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
const r = Math.round(px);
|
|
1651
|
+
const t = mode === 'tokens' ? spacingTokenAt(r) : null;
|
|
1652
|
+
if (t) applyPreview(prop, `var(${t.name})`, { token: t.name, primitive: `${Math.round(t.px)}px` });
|
|
1653
|
+
else applyPreview(prop, `${r}px`, { primitive: `${r}px`, system: mode === 'px' }); // with tokens around, an off-scale px is hardcoded
|
|
1654
|
+
};
|
|
1655
|
+
const sideTrace = (prop) => {
|
|
1656
|
+
const T = state.traces;
|
|
1657
|
+
const [fam, side] = prop.split('-');
|
|
1658
|
+
const axis = side === 'top' || side === 'bottom' ? `${fam}-block` : `${fam}-inline`;
|
|
1659
|
+
return T[prop] || T[axis] || T[fam] || null;
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
// Box-model diagram (Webflow-style): margin ring, padding ring, element size in the middle.
|
|
1663
|
+
const boxModel = (cs, rect) => {
|
|
1664
|
+
const SIDES = ['top', 'right', 'bottom', 'left'];
|
|
1665
|
+
const bm = mk('bm');
|
|
1666
|
+
const field = (prop, cls, allowNegative) => {
|
|
1667
|
+
const px = Math.round(parseFloat(cs.getPropertyValue(prop)) || 0);
|
|
1668
|
+
const inp = mk(`bx ${cls}` + (hasPending(prop) ? ' mod' : ''), 'input');
|
|
1669
|
+
inp.dataset.prop = prop;
|
|
1670
|
+
inp.type = 'text';
|
|
1671
|
+
inp.inputMode = 'numeric';
|
|
1672
|
+
inp.value = String(px);
|
|
1673
|
+
inp.setAttribute('data-cdm-field', '');
|
|
1674
|
+
const t = sideTrace(prop);
|
|
1675
|
+
const hint = spacingHint(px);
|
|
1676
|
+
inp.title = `${prop}: ${px}px${hint ? ' = ' + hint : ''}${t ? ' · ' + tipFor(t) : ''}`;
|
|
1677
|
+
const startPx = () => Math.round(parseFloat(getComputedStyle(state.selectedEl).getPropertyValue(prop)) || 0);
|
|
1678
|
+
const reset = () => { inp.value = String(startPx()); }; // the value the page has right now
|
|
1679
|
+
const setPx = (v) => {
|
|
1680
|
+
const vv = allowNegative ? v : Math.max(0, v);
|
|
1681
|
+
const fam = prop.split('-')[0];
|
|
1682
|
+
const targets = state.linkSides ? SIDES.map((sd) => `${fam}-${sd}`) : [prop];
|
|
1683
|
+
for (const tp of targets) {
|
|
1684
|
+
previewSpacingPx(tp, vv);
|
|
1685
|
+
const f = tp === prop ? inp : bm.querySelector(`.bx[data-prop="${tp}"]`);
|
|
1686
|
+
if (f) { f.value = String(normPx(vv)); f.classList.add('mod'); }
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
let scrubBase = 0;
|
|
1690
|
+
scrub(inp, {
|
|
1691
|
+
step: 1,
|
|
1692
|
+
onDelta: (steps) => setPx(scrubBase + steps),
|
|
1693
|
+
onClick: () => { inp.focus(); inp.select(); openNumericOptions(inp, spacingItems(), Math.round(parseFloat(inp.value) || 0), (v) => setPx(v), spacingEmptyText()); },
|
|
1694
|
+
});
|
|
1695
|
+
inp.addEventListener('pointerdown', () => { scrubBase = startPx(); }, true);
|
|
1696
|
+
dblclickReset(inp, prop);
|
|
1697
|
+
inp.addEventListener('input', () => { if (dd && dd.anchor === inp) dd.setFilter(inp.value); });
|
|
1698
|
+
numericKeys(inp, {
|
|
1699
|
+
reset,
|
|
1700
|
+
step: 1,
|
|
1701
|
+
bigStep: spacingBasePx() || 10,
|
|
1702
|
+
nudge: (by, big) => {
|
|
1703
|
+
const cur = parseFloat(inp.value) || 0;
|
|
1704
|
+
if (big && spacingMode() === 'tokens') { const nx = spacingTokenNext(cur, by); if (nx !== null) setPx(nx); return; } // Shift+arrow walks the token scale
|
|
1705
|
+
setPx(cur + by);
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
inp.addEventListener('change', () => {
|
|
1709
|
+
const parsed = parseFloat(inp.value);
|
|
1710
|
+
if (inp.value.trim() === '' || Number.isNaN(parsed)) { reset(); return; }
|
|
1711
|
+
setPx(parsed);
|
|
1712
|
+
});
|
|
1713
|
+
return inp;
|
|
1714
|
+
};
|
|
1715
|
+
const label = (text, props) => {
|
|
1716
|
+
const l = mk('bm-l' + (props.some(hasPending) ? ' mod' : ''), 'span');
|
|
1717
|
+
l.dataset.props = props.join(' ');
|
|
1718
|
+
l.textContent = text;
|
|
1719
|
+
l.title = (l.classList.contains('mod') ? 'Changed · ' : '') + 'Double-click to reset ' + text.toLowerCase();
|
|
1720
|
+
l.addEventListener('dblclick', (e) => { e.preventDefault(); revertProps(props); });
|
|
1721
|
+
markDot(l);
|
|
1722
|
+
return l;
|
|
1723
|
+
};
|
|
1724
|
+
const mProps = SIDES.map((sd) => `margin-${sd}`).concat(['margin', 'margin-inline', 'margin-block']);
|
|
1725
|
+
const pProps = SIDES.map((sd) => `padding-${sd}`).concat(['padding', 'padding-inline', 'padding-block']);
|
|
1726
|
+
const diags = () => ['tl', 'tr', 'bl', 'br'].map((c) => mk(`diag ${c}`));
|
|
1727
|
+
bm.append(...diags(), label('Margin', mProps), ...SIDES.map((sd) => field(`margin-${sd}`, sd[0], true)));
|
|
1728
|
+
const bp = mk('bp');
|
|
1729
|
+
bp.append(...diags(), label('Padding', pProps), ...SIDES.map((sd) => field(`padding-${sd}`, sd[0], false)));
|
|
1730
|
+
const bc = mk('bc');
|
|
1731
|
+
bc.textContent = `${Math.round(rect.width)} × ${Math.round(rect.height)}`;
|
|
1732
|
+
bc.title = 'Rendered size';
|
|
1733
|
+
bp.append(bc);
|
|
1734
|
+
bm.append(bp);
|
|
1735
|
+
return bm;
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
// One keyboard contract for every numeric field (box model, Gap, Opacity):
|
|
1739
|
+
// Enter commits and closes the list · Escape restores the live value and closes · Tab closes
|
|
1740
|
+
// Up/Down nudge by `step` (Shift: `bigStep`) with an instant preview; while the list is
|
|
1741
|
+
// open and the user has arrowed into it, Up/Down move the highlight and Enter picks.
|
|
1742
|
+
const numericKeys = (inp, { reset, nudge, step = 1, bigStep = 10 }) => {
|
|
1743
|
+
inp.setAttribute('data-cdm-field', '');
|
|
1744
|
+
const mine = () => dd && dd.anchor === inp;
|
|
1745
|
+
inp.addEventListener('keydown', (e) => {
|
|
1746
|
+
e.stopPropagation();
|
|
1747
|
+
if (e.key === 'Enter') {
|
|
1748
|
+
e.preventDefault();
|
|
1749
|
+
if (mine() && dd.navigated && dd.hl >= 0 && dd.pickHighlighted()) return;
|
|
1750
|
+
closeDropdown();
|
|
1751
|
+
inp.dispatchEvent(new Event('change'));
|
|
1752
|
+
inp.blur();
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (e.key === 'Escape') { e.preventDefault(); reset(); closeDropdown(); inp.blur(); return; }
|
|
1756
|
+
if (e.key === 'Tab') { closeDropdown(); return; }
|
|
1757
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
1758
|
+
e.preventDefault();
|
|
1759
|
+
if (mine() && dd.navigated) { dd.move(e.key === 'ArrowDown' ? 1 : -1); return; }
|
|
1760
|
+
if (mine() && dd.filter && dd.shown.length) { dd.move(e.key === 'ArrowDown' ? 1 : -1); return; }
|
|
1761
|
+
const by = e.shiftKey ? bigStep : step;
|
|
1762
|
+
nudge(e.key === 'ArrowUp' ? by : -by, e.shiftKey);
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
};
|
|
1766
|
+
// Double-click resets a value field only when the pair of clicks started on an unfocused
|
|
1767
|
+
// field with a pending change; a field being edited keeps native double-click (select word).
|
|
1768
|
+
const dblclickReset = (inp, prop) => {
|
|
1769
|
+
let lastDown = 0;
|
|
1770
|
+
let pairFocused = false;
|
|
1771
|
+
inp.addEventListener('pointerdown', () => {
|
|
1772
|
+
const now = Date.now();
|
|
1773
|
+
if (now - lastDown > 450) pairFocused = shadow.activeElement === inp;
|
|
1774
|
+
lastDown = now;
|
|
1775
|
+
}, true);
|
|
1776
|
+
inp.addEventListener('dblclick', (e) => {
|
|
1777
|
+
if (pairFocused || !hasPending(prop)) return;
|
|
1778
|
+
e.preventDefault();
|
|
1779
|
+
closeDropdown();
|
|
1780
|
+
revertProps([prop]);
|
|
1781
|
+
});
|
|
1782
|
+
};
|
|
1783
|
+
const fieldKeys = (inp, reset) => numericKeys(inp, { reset, nudge: () => {} });
|
|
1784
|
+
|
|
1785
|
+
const tipFor = (t) => {
|
|
1786
|
+
if (!t) return 'Not set on this element (inherited or default)';
|
|
1787
|
+
const chain = t.chain.length ? t.chain.map((c) => c.name.replace(/^--/, '')).join(' → ') + ' → ' + primitiveOf(t) : '';
|
|
1788
|
+
const authored = t.chain.length && bareVar(t.authored) ? '' : t.authored; // a bare var() is already in the chain
|
|
1789
|
+
return [authored, t.from, chain].filter(Boolean).join(' · ');
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
const SCALE_KEYS = new Set(['fontSize', 'fontWeight', 'lineHeight', 'tracking', 'radius', 'shadow']);
|
|
1793
|
+
|
|
1794
|
+
// Token picker: a text field with an in-panel dropdown of the page's tokens of one
|
|
1795
|
+
// family (swatch + primitive shown). Picking or typing a token previews var(--token);
|
|
1796
|
+
// any other text previews the literal and flags it hardcoded.
|
|
1797
|
+
const tokenInput = ({ prop, key, swatch, special }) => {
|
|
1798
|
+
const t = state.traces[prop];
|
|
1799
|
+
const names = (tokenCatalog()[key] || []);
|
|
1800
|
+
const idx = customProps();
|
|
1801
|
+
const cs = getComputedStyle(state.selectedEl);
|
|
1802
|
+
let current = semanticName(t) || (t ? t.authored : '') || cs.getPropertyValue(prop).trim();
|
|
1803
|
+
if (current === 'rgba(0, 0, 0, 0)') current = 'transparent';
|
|
1804
|
+
if (swatch) current = rgbToHex(current);
|
|
1805
|
+
const borderless = prop === 'border-color' && noBorder(cs);
|
|
1806
|
+
if (borderless) current = 'none';
|
|
1807
|
+
const wrap = mk(swatch ? 'swatched' : '');
|
|
1808
|
+
let sw = null;
|
|
1809
|
+
if (swatch) { sw = mk('sw', 'span'); sw.style.background = borderless ? 'transparent' : cs.getPropertyValue(prop); wrap.append(sw); }
|
|
1810
|
+
// the primitive under a colour field is shown only when it says more than the field itself
|
|
1811
|
+
const norm = (v) => {
|
|
1812
|
+
const x = rgbToHex(String(v || '').toLowerCase().replace(/\s+/g, '').replace(/^rgba\(0,0,0,0\)$/, 'transparent'));
|
|
1813
|
+
const m = /^#(.)\1(.)\2(.)\3$/.exec(x);
|
|
1814
|
+
return m ? `#${m[1]}${m[2]}${m[3]}` : x;
|
|
1815
|
+
};
|
|
1816
|
+
const setPrim = (value) => {
|
|
1817
|
+
const el = wrap.querySelector('.prim');
|
|
1818
|
+
if (!el) return;
|
|
1819
|
+
const show = value && inp.value !== 'none' && norm(value) !== norm(inp.value) && norm(value) !== norm(inp.value.replace(/^--/, ''));
|
|
1820
|
+
el.textContent = show ? value : '';
|
|
1821
|
+
el.style.display = show ? '' : 'none';
|
|
1822
|
+
};
|
|
1823
|
+
const inp = mk('ctl', 'input');
|
|
1824
|
+
inp.type = 'text';
|
|
1825
|
+
inp.value = current;
|
|
1826
|
+
inp.title = borderless ? 'No border on this element' : tipFor(t);
|
|
1827
|
+
inp.autocomplete = 'off';
|
|
1828
|
+
inp.spellcheck = false;
|
|
1829
|
+
inp.setAttribute('data-cdm-field', '');
|
|
1830
|
+
const items = [
|
|
1831
|
+
...Object.entries(special || {}).map(([k, v]) => ({ value: k, label: k, primitive: v.css })),
|
|
1832
|
+
...names.map((n) => { const prim = resolveVar(n, state.selectedEl); return { value: n.replace(/^--/, ''), label: n.replace(/^--/, ''), primitive: prim, swatch: swatch ? prim : null }; }),
|
|
1833
|
+
];
|
|
1834
|
+
// scales list (and scrub) by value, not by name: xs, sm, base, lg, xl, 2xl...
|
|
1835
|
+
const num = (v) => { const m = /^(-?[\d.]+)(rem|em|px|%)?$/.exec(String(v || '').trim()); if (!m) return null; const n = parseFloat(m[1]); return m[2] === 'rem' || m[2] === 'em' ? n * rootFontPx() : n; };
|
|
1836
|
+
if (SCALE_KEYS.has(key)) {
|
|
1837
|
+
if (items.filter((it) => num(it.primitive) !== null).length >= 2) items.sort((a, b) => (num(a.primitive) ?? Infinity) - (num(b.primitive) ?? Infinity));
|
|
1838
|
+
}
|
|
1839
|
+
const commit = (rawIn) => {
|
|
1840
|
+
const raw = rawIn.trim();
|
|
1841
|
+
if (!raw || raw === current) { inp.value = current; return; }
|
|
1842
|
+
const name = raw.startsWith('--') ? raw : `--${raw}`;
|
|
1843
|
+
if (idx[name] !== undefined && tokenFits(key, name)) {
|
|
1844
|
+
const prim = resolveVar(name, state.selectedEl);
|
|
1845
|
+
applyPreview(prop, `var(${name})`, { token: name, primitive: prim });
|
|
1846
|
+
if (sw) sw.style.background = prim;
|
|
1847
|
+
inp.value = name.replace(/^--/, '');
|
|
1848
|
+
setPrim(prim);
|
|
1849
|
+
} else if (special && special[raw]) {
|
|
1850
|
+
applyPreview(prop, special[raw].css, { label: raw, primitive: special[raw].css, system: true });
|
|
1851
|
+
} else {
|
|
1852
|
+
// a literal: bare numbers get px where a length is expected; anything the browser
|
|
1853
|
+
// would reject is not recorded as a change
|
|
1854
|
+
const lit = /^-?\d+(\.\d+)?$/.test(raw) && LENGTH_PROPS.has(prop) ? `${raw}px` : raw;
|
|
1855
|
+
if (!supports(prop, lit)) { inp.value = current; inp.classList.add('bad'); setTimeout(() => inp.classList.remove('bad'), 600); return; }
|
|
1856
|
+
applyPreview(prop, lit, { primitive: lit });
|
|
1857
|
+
if (sw) sw.style.background = lit;
|
|
1858
|
+
setPrim(lit);
|
|
1859
|
+
inp.value = lit;
|
|
1860
|
+
}
|
|
1861
|
+
current = inp.value;
|
|
1862
|
+
};
|
|
1863
|
+
const mine = () => dd && dd.anchor === inp;
|
|
1864
|
+
const emptyText = `No matching token. Enter keeps what you typed${key === 'color' || key === 'fontFamily' || key === 'shadow' ? ' as a literal (hardcoded)' : ' (hardcoded)'}`;
|
|
1865
|
+
const open = () => openDropdown({ anchor: inp, items, current, emptyText, onPick: (it) => { inp.value = it.value; commit(it.value); closeDropdown(); inp.blur(); } });
|
|
1866
|
+
inp.addEventListener('focus', () => { if (!mine()) open(); });
|
|
1867
|
+
inp.addEventListener('click', () => { if (!mine()) open(); });
|
|
1868
|
+
dblclickReset(inp, prop);
|
|
1869
|
+
// ordered scales scrub: drag steps through the family (text-sm -> text-base -> text-lg)
|
|
1870
|
+
if (SCALE_KEYS.has(key) && items.length > 1) {
|
|
1871
|
+
inp.classList.add('scale');
|
|
1872
|
+
let base = 0;
|
|
1873
|
+
inp.addEventListener('pointerdown', () => {
|
|
1874
|
+
let i = items.findIndex((it) => it.value === current);
|
|
1875
|
+
if (i < 0) { // literal value: seed from the nearest token by size
|
|
1876
|
+
const n = num(cs.getPropertyValue(prop)); let best = 0; let bd = Infinity;
|
|
1877
|
+
items.forEach((it, k) => { const v = num(it.primitive); if (v !== null && n !== null && Math.abs(v - n) < bd) { bd = Math.abs(v - n); best = k; } });
|
|
1878
|
+
i = best;
|
|
1879
|
+
}
|
|
1880
|
+
base = i;
|
|
1881
|
+
}, true);
|
|
1882
|
+
scrub(inp, {
|
|
1883
|
+
step: 14,
|
|
1884
|
+
onDelta: (steps) => {
|
|
1885
|
+
const it = items[Math.min(items.length - 1, Math.max(0, base + steps))];
|
|
1886
|
+
if (it && it.value !== inp.value) { inp.value = it.value; commit(it.value); }
|
|
1887
|
+
},
|
|
1888
|
+
onClick: () => { inp.focus(); if (!mine()) open(); },
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
inp.addEventListener('input', () => { if (!mine()) open(); dd.setFilter(inp.value); });
|
|
1892
|
+
inp.addEventListener('change', () => commit(inp.value));
|
|
1893
|
+
inp.addEventListener('keydown', (e) => {
|
|
1894
|
+
e.stopPropagation();
|
|
1895
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!mine()) open(); dd.move(e.key === 'ArrowDown' ? 1 : -1); return; }
|
|
1896
|
+
if (e.key === 'Enter') { e.preventDefault(); if (mine() && dd.hl >= 0 && dd.shown[dd.hl] && dd.shown[dd.hl].value !== inp.value && dd.pickHighlighted()) return; commit(inp.value); closeDropdown(); inp.blur(); return; }
|
|
1897
|
+
if (e.key === 'Escape') { e.preventDefault(); inp.value = current; closeDropdown(); inp.blur(); return; }
|
|
1898
|
+
if (e.key === 'Tab') closeDropdown();
|
|
1899
|
+
});
|
|
1900
|
+
wrap.append(inp);
|
|
1901
|
+
if (swatch) {
|
|
1902
|
+
const prim = mk('prim');
|
|
1903
|
+
wrap.append(prim);
|
|
1904
|
+
setPrim(primitiveOf(t, cs.getPropertyValue(prop).trim()));
|
|
1905
|
+
}
|
|
1906
|
+
return { node: wrap, hardcoded: !!t && t.status === 'hardcoded', tip: tipFor(t) };
|
|
1907
|
+
};
|
|
1908
|
+
|
|
1909
|
+
// Select-like control: a button that opens the same in-panel dropdown.
|
|
1910
|
+
// options: strings or { value, label }. onPick overrides the default preview.
|
|
1911
|
+
const selectInput = ({ prop, options, current, system = true, onPick, container }) => {
|
|
1912
|
+
const opts = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
1913
|
+
if (!opts.some((o) => o.value === current)) opts.unshift({ value: current, label: current });
|
|
1914
|
+
const b = mk('ctl sel', 'button');
|
|
1915
|
+
b.type = 'button';
|
|
1916
|
+
b.setAttribute('data-cdm-field', '');
|
|
1917
|
+
const v = mk('v', 'span');
|
|
1918
|
+
const labelOf = (val) => (opts.find((o) => o.value === val) || { label: val }).label;
|
|
1919
|
+
v.textContent = labelOf(current);
|
|
1920
|
+
const ch = mk('chev', 'span');
|
|
1921
|
+
ch.textContent = '▼';
|
|
1922
|
+
b.append(v, ch);
|
|
1923
|
+
let cur = current;
|
|
1924
|
+
const pick = (it) => {
|
|
1925
|
+
cur = it.value;
|
|
1926
|
+
v.textContent = it.label;
|
|
1927
|
+
closeDropdown();
|
|
1928
|
+
if (onPick) onPick(it.value); else applyPreview(prop, it.value, { label: it.value, primitive: it.value, system });
|
|
1929
|
+
};
|
|
1930
|
+
const mine = () => dd && dd.anchor === b;
|
|
1931
|
+
const open = () => openDropdown({ anchor: b, items: opts, current: cur, onPick: pick, container });
|
|
1932
|
+
b.addEventListener('click', () => (mine() ? closeDropdown() : open()));
|
|
1933
|
+
b.addEventListener('keydown', (e) => {
|
|
1934
|
+
e.stopPropagation();
|
|
1935
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!mine()) open(); dd.move(e.key === 'ArrowDown' ? 1 : -1); }
|
|
1936
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (mine()) { if (!dd.pickHighlighted()) closeDropdown(); } else open(); }
|
|
1937
|
+
else if (e.key === 'Escape') { e.preventDefault(); if (mine()) closeDropdown(); else if (modal && modal.contains(b)) closeCommitModal(); else b.blur(); }
|
|
1938
|
+
else if (e.key === 'Tab') closeDropdown();
|
|
1939
|
+
});
|
|
1940
|
+
return b;
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
// Spacing field in px (what the references show); previews as spacing units under the hood.
|
|
1944
|
+
const spacingInput = ({ prop }) => {
|
|
1945
|
+
const cs = getComputedStyle(state.selectedEl);
|
|
1946
|
+
const raw = cs.getPropertyValue(`${prop}-start`) || cs.getPropertyValue(prop) || '0';
|
|
1947
|
+
const px = Math.round(parseFloat(raw) || 0);
|
|
1948
|
+
const wrap = mk('unit');
|
|
1949
|
+
const inp = mk('ctl num', 'input');
|
|
1950
|
+
inp.type = 'text';
|
|
1951
|
+
inp.inputMode = 'numeric';
|
|
1952
|
+
inp.value = String(px);
|
|
1953
|
+
const u = mk('u', 'span');
|
|
1954
|
+
const unitText = (v) => { const h = spacingHint(v); return h ? `px · ${h}` : 'px'; };
|
|
1955
|
+
u.textContent = unitText(px);
|
|
1956
|
+
const livePx = () => Math.round(parseFloat(getComputedStyle(state.selectedEl).getPropertyValue(`${prop}-start`) || getComputedStyle(state.selectedEl).getPropertyValue(prop)) || 0);
|
|
1957
|
+
const setPx = (vIn) => {
|
|
1958
|
+
const v = Math.max(0, vIn);
|
|
1959
|
+
previewSpacingPx(prop, v);
|
|
1960
|
+
inp.value = String(normPx(v));
|
|
1961
|
+
u.textContent = unitText(v);
|
|
1962
|
+
};
|
|
1963
|
+
numericKeys(inp, { reset: () => { inp.value = String(livePx()); u.textContent = unitText(livePx()); }, step: 1, bigStep: spacingBasePx() || 10, nudge: (by) => setPx((parseFloat(inp.value) || 0) + by) });
|
|
1964
|
+
let scrubBase = 0;
|
|
1965
|
+
inp.addEventListener('pointerdown', () => { scrubBase = Math.round(parseFloat(inp.value) || 0); }, true);
|
|
1966
|
+
scrub(inp, {
|
|
1967
|
+
step: 1,
|
|
1968
|
+
onDelta: (steps) => setPx(scrubBase + steps),
|
|
1969
|
+
onClick: () => { inp.focus(); inp.select(); openNumericOptions(inp, spacingItems(), Math.round(parseFloat(inp.value) || 0), setPx, spacingEmptyText()); },
|
|
1970
|
+
});
|
|
1971
|
+
dblclickReset(inp, prop);
|
|
1972
|
+
inp.addEventListener('input', () => { if (dd && dd.anchor === inp) dd.setFilter(inp.value); });
|
|
1973
|
+
inp.addEventListener('change', () => {
|
|
1974
|
+
const parsed = parseFloat(inp.value);
|
|
1975
|
+
if (inp.value.trim() === '' || Number.isNaN(parsed)) { inp.value = String(livePx()); return; } // blank or junk: leave it alone
|
|
1976
|
+
setPx(parsed);
|
|
1977
|
+
});
|
|
1978
|
+
wrap.append(inp, u);
|
|
1979
|
+
const t = state.traces[prop];
|
|
1980
|
+
return { node: wrap, hardcoded: !!t && t.status === 'hardcoded', tip: tipFor(t) };
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
const section = (title, rows, actions = []) => {
|
|
1984
|
+
const s = mk('sec' + (state.collapsed.has(title) ? ' closed' : ''));
|
|
1985
|
+
const h = mk('sec-h');
|
|
1986
|
+
h.innerHTML = `<span>${esc(title)}</span><span class="chev">▼</span>`;
|
|
1987
|
+
if (actions.length) {
|
|
1988
|
+
const acts = mk('acts', 'span');
|
|
1989
|
+
actions.forEach((a) => { a.addEventListener('click', (e) => e.stopPropagation()); acts.append(a); });
|
|
1990
|
+
h.insertBefore(acts, h.querySelector('.chev'));
|
|
1991
|
+
}
|
|
1992
|
+
h.addEventListener('click', () => {
|
|
1993
|
+
s.classList.toggle('closed');
|
|
1994
|
+
if (s.classList.contains('closed')) state.collapsed.add(title); else state.collapsed.delete(title);
|
|
1995
|
+
});
|
|
1996
|
+
const body = mk('sec-body');
|
|
1997
|
+
rows.filter(Boolean).forEach((r) => body.append(r));
|
|
1998
|
+
s.append(h, body);
|
|
1999
|
+
return s;
|
|
2000
|
+
};
|
|
2001
|
+
|
|
2002
|
+
/* ------------------------------------------------------- ask claude --- */
|
|
2003
|
+
|
|
2004
|
+
const liveTarget = () => {
|
|
2005
|
+
const target = state.selectedEl;
|
|
2006
|
+
if (!target) return null;
|
|
2007
|
+
if (target.isConnected) return target;
|
|
2008
|
+
const stampSel = target.getAttribute('data-claude-source');
|
|
2009
|
+
const text = (target.textContent || '').trim();
|
|
2010
|
+
return stampSel
|
|
2011
|
+
? [...document.querySelectorAll(`[data-claude-source="${CSS.escape(stampSel)}"]`)].find((c) => (c.textContent || '').trim() === text) || null
|
|
2012
|
+
: null;
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
const sendPrompt = () => {
|
|
2016
|
+
const instruction = state.draft.trim();
|
|
2017
|
+
if (!instruction) { if (state.promptFocus) state.promptFocus(); return; }
|
|
2018
|
+
const live = liveTarget();
|
|
2019
|
+
if (!live) { closePrompt(); showToast('The page updated under that selection; select it again.', 5000); return; }
|
|
2020
|
+
const payload = buildPayload(live, instruction, state.draftScope);
|
|
2021
|
+
state.draft = '';
|
|
2022
|
+
if (state.promptReset) state.promptReset();
|
|
2023
|
+
deliver(payload);
|
|
2024
|
+
};
|
|
2025
|
+
|
|
2026
|
+
// Collapsed by default; Enter (with nothing focused) or a click on the header opens it.
|
|
2027
|
+
const promptSection = () => {
|
|
2028
|
+
const s = mk('sec prompt' + (state.promptExpanded ? '' : ' closed'));
|
|
2029
|
+
const h = mk('sec-h');
|
|
2030
|
+
h.innerHTML = '<span>Ask Claude</span><span class="kbd">↵ to open</span><span class="chev">▼</span>';
|
|
2031
|
+
const body = mk('sec-body');
|
|
2032
|
+
const ta = mk('ta', 'textarea');
|
|
2033
|
+
ta.rows = 2;
|
|
2034
|
+
ta.placeholder = 'Describe the change…';
|
|
2035
|
+
ta.value = state.draft;
|
|
2036
|
+
ta.setAttribute('data-cdm-field', '');
|
|
2037
|
+
ta.addEventListener('input', () => { state.draft = ta.value; });
|
|
2038
|
+
ta.addEventListener('keydown', (e) => {
|
|
2039
|
+
if (e.isComposing) return;
|
|
2040
|
+
e.stopPropagation();
|
|
2041
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendPrompt(); }
|
|
2042
|
+
if (e.key === 'Escape') { e.preventDefault(); ta.blur(); }
|
|
2043
|
+
});
|
|
2044
|
+
const scopes = mk('scopes');
|
|
2045
|
+
SCOPES.forEach((sc) => {
|
|
2046
|
+
const b = mk('scope' + (sc.value === state.draftScope ? ' on' : ''), 'button');
|
|
2047
|
+
b.textContent = sc.label;
|
|
2048
|
+
b.title = sc.title;
|
|
2049
|
+
b.setAttribute('aria-pressed', String(sc.value === state.draftScope));
|
|
2050
|
+
b.addEventListener('click', () => {
|
|
2051
|
+
state.draftScope = sc.value;
|
|
2052
|
+
scopes.querySelectorAll('.scope').forEach((x) => { x.classList.toggle('on', x === b); x.setAttribute('aria-pressed', String(x === b)); });
|
|
2053
|
+
});
|
|
2054
|
+
scopes.append(b);
|
|
2055
|
+
});
|
|
2056
|
+
const foot = mk('card-foot');
|
|
2057
|
+
const hint = mk('hint', 'span');
|
|
2058
|
+
hint.textContent = 'Enter to send · Shift+Enter newline';
|
|
2059
|
+
const send = mk('btn primary', 'button');
|
|
2060
|
+
send.textContent = 'Send to Claude';
|
|
2061
|
+
send.addEventListener('click', sendPrompt);
|
|
2062
|
+
foot.append(hint, send);
|
|
2063
|
+
body.append(ta, scopes, foot);
|
|
2064
|
+
const setOpen = (open) => {
|
|
2065
|
+
state.promptExpanded = open;
|
|
2066
|
+
s.classList.toggle('closed', !open);
|
|
2067
|
+
h.querySelector('.kbd').style.display = open ? 'none' : '';
|
|
2068
|
+
if (open) setTimeout(() => ta.focus(), 0);
|
|
2069
|
+
};
|
|
2070
|
+
h.addEventListener('click', () => setOpen(!state.promptExpanded));
|
|
2071
|
+
h.querySelector('.kbd').style.display = state.promptExpanded ? 'none' : '';
|
|
2072
|
+
state.promptOpenSection = () => setOpen(true);
|
|
2073
|
+
state.promptFocus = () => { setOpen(true); };
|
|
2074
|
+
state.promptReset = () => { ta.value = ''; };
|
|
2075
|
+
s.append(h, body);
|
|
2076
|
+
return s;
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
/* -------------------------------------------------------------- panel --- */
|
|
2080
|
+
|
|
2081
|
+
const DOCK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M10 3v10"/><path d="M4.5 8h3M6.5 6.5 8 8l-1.5 1.5"/></svg>';
|
|
2082
|
+
const PICK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2.5H3a.5.5 0 0 0-.5.5v3M10 2.5h3a.5.5 0 0 1 .5.5v3M6 13.5H3a.5.5 0 0 1-.5-.5v-3"/><path d="M8 8l5.5 2-2.4 1.1L10 13.5z" fill="currentColor" stroke="none"/></svg>';
|
|
2083
|
+
const CLOSE_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
|
2084
|
+
|
|
2085
|
+
// The hover inspector normally rests once something is selected; picking keeps it on
|
|
2086
|
+
// so the user can re-target the open sidebar with another click.
|
|
2087
|
+
const PICK_TITLE = {
|
|
2088
|
+
on: 'Picking: hover highlights, click selects another element. Click to go back to using the page',
|
|
2089
|
+
off: 'Pick another element: hover highlights, click selects, the sidebar stays open',
|
|
2090
|
+
};
|
|
2091
|
+
const setPicking = (on) => {
|
|
2092
|
+
state.picking = on;
|
|
2093
|
+
if (state.pickBtn) {
|
|
2094
|
+
state.pickBtn.classList.toggle('on', on);
|
|
2095
|
+
state.pickBtn.setAttribute('aria-pressed', String(on));
|
|
2096
|
+
state.pickBtn.title = PICK_TITLE[on ? 'on' : 'off'];
|
|
2097
|
+
}
|
|
2098
|
+
syncCursor();
|
|
2099
|
+
if (!on) {
|
|
2100
|
+
hi.style.display = 'none';
|
|
2101
|
+
hiLabel.style.display = 'none';
|
|
2102
|
+
state.hoverEl = null;
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
const renderPanel = (target, keepScroll = false) => {
|
|
2107
|
+
closeDropdown();
|
|
2108
|
+
const scrollTop = keepScroll ? panelScroll.scrollTop : 0;
|
|
2109
|
+
const fiber = findFiber(target);
|
|
2110
|
+
const chain = fiber ? componentChain(fiber) : [];
|
|
2111
|
+
const src = resolveSource(target);
|
|
2112
|
+
const ruleObjs = matchedRuleObjects(target);
|
|
2113
|
+
state.traces = tokenTrace(target, ruleObjs);
|
|
2114
|
+
const T = state.traces;
|
|
2115
|
+
const cs = getComputedStyle(target);
|
|
2116
|
+
const r = target.getBoundingClientRect();
|
|
2117
|
+
// one per visible control: all padding sides count once, all margin sides once
|
|
2118
|
+
const hardcoded = new Set(Object.entries(T).filter(([, x]) => x.status === 'hardcoded').map(([k]) => k.replace(/^(padding|margin)(-.*)?$/, '$1'))).size;
|
|
2119
|
+
const srcLine = src.file ? `${src.file}:${src.line}:${src.col}` : '';
|
|
2120
|
+
const srcIsAncestor = src.via === 'stamp-ancestor';
|
|
2121
|
+
|
|
2122
|
+
panelScroll.innerHTML = '';
|
|
2123
|
+
panelHead.innerHTML = '';
|
|
2124
|
+
const title = mk('p-title');
|
|
2125
|
+
title.innerHTML = `<span class="name">${esc(chain[0] || target.tagName.toLowerCase())}</span><span class="tag"><${esc(target.tagName.toLowerCase())}></span>`;
|
|
2126
|
+
const btns = mk('hdr-btns');
|
|
2127
|
+
const dockBtn = mk('kbtn', 'button');
|
|
2128
|
+
dockBtn.innerHTML = DOCK_ICON;
|
|
2129
|
+
dockBtn.title = `Docked ${state.dock}: click to dock ${state.dock === 'left' ? 'right' : 'left'}, or drag the header`;
|
|
2130
|
+
dockBtn.addEventListener('click', () => setDock(state.dock === 'left' ? 'right' : 'left'));
|
|
2131
|
+
state.dockBtn = dockBtn;
|
|
2132
|
+
const pickBtn = mk('kbtn' + (state.picking ? ' on' : ''), 'button');
|
|
2133
|
+
pickBtn.innerHTML = PICK_ICON;
|
|
2134
|
+
pickBtn.title = PICK_TITLE[state.picking ? 'on' : 'off'];
|
|
2135
|
+
pickBtn.setAttribute('aria-pressed', String(state.picking));
|
|
2136
|
+
pickBtn.addEventListener('click', () => setPicking(!state.picking));
|
|
2137
|
+
state.pickBtn = pickBtn;
|
|
2138
|
+
// an X, not a keycap: this closes the sidebar and leaves Design Mode on (Esc does the same)
|
|
2139
|
+
const closeBtn = mk('kbtn', 'button');
|
|
2140
|
+
closeBtn.innerHTML = CLOSE_ICON;
|
|
2141
|
+
closeBtn.title = 'Close the sidebar (Esc). Design Mode stays on; Esc again exits';
|
|
2142
|
+
closeBtn.setAttribute('aria-label', 'Close the sidebar');
|
|
2143
|
+
closeBtn.addEventListener('click', () => closePrompt());
|
|
2144
|
+
btns.append(dockBtn, pickBtn, closeBtn);
|
|
2145
|
+
title.append(btns);
|
|
2146
|
+
const sub = mk('p-sub');
|
|
2147
|
+
sub.textContent = chain.slice(1).join(' ← ');
|
|
2148
|
+
sub.title = chain.join(' ← ');
|
|
2149
|
+
const classes = mk('p-classes');
|
|
2150
|
+
classes.textContent = [...target.classList].join(' ');
|
|
2151
|
+
classes.title = classes.textContent;
|
|
2152
|
+
panelHead.append(title);
|
|
2153
|
+
panelScroll.append(sub);
|
|
2154
|
+
if (srcLine) {
|
|
2155
|
+
const srcEl = mk('p-src mono', 'button');
|
|
2156
|
+
srcEl.textContent = (srcIsAncestor ? '↑ ' : '') + srcLine;
|
|
2157
|
+
srcEl.title = (srcIsAncestor ? 'Nearest mapped ancestor (this element itself is not mapped to a source line). ' : 'Source of this element. ') + 'Click to copy';
|
|
2158
|
+
srcEl.addEventListener('click', () => {
|
|
2159
|
+
const done = () => showToast(`Copied ${srcLine}`, 1800);
|
|
2160
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(srcLine).then(done, done);
|
|
2161
|
+
else done();
|
|
2162
|
+
});
|
|
2163
|
+
panelScroll.append(srcEl);
|
|
2164
|
+
}
|
|
2165
|
+
panelScroll.append(classes);
|
|
2166
|
+
if (hardcoded) {
|
|
2167
|
+
const f = mk('flag');
|
|
2168
|
+
f.append(hcGlyph(), ` ${hardcoded} hardcoded value${hardcoded > 1 ? 's' : ''} on this element`);
|
|
2169
|
+
f.title = 'Values written as literals, with no design token behind them';
|
|
2170
|
+
panelScroll.append(f);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
panelScroll.append(promptSection());
|
|
2174
|
+
|
|
2175
|
+
const tk = (label, opts) => { const c = tokenInput(opts); return row(label, c.node, { hardcoded: c.hardcoded, tip: c.tip, props: [opts.prop] }); };
|
|
2176
|
+
|
|
2177
|
+
const disp = cs.display;
|
|
2178
|
+
const isFlex = disp.includes('flex');
|
|
2179
|
+
const isGrid = disp.includes('grid');
|
|
2180
|
+
const layout = [
|
|
2181
|
+
row('Display', segmented({ prop: 'display', current: disp, options: [
|
|
2182
|
+
{ value: 'block', icon: 'block' }, { value: 'inline-block', icon: 'inline-block' }, { value: 'flex', icon: 'flex' }, { value: 'grid', icon: 'grid' }, { value: 'none', icon: 'none', title: 'hidden' },
|
|
2183
|
+
] }), { props: ['display'] }),
|
|
2184
|
+
isFlex ? row('Direction', segmented({ prop: 'flex-direction', current: cs.flexDirection, options: [
|
|
2185
|
+
{ value: 'row', icon: 'row', title: 'horizontal' }, { value: 'column', icon: 'column', title: 'vertical' },
|
|
2186
|
+
] }), { props: ['flex-direction'] }) : null,
|
|
2187
|
+
(isFlex || isGrid) ? row('Align', segmented({ prop: 'align-items', current: cs.alignItems, map: { normal: 'stretch', start: 'flex-start', end: 'flex-end', 'self-start': 'flex-start', 'self-end': 'flex-end' }, options: [
|
|
2188
|
+
{ value: 'flex-start', icon: 'a-start' }, { value: 'center', icon: 'a-center' }, { value: 'flex-end', icon: 'a-end' }, { value: 'stretch', icon: 'a-stretch' }, { value: 'baseline', icon: 'a-baseline' },
|
|
2189
|
+
] }), { props: ['align-items'] }) : null,
|
|
2190
|
+
(isFlex || isGrid) ? row('Justify', segmented({ prop: 'justify-content', current: cs.justifyContent, map: { normal: 'flex-start', start: 'flex-start', end: 'flex-end', left: 'flex-start', right: 'flex-end' }, options: [
|
|
2191
|
+
{ value: 'flex-start', icon: 'j-start' }, { value: 'center', icon: 'j-center' }, { value: 'flex-end', icon: 'j-end' }, { value: 'space-between', icon: 'j-between' }, { value: 'space-around', icon: 'j-around' },
|
|
2192
|
+
] }), { props: ['justify-content'] }) : null,
|
|
2193
|
+
(isFlex || isGrid) ? (() => { const g = spacingInput({ prop: 'gap' }); return row('Gap', g.node, { hardcoded: g.hardcoded, tip: g.tip, props: ['gap'] }); })() : null,
|
|
2194
|
+
];
|
|
2195
|
+
const spacing = [boxModel(cs, r)];
|
|
2196
|
+
// Figma-style toggle: edit all four sides of padding (or margin) together
|
|
2197
|
+
const LINK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 2.5H3.5A1 1 0 0 0 2.5 3.5V5M11 2.5h1.5a1 1 0 0 1 1 1V5M5 13.5H3.5a1 1 0 0 1-1-1V11M11 13.5h1.5a1 1 0 0 0 1-1V11"/><rect x="6" y="6" width="4" height="4" rx="1" fill="currentColor" stroke="none"/></svg>';
|
|
2198
|
+
const linkBtn = () => {
|
|
2199
|
+
const b = mk('sbtn' + (state.linkSides ? ' on' : ''), 'button');
|
|
2200
|
+
b.innerHTML = LINK_ICON;
|
|
2201
|
+
const t = () => (state.linkSides ? 'All sides linked: editing one side edits all four. Click to edit sides separately' : 'Sides are separate. Click to link all four sides');
|
|
2202
|
+
b.title = t();
|
|
2203
|
+
b.setAttribute('aria-pressed', String(state.linkSides));
|
|
2204
|
+
b.addEventListener('click', () => {
|
|
2205
|
+
state.linkSides = !state.linkSides;
|
|
2206
|
+
try { sessionStorage.setItem('__cdm_link_sides', state.linkSides ? '1' : '0'); } catch { /* memory only */ }
|
|
2207
|
+
b.classList.toggle('on', state.linkSides);
|
|
2208
|
+
b.setAttribute('aria-pressed', String(state.linkSides));
|
|
2209
|
+
b.title = t();
|
|
2210
|
+
});
|
|
2211
|
+
return b;
|
|
2212
|
+
};
|
|
2213
|
+
const typography = [
|
|
2214
|
+
tk('Font', { prop: 'font-family', key: 'fontFamily' }),
|
|
2215
|
+
tk('Size', { prop: 'font-size', key: 'fontSize' }),
|
|
2216
|
+
tk('Weight', { prop: 'font-weight', key: 'fontWeight' }),
|
|
2217
|
+
tk('Leading', { prop: 'line-height', key: 'lineHeight' }),
|
|
2218
|
+
tk('Tracking', { prop: 'letter-spacing', key: 'tracking' }),
|
|
2219
|
+
tk('Color', { prop: 'color', key: 'color', swatch: true }),
|
|
2220
|
+
row('Align', segmented({ prop: 'text-align', current: cs.textAlign, map: { start: 'left', end: 'right', '-webkit-left': 'left', '-webkit-center': 'center', '-webkit-right': 'right' }, options: [
|
|
2221
|
+
{ value: 'left', icon: 't-left' }, { value: 'center', icon: 't-center' }, { value: 'right', icon: 't-right' }, { value: 'justify', icon: 't-justify' },
|
|
2222
|
+
] }), { props: ['text-align'] }),
|
|
2223
|
+
];
|
|
2224
|
+
const opacityIn = mk('ctl num', 'input');
|
|
2225
|
+
opacityIn.type = 'text'; opacityIn.inputMode = 'numeric';
|
|
2226
|
+
const opStart = () => Math.round(parseFloat(getComputedStyle(state.selectedEl).opacity) * 100);
|
|
2227
|
+
opacityIn.value = String(opStart());
|
|
2228
|
+
const setOpacity = (vIn) => {
|
|
2229
|
+
const v = Math.min(100, Math.max(0, Math.round(vIn)));
|
|
2230
|
+
applyPreview('opacity', String(v / 100), { label: `${v}%`, primitive: String(v / 100), system: true });
|
|
2231
|
+
opacityIn.value = String(v);
|
|
2232
|
+
};
|
|
2233
|
+
let opBase = 100;
|
|
2234
|
+
opacityIn.addEventListener('pointerdown', () => { opBase = opStart(); }, true);
|
|
2235
|
+
scrub(opacityIn, {
|
|
2236
|
+
step: 2,
|
|
2237
|
+
onDelta: (steps) => setOpacity(opBase + steps),
|
|
2238
|
+
onClick: () => { opacityIn.focus(); opacityIn.select(); openNumericOptions(opacityIn, [0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100].map((v) => ({ value: v, label: `${v}%` })), opStart(), setOpacity, 'No preset. Enter keeps the value (0 to 100)'); },
|
|
2239
|
+
});
|
|
2240
|
+
numericKeys(opacityIn, { reset: () => { opacityIn.value = String(opStart()); }, step: 1, bigStep: 10, nudge: (by) => setOpacity((parseFloat(opacityIn.value) || 0) + by) });
|
|
2241
|
+
dblclickReset(opacityIn, 'opacity');
|
|
2242
|
+
opacityIn.addEventListener('input', () => { if (dd && dd.anchor === opacityIn) dd.setFilter(opacityIn.value); });
|
|
2243
|
+
opacityIn.addEventListener('change', () => {
|
|
2244
|
+
const parsed = parseFloat(opacityIn.value);
|
|
2245
|
+
if (opacityIn.value.trim() === '' || Number.isNaN(parsed)) { opacityIn.value = String(opStart()); return; }
|
|
2246
|
+
setOpacity(parsed);
|
|
2247
|
+
});
|
|
2248
|
+
const opWrap = mk('unit'); const pct = mk('u', 'span'); pct.textContent = '%'; opWrap.append(opacityIn, pct);
|
|
2249
|
+
const appearance = [
|
|
2250
|
+
tk('Fill', { prop: 'background-color', key: 'color', swatch: true }),
|
|
2251
|
+
tk('Radius', { prop: 'border-radius', key: 'radius', special: { full: { css: 'calc(infinity * 1px)' }, none: { css: '0px' } } }),
|
|
2252
|
+
tk('Border', { prop: 'border-color', key: 'color', swatch: true }),
|
|
2253
|
+
tk('Shadow', { prop: 'box-shadow', key: 'shadow', special: { none: { css: 'none' } } }),
|
|
2254
|
+
row('Opacity', opWrap, { props: ['opacity'] }),
|
|
2255
|
+
];
|
|
2256
|
+
|
|
2257
|
+
panelScroll.append(
|
|
2258
|
+
section('Layout', layout),
|
|
2259
|
+
section('Spacing', spacing, [linkBtn()]),
|
|
2260
|
+
section('Typography', typography),
|
|
2261
|
+
section('Appearance', appearance),
|
|
2262
|
+
);
|
|
2263
|
+
if (!panel.classList.contains('open')) applyDock();
|
|
2264
|
+
panelScroll.scrollTop = scrollTop;
|
|
2265
|
+
renderTray();
|
|
2266
|
+
renderCrumbs(target);
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
const childrenOf = (node) => [...node.children].filter((c) => !isOurs(c) && c.tagName !== 'SCRIPT' && c.tagName !== 'STYLE');
|
|
2270
|
+
const nodeLabel = (node, prevComp) => {
|
|
2271
|
+
const f = ownFiber(node);
|
|
2272
|
+
const comp = f ? componentChain(f)[0] : null;
|
|
2273
|
+
const label = (comp && comp !== prevComp) ? comp : node.tagName.toLowerCase() + (node.classList[0] ? '.' + node.classList[0] : '');
|
|
2274
|
+
return { label, comp };
|
|
2275
|
+
};
|
|
2276
|
+
|
|
2277
|
+
let childMenu = null;
|
|
2278
|
+
let childMenuAnchor = null;
|
|
2279
|
+
const closeChildMenu = () => {
|
|
2280
|
+
if (childMenu) { childMenu.remove(); childMenu = null; }
|
|
2281
|
+
if (childMenuAnchor) childMenuAnchor.classList.remove('on');
|
|
2282
|
+
};
|
|
2283
|
+
const openChildMenu = (leaf) => {
|
|
2284
|
+
closeChildMenu();
|
|
2285
|
+
const kids = childrenOf(leaf);
|
|
2286
|
+
if (!kids.length) return;
|
|
2287
|
+
childMenu = mk('menu ui');
|
|
2288
|
+
const head = mk('mh');
|
|
2289
|
+
head.textContent = `${kids.length} child element${kids.length > 1 ? 's' : ''} of ${nodeLabel(leaf, null).label}`;
|
|
2290
|
+
childMenu.append(head);
|
|
2291
|
+
const { comp: leafComp } = nodeLabel(leaf, null);
|
|
2292
|
+
kids.slice(0, 14).forEach((k) => {
|
|
2293
|
+
const b = document.createElement('button');
|
|
2294
|
+
const { label } = nodeLabel(k, leafComp);
|
|
2295
|
+
const n = childrenOf(k).length;
|
|
2296
|
+
b.innerHTML = `<span class="ml">${esc(label)}</span>${n ? `<span class="mc">${n} inside</span>` : ''}`;
|
|
2297
|
+
b.title = (k.textContent || '').trim().slice(0, 80) || label;
|
|
2298
|
+
b.addEventListener('click', () => { closeChildMenu(); openPrompt(k, { keepScroll: true }); });
|
|
2299
|
+
childMenu.append(b);
|
|
2300
|
+
});
|
|
2301
|
+
if (kids.length > 14) { const more = mk('more'); more.textContent = `+${kids.length - 14} more (click one to descend, then open its children)`; childMenu.append(more); }
|
|
2302
|
+
panel.append(childMenu);
|
|
2303
|
+
const pr = panel.getBoundingClientRect();
|
|
2304
|
+
const cr = crumbs.getBoundingClientRect();
|
|
2305
|
+
childMenu.style.bottom = `${pr.bottom - cr.top + 4}px`;
|
|
2306
|
+
if (childMenuAnchor) childMenuAnchor.classList.add('on');
|
|
2307
|
+
};
|
|
2308
|
+
// click-and-drag scrolls the strip; a real drag swallows the click that follows
|
|
2309
|
+
let crumbDrag = null;
|
|
2310
|
+
let swallowCrumbClick = false;
|
|
2311
|
+
crumbs.addEventListener('pointerdown', (e) => {
|
|
2312
|
+
if (e.button !== 0) return;
|
|
2313
|
+
crumbDrag = { x: e.clientX, left: crumbs.scrollLeft, moved: false, id: e.pointerId };
|
|
2314
|
+
});
|
|
2315
|
+
crumbs.addEventListener('pointermove', (e) => {
|
|
2316
|
+
if (!crumbDrag || e.pointerId !== crumbDrag.id) return;
|
|
2317
|
+
const dx = e.clientX - crumbDrag.x;
|
|
2318
|
+
if (!crumbDrag.moved && Math.abs(dx) > 4) {
|
|
2319
|
+
crumbDrag.moved = true;
|
|
2320
|
+
crumbs.classList.add('dragging');
|
|
2321
|
+
try { crumbs.setPointerCapture(e.pointerId); } catch { /* pointer already gone */ }
|
|
2322
|
+
}
|
|
2323
|
+
if (crumbDrag.moved) crumbs.scrollLeft = crumbDrag.left - dx;
|
|
2324
|
+
});
|
|
2325
|
+
const endCrumbDrag = (e) => {
|
|
2326
|
+
if (!crumbDrag || (e && e.pointerId !== crumbDrag.id)) return;
|
|
2327
|
+
const moved = crumbDrag.moved;
|
|
2328
|
+
crumbDrag = null;
|
|
2329
|
+
crumbs.classList.remove('dragging');
|
|
2330
|
+
if (moved) { swallowCrumbClick = true; setTimeout(() => { swallowCrumbClick = false; }, 0); }
|
|
2331
|
+
};
|
|
2332
|
+
crumbs.addEventListener('pointerup', endCrumbDrag);
|
|
2333
|
+
crumbs.addEventListener('pointercancel', endCrumbDrag);
|
|
2334
|
+
crumbs.addEventListener('click', (e) => { if (swallowCrumbClick) { e.stopPropagation(); e.preventDefault(); } }, true);
|
|
2335
|
+
|
|
2336
|
+
// any click outside the menu (and not on its anchor) closes it
|
|
2337
|
+
shadow.addEventListener('click', (e) => {
|
|
2338
|
+
const path = e.composedPath();
|
|
2339
|
+
if (dd && !path.includes(dd.el) && !path.includes(dd.anchor)) closeDropdown();
|
|
2340
|
+
if (childMenu && !path.includes(childMenu) && !path.includes(childMenuAnchor)) closeChildMenu();
|
|
2341
|
+
}, true);
|
|
2342
|
+
|
|
2343
|
+
// The trail runs from the app root down to the deepest element reached, so
|
|
2344
|
+
// stepping up to a parent keeps the children visible; the current element is
|
|
2345
|
+
// highlighted wherever it sits and carries a picker listing all its children.
|
|
2346
|
+
const renderCrumbs = (target) => {
|
|
2347
|
+
closeChildMenu();
|
|
2348
|
+
if (!(state.trailLeaf && state.trailLeaf.isConnected && target.contains(state.trailLeaf))) state.trailLeaf = target;
|
|
2349
|
+
const leaf = state.trailLeaf;
|
|
2350
|
+
const path = [];
|
|
2351
|
+
let n = leaf;
|
|
2352
|
+
while (n && n !== document.body && path.length < 16) { path.push(n); n = n.parentElement; }
|
|
2353
|
+
path.reverse();
|
|
2354
|
+
crumbs.innerHTML = '';
|
|
2355
|
+
let prevComp = null;
|
|
2356
|
+
let cur = null;
|
|
2357
|
+
path.forEach((node, i) => {
|
|
2358
|
+
if (i) { const sep = mk('sep', 'span'); sep.textContent = '›'; crumbs.append(sep); }
|
|
2359
|
+
const { label, comp } = nodeLabel(node, prevComp);
|
|
2360
|
+
if (comp) prevComp = comp;
|
|
2361
|
+
const b = document.createElement('button');
|
|
2362
|
+
if (node === target) { b.className = 'cur'; cur = b; }
|
|
2363
|
+
b.textContent = label.slice(0, 22);
|
|
2364
|
+
b.title = label;
|
|
2365
|
+
b.addEventListener('click', () => openPrompt(node, { keepScroll: true }));
|
|
2366
|
+
crumbs.append(b);
|
|
2367
|
+
if (node === target) {
|
|
2368
|
+
const kids = childrenOf(node);
|
|
2369
|
+
childMenuAnchor = null;
|
|
2370
|
+
if (kids.length) {
|
|
2371
|
+
const more = mk('kids', 'button');
|
|
2372
|
+
more.textContent = '▾';
|
|
2373
|
+
more.title = `${kids.length} child element${kids.length > 1 ? 's' : ''} of ${label}: pick one to go deeper`;
|
|
2374
|
+
more.addEventListener('click', () => (childMenu ? closeChildMenu() : openChildMenu(node)));
|
|
2375
|
+
childMenuAnchor = more;
|
|
2376
|
+
crumbs.append(more);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
});
|
|
2380
|
+
crumbs.style.display = 'flex';
|
|
2381
|
+
if (cur) crumbs.scrollLeft = Math.max(0, cur.offsetLeft - crumbs.clientWidth / 2 + cur.offsetWidth / 2);
|
|
2382
|
+
else crumbs.scrollLeft = crumbs.scrollWidth;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
/* ---------------------------------------------------------- selection --- */
|
|
2386
|
+
|
|
2387
|
+
const closePrompt = () => {
|
|
2388
|
+
closeDropdown();
|
|
2389
|
+
closeCommitModal();
|
|
2390
|
+
closeChildMenu();
|
|
2391
|
+
state.promptOpen = false;
|
|
2392
|
+
state.picking = false;
|
|
2393
|
+
state.selectedEl = null;
|
|
2394
|
+
state.trailLeaf = null;
|
|
2395
|
+
watchSelected(null);
|
|
2396
|
+
applyDock();
|
|
2397
|
+
syncPill();
|
|
2398
|
+
syncCursor();
|
|
2399
|
+
ring.style.display = 'none';
|
|
2400
|
+
crumbs.style.display = 'none';
|
|
2401
|
+
};
|
|
2402
|
+
|
|
2403
|
+
const openPrompt = (target, { keepScroll = false } = {}) => {
|
|
2404
|
+
state.selectedEl = target;
|
|
2405
|
+
state.promptOpen = true;
|
|
2406
|
+
state.hoverEl = null;
|
|
2407
|
+
hi.style.display = 'none';
|
|
2408
|
+
hiLabel.style.display = 'none';
|
|
2409
|
+
box(target, ring, 1);
|
|
2410
|
+
renderPanel(target, keepScroll);
|
|
2411
|
+
watchSelected(target);
|
|
2412
|
+
syncPill();
|
|
2413
|
+
syncCursor();
|
|
2414
|
+
};
|
|
2415
|
+
|
|
2416
|
+
// Keep the ring and hover box glued to their elements while the page scrolls or resizes.
|
|
2417
|
+
let rafPending = false;
|
|
2418
|
+
// The page re-rendered under us (HMR, a route change, a list re-keyed): pending edits follow
|
|
2419
|
+
// the element that took the old one's place when there is a clear match, or are dropped with a note
|
|
2420
|
+
let reconcileTimer = 0;
|
|
2421
|
+
const reconcilePending = () => {
|
|
2422
|
+
if (reconcileTimer) { clearTimeout(reconcileTimer); reconcileTimer = 0; }
|
|
2423
|
+
let moved = 0;
|
|
2424
|
+
let dropped = 0;
|
|
2425
|
+
for (const [elx, map] of [...state.pending]) {
|
|
2426
|
+
if (elx.isConnected || !map.size) continue;
|
|
2427
|
+
const stampSel = elx.getAttribute('data-claude-source');
|
|
2428
|
+
const text = (elx.textContent || '').trim();
|
|
2429
|
+
const twin = stampSel
|
|
2430
|
+
? [...document.querySelectorAll(`[data-claude-source="${CSS.escape(stampSel)}"]`)].find((c) => (c.textContent || '').trim() === text && !state.pending.has(c)) || null
|
|
2431
|
+
: null;
|
|
2432
|
+
state.pending.delete(elx);
|
|
2433
|
+
if (twin) {
|
|
2434
|
+
state.pending.set(twin, map);
|
|
2435
|
+
for (const c of map.values()) reapplyOverride(twin, c);
|
|
2436
|
+
moved += map.size;
|
|
2437
|
+
} else {
|
|
2438
|
+
dropped += map.size;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (moved || dropped) {
|
|
2442
|
+
renderTray();
|
|
2443
|
+
if (state.promptOpen && state.selectedEl && state.selectedEl.isConnected) renderPanel(state.selectedEl, true);
|
|
2444
|
+
else refreshModMarks();
|
|
2445
|
+
if (dropped) showToast(`The page updated: ${dropped} unsent change${dropped > 1 ? 's' : ''} lost ${dropped > 1 ? 'their' : 'its'} element and ${dropped > 1 ? 'were' : 'was'} dropped.`, 6000);
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
const scheduleReconcile = () => {
|
|
2449
|
+
if (reconcileTimer) return;
|
|
2450
|
+
reconcileTimer = setTimeout(() => { reconcileTimer = 0; reconcilePending(); }, 120);
|
|
2451
|
+
};
|
|
2452
|
+
|
|
2453
|
+
const reposition = () => {
|
|
2454
|
+
rafPending = false;
|
|
2455
|
+
if (state.promptOpen && state.selectedEl && !state.selectedEl.isConnected) {
|
|
2456
|
+
if (state.pending.size) reconcilePending(); // previews move to the twin before the panel reads it
|
|
2457
|
+
const live = liveTarget();
|
|
2458
|
+
if (live) {
|
|
2459
|
+
// same stamp, same text: the re-rendered twin is the selection now
|
|
2460
|
+
state.selectedEl = live;
|
|
2461
|
+
renderPanel(live, true);
|
|
2462
|
+
} else {
|
|
2463
|
+
ring.style.display = 'none';
|
|
2464
|
+
showToast('The page updated under your selection; pick the element again.', 5000);
|
|
2465
|
+
closePrompt();
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
if (state.promptOpen && state.selectedEl && state.selectedEl.isConnected) box(state.selectedEl, ring, 1);
|
|
2470
|
+
if (inspecting() && state.hoverEl && state.hoverEl.isConnected) {
|
|
2471
|
+
const r = box(state.hoverEl, hi);
|
|
2472
|
+
hiLabel.style.left = `${Math.max(4, r.left)}px`;
|
|
2473
|
+
hiLabel.style.top = `${Math.max(4, r.top - 24)}px`;
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
const onScrollOrResize = () => {
|
|
2477
|
+
if (rafPending) return;
|
|
2478
|
+
rafPending = true;
|
|
2479
|
+
requestAnimationFrame(reposition);
|
|
2480
|
+
};
|
|
2481
|
+
window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true });
|
|
2482
|
+
window.addEventListener('resize', onScrollOrResize);
|
|
2483
|
+
// any page reflow (dock margin, HMR layout change) re-glues the boxes, even if rAF was paused
|
|
2484
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
2485
|
+
const ro = new ResizeObserver(() => reposition());
|
|
2486
|
+
ro.observe(document.documentElement);
|
|
2487
|
+
if (document.body) ro.observe(document.body);
|
|
2488
|
+
}
|
|
2489
|
+
// the selected element itself resizing (text change, image load, HMR) re-glues the ring
|
|
2490
|
+
let selRO = null;
|
|
2491
|
+
const watchSelected = (elx) => {
|
|
2492
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
2493
|
+
if (selRO) selRO.disconnect();
|
|
2494
|
+
selRO = null;
|
|
2495
|
+
if (!elx) return;
|
|
2496
|
+
selRO = new ResizeObserver(() => onScrollOrResize());
|
|
2497
|
+
selRO.observe(elx);
|
|
2498
|
+
};
|
|
2499
|
+
// removed nodes: the selection and pending edits check themselves
|
|
2500
|
+
if (typeof MutationObserver !== 'undefined' && document.body) {
|
|
2501
|
+
new MutationObserver((muts) => {
|
|
2502
|
+
if (!state.active) return;
|
|
2503
|
+
let removed = false;
|
|
2504
|
+
for (const m of muts) if (m.removedNodes.length) { removed = true; break; }
|
|
2505
|
+
if (!removed) return;
|
|
2506
|
+
if (state.promptOpen && state.selectedEl && !state.selectedEl.isConnected) onScrollOrResize();
|
|
2507
|
+
if (state.pending.size) scheduleReconcile();
|
|
2508
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
2509
|
+
}
|
|
2510
|
+
document.documentElement.addEventListener('transitionend', (e) => { if (e.target === document.documentElement) reposition(); });
|
|
2511
|
+
|
|
2512
|
+
/* ------------------------------------------------------------- events --- */
|
|
2513
|
+
|
|
2514
|
+
const onMove = (e) => {
|
|
2515
|
+
if (!inspecting()) return;
|
|
2516
|
+
const t = document.elementFromPoint(e.clientX, e.clientY);
|
|
2517
|
+
if (!t || isOurs(t) || t === document.documentElement || t === document.body) {
|
|
2518
|
+
hi.style.display = 'none';
|
|
2519
|
+
hiLabel.style.display = 'none';
|
|
2520
|
+
state.hoverEl = null;
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (t === state.hoverEl) return;
|
|
2524
|
+
state.hoverEl = t;
|
|
2525
|
+
const r = box(t, hi);
|
|
2526
|
+
const fiber = findFiber(t);
|
|
2527
|
+
const chain = fiber ? componentChain(fiber) : [];
|
|
2528
|
+
hiLabel.textContent = `${chain[0] ? chain[0] + ' · ' : ''}${t.tagName.toLowerCase()}${t.classList[0] ? '.' + t.classList[0] : ''} · ${Math.round(r.width)}×${Math.round(r.height)}`;
|
|
2529
|
+
hiLabel.style.display = 'block';
|
|
2530
|
+
hiLabel.style.left = `${Math.max(4, r.left)}px`;
|
|
2531
|
+
hiLabel.style.top = `${Math.max(4, r.top - 24)}px`;
|
|
2532
|
+
};
|
|
2533
|
+
|
|
2534
|
+
const SUPPRESSED = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
2535
|
+
const onSuppressed = (e) => {
|
|
2536
|
+
if (!state.active) return;
|
|
2537
|
+
if (isOurs(e.target)) return; // our own UI stays interactive
|
|
2538
|
+
if (e.type === 'click' && childMenu) closeChildMenu();
|
|
2539
|
+
if (!inspecting()) return; // sidebar open, pick toggle off: the page gets its clicks back
|
|
2540
|
+
e.preventDefault();
|
|
2541
|
+
e.stopImmediatePropagation();
|
|
2542
|
+
if (e.type !== 'click') return;
|
|
2543
|
+
if (e.detail === 0 && e.clientX === 0 && e.clientY === 0) return; // keyboard-synthesised click: nothing at (0,0) to pick
|
|
2544
|
+
let t = document.elementFromPoint(e.clientX, e.clientY);
|
|
2545
|
+
if (!t || isOurs(t) || t === document.body || t === document.documentElement) return;
|
|
2546
|
+
if (e.altKey) { // Alt+click: the parent of what is under the pointer
|
|
2547
|
+
const base = (state.hoverEl && state.hoverEl.isConnected) ? state.hoverEl : t;
|
|
2548
|
+
t = base.parentElement && base.parentElement !== document.body ? base.parentElement : base;
|
|
2549
|
+
}
|
|
2550
|
+
openPrompt(t);
|
|
2551
|
+
};
|
|
2552
|
+
const clearHover = () => { hi.style.display = 'none'; hiLabel.style.display = 'none'; state.hoverEl = null; };
|
|
2553
|
+
document.addEventListener('pointerleave', clearHover, true);
|
|
2554
|
+
document.documentElement.addEventListener('mouseleave', clearHover);
|
|
2555
|
+
window.addEventListener('blur', clearHover);
|
|
2556
|
+
|
|
2557
|
+
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || '');
|
|
2558
|
+
const isTextEntry = (n) => n instanceof Element && (n.tagName === 'TEXTAREA' || n.isContentEditable
|
|
2559
|
+
|| (n.tagName === 'INPUT' && !/^(button|checkbox|radio|range|color|file|submit|reset|image)$/i.test(n.type || 'text')));
|
|
2560
|
+
const inOurUI = (n) => isOurs(n) || (n && n.getRootNode && n.getRootNode() === shadow);
|
|
2561
|
+
const onKey = (e) => {
|
|
2562
|
+
const origin = e.composedPath ? e.composedPath()[0] : e.target;
|
|
2563
|
+
// Cmd+D on Mac, Ctrl+D elsewhere (Ctrl+D is delete-forward in Mac text fields); never while typing
|
|
2564
|
+
const mod = IS_MAC ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
|
|
2565
|
+
const isD = e.code ? e.code === 'KeyD' : String(e.key).toLowerCase() === 'd'; // some virtual keyboards send no code
|
|
2566
|
+
if (cfg.hotkey && mod && !e.shiftKey && !e.altKey && isD) {
|
|
2567
|
+
if (isTextEntry(origin)) return;
|
|
2568
|
+
e.preventDefault();
|
|
2569
|
+
api.toggle();
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (!state.active) return;
|
|
2573
|
+
if (origin && origin.hasAttribute && origin.hasAttribute('data-cdm-field')) return; // panel fields handle their own keys
|
|
2574
|
+
// Enter from the page (nothing of ours focused) opens the Ask Claude section; inside the
|
|
2575
|
+
// sidebar Enter belongs to whatever is focused (buttons activate, fields commit)
|
|
2576
|
+
if (e.key === 'Enter' && state.promptOpen && state.promptOpenSection && !e.isComposing && !modal && !inOurUI(origin) && !isTextEntry(origin)) {
|
|
2577
|
+
e.preventDefault();
|
|
2578
|
+
e.stopImmediatePropagation();
|
|
2579
|
+
state.promptOpenSection();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
if (e.key === 'Escape' && !e.isComposing) {
|
|
2583
|
+
if (isTextEntry(origin) && !inOurUI(origin)) return; // a page input's own Escape
|
|
2584
|
+
e.preventDefault();
|
|
2585
|
+
e.stopImmediatePropagation();
|
|
2586
|
+
if (confirmEl) closeConfirm();
|
|
2587
|
+
else if (dd) closeDropdown();
|
|
2588
|
+
else if (modal) closeCommitModal();
|
|
2589
|
+
else if (childMenu) closeChildMenu();
|
|
2590
|
+
else if (state.promptOpen) closePrompt();
|
|
2591
|
+
else requestDisable();
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
// The last Esc (or the pill's esc, or the hotkey) with unsent edits on the page: ask before dropping them
|
|
2596
|
+
const requestDisable = () => {
|
|
2597
|
+
const n = pendingCount();
|
|
2598
|
+
if (!n) { api.disable(); return; }
|
|
2599
|
+
confirmBox({
|
|
2600
|
+
text: `Exit Design Mode and discard ${n} unsent change${n > 1 ? 's' : ''}?`,
|
|
2601
|
+
ok: 'Discard and exit',
|
|
2602
|
+
onOk: () => api.disable(),
|
|
2603
|
+
});
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
document.addEventListener('pointermove', onMove, { capture: true, passive: true });
|
|
2607
|
+
for (const type of SUPPRESSED) document.addEventListener(type, onSuppressed, { capture: true });
|
|
2608
|
+
document.addEventListener('keydown', onKey, { capture: true });
|
|
2609
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2610
|
+
if (!pendingCount()) return;
|
|
2611
|
+
e.preventDefault();
|
|
2612
|
+
e.returnValue = ''; // unsent design edits would be lost
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
/* ---------------------------------------------------------------- api --- */
|
|
2616
|
+
|
|
2617
|
+
const api = {
|
|
2618
|
+
version: '0.4.0',
|
|
2619
|
+
bootId: Math.random().toString(36).slice(2, 10),
|
|
2620
|
+
config: cfg,
|
|
2621
|
+
heartbeat: Date.now(),
|
|
2622
|
+
root: shadow,
|
|
2623
|
+
enable() {
|
|
2624
|
+
ensureMounted();
|
|
2625
|
+
state.active = true;
|
|
2626
|
+
syncPill();
|
|
2627
|
+
syncCursor();
|
|
2628
|
+
},
|
|
2629
|
+
disable() {
|
|
2630
|
+
closeConfirm();
|
|
2631
|
+
if (pendingCount()) discardAll();
|
|
2632
|
+
state.active = false;
|
|
2633
|
+
state.picking = false;
|
|
2634
|
+
state.hoverEl = null;
|
|
2635
|
+
closePrompt();
|
|
2636
|
+
hi.style.display = 'none';
|
|
2637
|
+
hiLabel.style.display = 'none';
|
|
2638
|
+
syncPill();
|
|
2639
|
+
syncCursor();
|
|
2640
|
+
},
|
|
2641
|
+
toggle() { state.active ? requestDisable() : api.enable(); },
|
|
2642
|
+
isActive() { return state.active; },
|
|
2643
|
+
peek() { return state.queue.slice(); },
|
|
2644
|
+
take() {
|
|
2645
|
+
const q = state.queue.slice();
|
|
2646
|
+
state.queue = [];
|
|
2647
|
+
persist();
|
|
2648
|
+
return q;
|
|
2649
|
+
},
|
|
2650
|
+
applied() {
|
|
2651
|
+
clearPreviews();
|
|
2652
|
+
showToast('Previews cleared; the page now shows the committed code.');
|
|
2653
|
+
},
|
|
2654
|
+
pendingChanges() {
|
|
2655
|
+
return [...state.pending.entries()].map(([elx, m]) => ({ element: elLabel(elx), edits: [...m.values()].map((c) => ({ prop: c.prop, from: labelFor(c.from), to: labelFor(c.to), hardcoded: c.to.hardcoded })) }));
|
|
2656
|
+
},
|
|
2657
|
+
notify(text) { showToast(String(text).slice(0, 300), 6000); },
|
|
2658
|
+
select(target) { if (target instanceof Element) { api.enable(); openPrompt(target); } },
|
|
2659
|
+
simulate(selector, instruction, scope = 'auto') {
|
|
2660
|
+
let t;
|
|
2661
|
+
try { t = document.querySelector(selector); } catch (err) { return { error: `invalid selector ${selector}: ${err.message}` }; }
|
|
2662
|
+
if (!t) return { error: `no element matches ${selector}` };
|
|
2663
|
+
const payload = buildPayload(t, instruction, scope);
|
|
2664
|
+
deliver(payload);
|
|
2665
|
+
return { seq: payload.seq, source: payload.source };
|
|
2666
|
+
},
|
|
2667
|
+
};
|
|
2668
|
+
|
|
2669
|
+
setInterval(() => { api.heartbeat = Date.now(); }, 1000);
|
|
2670
|
+
window.__claudeDesign = api;
|
|
2671
|
+
if (cfg.endpoint && state.queue.length) scheduleRetry();
|
|
2672
|
+
console.log('[design-mode] overlay ready', cfg.endpoint ? '(plugin endpoint)' : '(session mode)');
|
|
2673
|
+
})();
|