tweaklocal 0.1.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/README.md +37 -0
- package/bin/tweaklocal.js +13 -0
- package/overlay/overlay.js +804 -0
- package/package.json +14 -0
- package/src/resolver.js +234 -0
- package/src/router.js +86 -0
- package/src/server.js +243 -0
- package/src/telemetry.js +97 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
// TweakLocal overlay — injected into the app in dev. Vanilla JS, no deps.
|
|
2
|
+
(() => {
|
|
3
|
+
if (window.__TWEAKLOCAL__) return;
|
|
4
|
+
window.__TWEAKLOCAL__ = true;
|
|
5
|
+
|
|
6
|
+
const script = [...document.scripts].find((s) => /\/overlay\.js/.test(s.src));
|
|
7
|
+
const ORIGIN = window.TWEAKLOCAL_ORIGIN || (script ? new URL(script.src).origin : 'http://localhost:4100');
|
|
8
|
+
|
|
9
|
+
const SPACE_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'];
|
|
10
|
+
const FONT_FALLBACK = ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl', 'text-7xl'];
|
|
11
|
+
// px scales for the style-system-agnostic lane (inline style edits)
|
|
12
|
+
const PX_SPACE = [0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96];
|
|
13
|
+
const PX_FONT = [10, 11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 36, 42, 48, 56, 64, 72, 88, 96];
|
|
14
|
+
|
|
15
|
+
const css = `
|
|
16
|
+
#twk-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
|
|
17
|
+
.twk-outline{position:fixed;border:1.5px solid #6366f1;border-radius:3px;background:rgba(99,102,241,.08);pointer-events:none;transition:all .04s linear}
|
|
18
|
+
.twk-outline.twk-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
|
|
19
|
+
.twk-badge{position:fixed;background:#312e81;color:#e0e7ff;font-size:11px;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;transform:translateY(-100%)}
|
|
20
|
+
.twk-pop-label{position:fixed;background:#10b981;color:#052e1b;font-size:13.8px;font-weight:700;padding:3px 10px;border-radius:8px 8px 0 0;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);text-align:center}
|
|
21
|
+
.twk-delete-btn{position:fixed;width:22px;height:22px;border-radius:50%;background:#dc2626;color:#fff;border:none;cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.4)}
|
|
22
|
+
.twk-delete-btn:hover{background:#ef4444}
|
|
23
|
+
.twk-delete-btn svg{width:12px;height:12px;pointer-events:none}
|
|
24
|
+
.twk-pop{position:fixed;background:#111827;color:#f9fafb;border:1.5px solid #10b981;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.35);padding:8px;pointer-events:auto;display:flex;flex-direction:column;gap:6px;min-width:300px;max-width:340px;font-size:12px}
|
|
25
|
+
.twk-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
26
|
+
.twk-pop button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
|
|
27
|
+
.twk-pop button:hover{background:#4b5563}
|
|
28
|
+
.twk-pop button.twk-primary{background:#6366f1}
|
|
29
|
+
.twk-pop input{flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none}
|
|
30
|
+
.twk-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
|
|
31
|
+
.twk-label{color:#9ca3af;min-width:50px}
|
|
32
|
+
.twk-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
|
|
33
|
+
.twk-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
|
|
34
|
+
.twk-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
|
|
35
|
+
.twk-swatch:hover{transform:scale(1.15)}
|
|
36
|
+
.twk-chip{font-size:10.5px !important;padding:2px 6px !important}
|
|
37
|
+
.twk-tray{position:fixed;right:14px;bottom:14px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;pointer-events:auto}
|
|
38
|
+
.twk-total{background:#064e3b;color:#a7f3d0;border-radius:8px;padding:6px 11px;font-size:13px;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px}
|
|
39
|
+
.twk-tweak{background:#111827;color:#e5e7eb;border-radius:8px;padding:7px 11px;font-size:13px;display:flex;gap:8px;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px;overflow:hidden;text-overflow:ellipsis}
|
|
40
|
+
.twk-dot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
41
|
+
.twk-dot.done{background:#10b981}.twk-dot.queued,.twk-dot.running{background:#f59e0b;animation:twk-pulse 1s infinite}.twk-dot.error{background:#ef4444}.twk-dot.reverted{background:#6b7280}
|
|
42
|
+
.twk-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
|
|
43
|
+
.twk-meta{color:#9ca3af}
|
|
44
|
+
.twk-hint{position:fixed;left:14px;bottom:14px;background:#111827;color:#9ca3af;font-size:12.5px;padding:6px 11px;border-radius:6px;pointer-events:none}
|
|
45
|
+
[contenteditable="plaintext-only"],[contenteditable="true"]{outline:2px dashed #10b981;outline-offset:2px}
|
|
46
|
+
@keyframes twk-pulse{50%{opacity:.4}}`;
|
|
47
|
+
|
|
48
|
+
const style = document.createElement('style');
|
|
49
|
+
style.textContent = css;
|
|
50
|
+
document.head.appendChild(style);
|
|
51
|
+
|
|
52
|
+
const root = document.createElement('div');
|
|
53
|
+
root.id = 'twk-root';
|
|
54
|
+
document.body.appendChild(root);
|
|
55
|
+
|
|
56
|
+
const state = {
|
|
57
|
+
selectMode: false,
|
|
58
|
+
hoverEl: null,
|
|
59
|
+
selected: null, // { el, loc }
|
|
60
|
+
editing: null, // { el, original }
|
|
61
|
+
tailwind: true, // daemon reports whether the app uses Tailwind
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------- helpers ----------
|
|
65
|
+
const el = (tag, cls, text) => {
|
|
66
|
+
const n = document.createElement(tag);
|
|
67
|
+
if (cls) n.className = cls;
|
|
68
|
+
if (text != null) n.textContent = text;
|
|
69
|
+
return n;
|
|
70
|
+
};
|
|
71
|
+
const api = async (p, body) => {
|
|
72
|
+
const r = await fetch(`${ORIGIN}/api/${p}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
});
|
|
77
|
+
const j = await r.json();
|
|
78
|
+
if (j && j.ok === false) throw new Error(j.error);
|
|
79
|
+
return j;
|
|
80
|
+
};
|
|
81
|
+
const inOverlay = (t) => t instanceof Node && root.contains(t);
|
|
82
|
+
const classList = (e) => (e.getAttribute('class') || '').split(/\s+/).filter(Boolean);
|
|
83
|
+
// "components/sections/Hero.tsx:50:9" (or absolute path) -> "Hero.tsx:50"
|
|
84
|
+
const shortLoc = (loc) => {
|
|
85
|
+
const m = /^(.*):(\d+):(\d+)$/.exec(loc);
|
|
86
|
+
if (!m) return loc;
|
|
87
|
+
return `${m[1].split('/').pop()}:${m[2]}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function positionBox(box, target) {
|
|
91
|
+
const r = target.getBoundingClientRect();
|
|
92
|
+
Object.assign(box.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' });
|
|
93
|
+
return r;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- design system (Tailwind v4 theme vars from the page's CSS) ----------
|
|
97
|
+
let theme = null;
|
|
98
|
+
function readTheme() {
|
|
99
|
+
if (theme) return theme;
|
|
100
|
+
const vars = {};
|
|
101
|
+
const visit = (rules) => {
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
if (rule.cssRules) { try { visit(rule.cssRules); } catch { /* cross-origin */ } }
|
|
104
|
+
if (rule.style) {
|
|
105
|
+
for (const prop of rule.style) {
|
|
106
|
+
if (prop.startsWith('--')) vars[prop] = rule.style.getPropertyValue(prop).trim();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
for (const sheet of document.styleSheets) {
|
|
112
|
+
try { visit(sheet.cssRules); } catch { /* cross-origin */ }
|
|
113
|
+
}
|
|
114
|
+
const px = (v) => {
|
|
115
|
+
const m = /^([\d.]+)(px|rem|em)?$/.exec(v);
|
|
116
|
+
return m ? parseFloat(m[1]) * (m[2] === 'px' ? 1 : 16) : NaN;
|
|
117
|
+
};
|
|
118
|
+
// Design-system sizes first, merged with the canonical Tailwind scale so
|
|
119
|
+
// stepping never dead-ends when the app only uses a few sizes.
|
|
120
|
+
const CANON_PX = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72 };
|
|
121
|
+
const sizeMap = new Map(Object.entries(CANON_PX));
|
|
122
|
+
for (const k of Object.keys(vars)) {
|
|
123
|
+
if (!/^--text-[a-z0-9]+$/.test(k)) continue;
|
|
124
|
+
const v = px(vars[k]);
|
|
125
|
+
if (!isNaN(v)) sizeMap.set('text-' + k.slice(7), v);
|
|
126
|
+
}
|
|
127
|
+
const textSizes = [...sizeMap.entries()].sort((a, b) => a[1] - b[1]).map(([cls]) => cls);
|
|
128
|
+
const colors = Object.keys(vars)
|
|
129
|
+
.filter((k) => /^--color-[a-z0-9-]+$/.test(k))
|
|
130
|
+
.map((k) => ({ name: k.slice(8), value: vars[k] }))
|
|
131
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
132
|
+
// Any CSS custom property whose value is a color — the app's design
|
|
133
|
+
// tokens, whatever the styling system. Applied as var(--name) so the
|
|
134
|
+
// source edit stays token-based.
|
|
135
|
+
const varColors = Object.keys(vars)
|
|
136
|
+
.filter((k) => vars[k] && CSS.supports('color', vars[k]))
|
|
137
|
+
.map((k) => ({ name: k, value: vars[k], apply: `var(${k})` }));
|
|
138
|
+
theme = { textSizes, colors, varColors, fromDS: colors.length > 0 };
|
|
139
|
+
return theme;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Fallback palette: the colors actually rendered on the page right now.
|
|
143
|
+
function harvestPageColors() {
|
|
144
|
+
const seen = new Map();
|
|
145
|
+
const els = document.querySelectorAll('[data-twk]');
|
|
146
|
+
for (const e of [...els].slice(0, 400)) {
|
|
147
|
+
const cs = getComputedStyle(e);
|
|
148
|
+
for (const v of [cs.color, cs.backgroundColor]) {
|
|
149
|
+
if (!v || v === 'rgba(0, 0, 0, 0)' || v === 'transparent') continue;
|
|
150
|
+
seen.set(v, (seen.get(v) || 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...seen.entries()]
|
|
154
|
+
.sort((a, b) => b[1] - a[1])
|
|
155
|
+
.slice(0, 20)
|
|
156
|
+
.map(([value]) => ({ name: value, value, apply: value }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- hover ----------
|
|
160
|
+
const hoverBox = el('div', 'twk-outline');
|
|
161
|
+
const hoverBadge = el('div', 'twk-badge');
|
|
162
|
+
hoverBox.style.display = hoverBadge.style.display = 'none';
|
|
163
|
+
root.append(hoverBox, hoverBadge);
|
|
164
|
+
|
|
165
|
+
function setHover(target) {
|
|
166
|
+
state.hoverEl = target;
|
|
167
|
+
if (!target || target === state.selected?.el) {
|
|
168
|
+
hoverBox.style.display = hoverBadge.style.display = 'none';
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
hoverBox.style.display = hoverBadge.style.display = 'block';
|
|
172
|
+
const r = positionBox(hoverBox, target);
|
|
173
|
+
hoverBadge.textContent = `<${target.tagName.toLowerCase()}> ${shortLoc(target.getAttribute('data-twk'))}`;
|
|
174
|
+
Object.assign(hoverBadge.style, { left: r.left + 'px', top: Math.max(r.top - 4, 16) + 'px' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- selection ----------
|
|
178
|
+
const selBox = el('div', 'twk-outline twk-selected');
|
|
179
|
+
selBox.style.display = 'none';
|
|
180
|
+
root.appendChild(selBox);
|
|
181
|
+
const pop = el('div', 'twk-pop');
|
|
182
|
+
pop.style.display = 'none';
|
|
183
|
+
root.appendChild(pop);
|
|
184
|
+
const popLabel = el('div', 'twk-pop-label');
|
|
185
|
+
popLabel.style.display = 'none';
|
|
186
|
+
root.appendChild(popLabel);
|
|
187
|
+
const deleteBtn = el('button', 'twk-delete-btn');
|
|
188
|
+
deleteBtn.innerHTML =
|
|
189
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/></svg>';
|
|
190
|
+
deleteBtn.style.display = 'none';
|
|
191
|
+
deleteBtn.title = 'Delete element';
|
|
192
|
+
root.appendChild(deleteBtn);
|
|
193
|
+
deleteBtn.onclick = async (e) => {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
e.stopPropagation();
|
|
196
|
+
const sel = state.selected;
|
|
197
|
+
if (!sel || !document.contains(sel.el)) return;
|
|
198
|
+
// Check first so a refusal never flickers the element or loses the
|
|
199
|
+
// selection — only hide optimistically once we know it'll succeed.
|
|
200
|
+
deleteBtn.disabled = true;
|
|
201
|
+
try {
|
|
202
|
+
await api('delete', { loc: sel.loc, dryRun: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
deleteBtn.disabled = false;
|
|
205
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
deleteBtn.disabled = false;
|
|
209
|
+
sel.el.style.display = 'none'; // optimistic; HMR makes it real
|
|
210
|
+
deselect();
|
|
211
|
+
try {
|
|
212
|
+
await api('delete', { loc: sel.loc });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
sel.el.style.display = '';
|
|
215
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message}` });
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function select(target) {
|
|
220
|
+
finishTextEdit(false);
|
|
221
|
+
const loc = target.getAttribute('data-twk');
|
|
222
|
+
state.selected = { el: target, loc, meta: null };
|
|
223
|
+
selBox.style.display = 'block';
|
|
224
|
+
renderPopover();
|
|
225
|
+
reposition();
|
|
226
|
+
loadMeta();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Source truth for the selection: the daemon's resolve tells us which text
|
|
230
|
+
// literals the element really has in JSX — the DOM can't (animation libs
|
|
231
|
+
// split text into spans, expressions render as text, etc).
|
|
232
|
+
async function loadMeta() {
|
|
233
|
+
const s = state.selected;
|
|
234
|
+
if (!s) return;
|
|
235
|
+
try {
|
|
236
|
+
const meta = await api('resolve', { loc: s.loc });
|
|
237
|
+
if (state.selected !== s) return; // selection changed meanwhile
|
|
238
|
+
s.meta = meta;
|
|
239
|
+
renderPopover();
|
|
240
|
+
reposition();
|
|
241
|
+
} catch { /* element not resolvable — leave popover as is */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function deselect() {
|
|
245
|
+
finishTextEdit(false);
|
|
246
|
+
state.selected = null;
|
|
247
|
+
selBox.style.display = 'none';
|
|
248
|
+
pop.style.display = 'none';
|
|
249
|
+
popLabel.style.display = 'none';
|
|
250
|
+
deleteBtn.style.display = 'none';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function reposition() {
|
|
254
|
+
const s = state.selected;
|
|
255
|
+
if (!s) return;
|
|
256
|
+
if (!document.contains(s.el)) {
|
|
257
|
+
// HMR replaced the node — re-acquire by stamp
|
|
258
|
+
const again = document.querySelector(`[data-twk="${CSS.escape(s.loc)}"]`);
|
|
259
|
+
if (!again) return deselect();
|
|
260
|
+
s.el = again;
|
|
261
|
+
}
|
|
262
|
+
const r = positionBox(selBox, s.el);
|
|
263
|
+
pop.style.display = 'flex';
|
|
264
|
+
const labelH = 22; // room for the file:line tab above the panel
|
|
265
|
+
const top = r.bottom + 8 + labelH + pop.offsetHeight > innerHeight ? r.top - pop.offsetHeight - 8 : r.bottom + 8 + labelH;
|
|
266
|
+
const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
|
|
267
|
+
Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
|
|
268
|
+
popLabel.style.display = 'block';
|
|
269
|
+
popLabel.textContent = shortLoc(s.loc);
|
|
270
|
+
Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
|
|
271
|
+
deleteBtn.style.display = 'flex';
|
|
272
|
+
const inset = 4;
|
|
273
|
+
Object.assign(deleteBtn.style, {
|
|
274
|
+
left: r.right - 22 - inset + 'px',
|
|
275
|
+
top: r.top + inset + 'px',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- class tweaks ----------
|
|
280
|
+
async function applyClassTweak(change, label) {
|
|
281
|
+
if (!change || (!change.remove.length && !change.add.length)) return;
|
|
282
|
+
const s = state.selected;
|
|
283
|
+
change.remove.forEach((c) => s.el.classList.remove(c)); // optimistic
|
|
284
|
+
change.add.forEach((c) => s.el.classList.add(c));
|
|
285
|
+
reposition();
|
|
286
|
+
try {
|
|
287
|
+
await api('edit-class', { loc: s.loc, remove: change.remove, add: change.add });
|
|
288
|
+
} catch (e) {
|
|
289
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
|
|
290
|
+
}
|
|
291
|
+
setTimeout(() => { reposition(); renderPopover(); }, 350); // after HMR
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------- popover ----------
|
|
295
|
+
function hasEditableText(target) {
|
|
296
|
+
return target.children.length === 0 && target.textContent.trim().length > 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Step a spacing class (p/m with optional side) along the Tailwind scale.
|
|
300
|
+
function spacingStep(target, base, side, dir) {
|
|
301
|
+
const prefix = base + side; // e.g. p, pt, m, ml
|
|
302
|
+
const re = new RegExp(`^${prefix}-(\\d+(?:\\.\\d+)?)$`);
|
|
303
|
+
const classes = classList(target);
|
|
304
|
+
const cur = classes.find((c) => re.test(c));
|
|
305
|
+
let from = cur ? re.exec(cur)[1] : null;
|
|
306
|
+
if (from == null && side) {
|
|
307
|
+
// side not set: start from the shorthand (pt-6 wins over p-4 in Tailwind ordering)
|
|
308
|
+
const allRe = new RegExp(`^${base}-(\\d+(?:\\.\\d+)?)$`);
|
|
309
|
+
const all = classes.find((c) => allRe.test(c));
|
|
310
|
+
from = all ? allRe.exec(all)[1] : '0';
|
|
311
|
+
}
|
|
312
|
+
if (from == null) from = '0';
|
|
313
|
+
let idx = SPACE_SCALE.indexOf(from);
|
|
314
|
+
if (idx < 0) idx = 0;
|
|
315
|
+
const next = Math.min(Math.max(idx + dir, 0), SPACE_SCALE.length - 1);
|
|
316
|
+
if (SPACE_SCALE[next] === from && cur) return null;
|
|
317
|
+
return { remove: cur ? [cur] : [], add: [`${prefix}-${SPACE_SCALE[next]}`] };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function fontStep(target, dir) {
|
|
321
|
+
const scale = readTheme().textSizes;
|
|
322
|
+
const classes = classList(target);
|
|
323
|
+
const cur = classes.find((c) => scale.includes(c));
|
|
324
|
+
let idx = cur ? scale.indexOf(cur) : scale.indexOf('text-base');
|
|
325
|
+
if (idx < 0) idx = Math.floor(scale.length / 2);
|
|
326
|
+
const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
|
|
327
|
+
if (scale[next] === cur) return null;
|
|
328
|
+
return { remove: cur ? [cur] : [], add: [scale[next]] };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Property editor: which classes to strip when setting each property.
|
|
332
|
+
const colorClassRe = (prefix) =>
|
|
333
|
+
new RegExp(`^${prefix}-(?:[a-z]+-\\d+(?:/\\d+)?|white|black|transparent|current|inherit)$`);
|
|
334
|
+
const PROPS = {
|
|
335
|
+
Background: { type: 'color', prefix: 'bg' },
|
|
336
|
+
'Text color': { type: 'color', prefix: 'text' },
|
|
337
|
+
'Border color': { type: 'color', prefix: 'border', ensure: 'border' },
|
|
338
|
+
Radius: { type: 'list', removeRe: /^rounded(-(none|sm|md|lg|xl|2xl|3xl|full))?$/, options: ['rounded-none', 'rounded-sm', 'rounded-md', 'rounded-lg', 'rounded-xl', 'rounded-2xl', 'rounded-3xl', 'rounded-full'] },
|
|
339
|
+
Shadow: { type: 'list', removeRe: /^shadow(-(none|sm|md|lg|xl|2xl))?$/, options: ['shadow-none', 'shadow-sm', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl'] },
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function propChange(target, propName, valueClass) {
|
|
343
|
+
const p = PROPS[propName];
|
|
344
|
+
const classes = classList(target);
|
|
345
|
+
const re = p.type === 'color' ? colorClassRe(p.prefix) : p.removeRe;
|
|
346
|
+
const remove = classes.filter((c) => re.test(c) && c !== valueClass);
|
|
347
|
+
const add = classes.includes(valueClass) ? [] : [valueClass];
|
|
348
|
+
if (p.ensure && !classes.some((c) => /^border(-\d+)?$/.test(c))) add.push(p.ensure);
|
|
349
|
+
return { remove, add };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- inline-style lane (no Tailwind required) ----------
|
|
353
|
+
async function applyStyleTweak(styles, optimistic, label) {
|
|
354
|
+
const s = state.selected;
|
|
355
|
+
optimistic?.();
|
|
356
|
+
reposition();
|
|
357
|
+
try {
|
|
358
|
+
await api('edit-style', { loc: s.loc, styles });
|
|
359
|
+
} catch (e) {
|
|
360
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `${label}: ${e.message}` });
|
|
361
|
+
}
|
|
362
|
+
setTimeout(() => { reposition(); renderPopover(); }, 350);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function pxStep(current, scale, dir) {
|
|
366
|
+
let idx = scale.findIndex((v) => v >= Math.round(current));
|
|
367
|
+
if (idx < 0) idx = scale.length - 1;
|
|
368
|
+
return scale[Math.min(Math.max(idx + dir, 0), scale.length - 1)];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function spacingRowPx(label, cssBase) {
|
|
372
|
+
const s = state.selected;
|
|
373
|
+
const row = el('div', 'twk-row');
|
|
374
|
+
row.append(el('span', 'twk-label', label));
|
|
375
|
+
const sideSel = el('select');
|
|
376
|
+
for (const [name, v] of [['All', ''], ['Top', 'Top'], ['Right', 'Right'], ['Bottom', 'Bottom'], ['Left', 'Left']]) {
|
|
377
|
+
const o = el('option', null, name);
|
|
378
|
+
o.value = v;
|
|
379
|
+
sideSel.appendChild(o);
|
|
380
|
+
}
|
|
381
|
+
const cur = el('span', 'twk-cur');
|
|
382
|
+
const show = () => {
|
|
383
|
+
const prop = cssBase + (sideSel.value || 'Top');
|
|
384
|
+
cur.textContent = Math.round(parseFloat(getComputedStyle(s.el)[prop]) || 0) + 'px';
|
|
385
|
+
};
|
|
386
|
+
show();
|
|
387
|
+
sideSel.onchange = show;
|
|
388
|
+
const bump = (dir) => {
|
|
389
|
+
const side = sideSel.value;
|
|
390
|
+
const readProp = cssBase + (side || 'Top');
|
|
391
|
+
const writeProp = side ? cssBase + side : cssBase;
|
|
392
|
+
const current = parseFloat(getComputedStyle(s.el)[readProp]) || 0;
|
|
393
|
+
const next = pxStep(current, PX_SPACE, dir);
|
|
394
|
+
if (next === Math.round(current)) return;
|
|
395
|
+
applyStyleTweak(
|
|
396
|
+
{ [writeProp]: next + 'px' },
|
|
397
|
+
() => { s.el.style[writeProp] = next + 'px'; show(); },
|
|
398
|
+
label
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
const minus = el('button', null, '−');
|
|
402
|
+
const plus = el('button', null, '+');
|
|
403
|
+
minus.onclick = () => bump(-1);
|
|
404
|
+
plus.onclick = () => bump(+1);
|
|
405
|
+
row.append(sideSel, minus, plus, cur);
|
|
406
|
+
return row;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function fontRowPx() {
|
|
410
|
+
const s = state.selected;
|
|
411
|
+
const row = el('div', 'twk-row');
|
|
412
|
+
row.append(el('span', 'twk-label', 'Font'));
|
|
413
|
+
const cur = el('span', 'twk-cur', Math.round(parseFloat(getComputedStyle(s.el).fontSize)) + 'px');
|
|
414
|
+
const bump = (dir) => {
|
|
415
|
+
const current = parseFloat(getComputedStyle(s.el).fontSize) || 16;
|
|
416
|
+
const next = pxStep(current, PX_FONT, dir);
|
|
417
|
+
if (next === Math.round(current)) return;
|
|
418
|
+
applyStyleTweak(
|
|
419
|
+
{ fontSize: next + 'px' },
|
|
420
|
+
() => { s.el.style.fontSize = next + 'px'; cur.textContent = next + 'px'; },
|
|
421
|
+
'font'
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
const minus = el('button', null, 'A−');
|
|
425
|
+
const plus = el('button', null, 'A+');
|
|
426
|
+
minus.onclick = () => bump(-1);
|
|
427
|
+
plus.onclick = () => bump(+1);
|
|
428
|
+
row.append(minus, plus, cur);
|
|
429
|
+
return row;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const STYLE_PROPS = {
|
|
433
|
+
Background: { css: 'backgroundColor', type: 'color' },
|
|
434
|
+
'Text color': { css: 'color', type: 'color' },
|
|
435
|
+
'Border color': { css: 'borderColor', type: 'color', ensureBorder: true },
|
|
436
|
+
Radius: { css: 'borderRadius', type: 'list', options: ['0px', '2px', '4px', '6px', '8px', '12px', '16px', '24px', '9999px'] },
|
|
437
|
+
Shadow: { css: 'boxShadow', type: 'list', options: ['none', '0 1px 2px rgba(0,0,0,.08)', '0 2px 8px rgba(0,0,0,.12)', '0 4px 16px rgba(0,0,0,.16)', '0 8px 30px rgba(0,0,0,.2)'], labels: ['none', 'sm', 'md', 'lg', 'xl'] },
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
function applyStyleProp(propName, applyValue) {
|
|
441
|
+
const s = state.selected;
|
|
442
|
+
const p = STYLE_PROPS[propName];
|
|
443
|
+
const styles = { [p.css]: applyValue };
|
|
444
|
+
if (p.ensureBorder && !parseFloat(getComputedStyle(s.el).borderTopWidth)) {
|
|
445
|
+
styles.borderWidth = '1px';
|
|
446
|
+
styles.borderStyle = 'solid';
|
|
447
|
+
}
|
|
448
|
+
applyStyleTweak(
|
|
449
|
+
styles,
|
|
450
|
+
() => { for (const [k, v] of Object.entries(styles)) s.el.style[k] = v; },
|
|
451
|
+
propName
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function spacingRow(label, base) {
|
|
456
|
+
const s = state.selected;
|
|
457
|
+
const row = el('div', 'twk-row');
|
|
458
|
+
row.append(el('span', 'twk-label', label));
|
|
459
|
+
const sideSel = el('select');
|
|
460
|
+
for (const [name, v] of [['All', ''], ['Top', 't'], ['Right', 'r'], ['Bottom', 'b'], ['Left', 'l']]) {
|
|
461
|
+
const o = el('option', null, name);
|
|
462
|
+
o.value = v;
|
|
463
|
+
sideSel.appendChild(o);
|
|
464
|
+
}
|
|
465
|
+
const minus = el('button', null, '−');
|
|
466
|
+
const plus = el('button', null, '+');
|
|
467
|
+
minus.onclick = () => applyClassTweak(spacingStep(s.el, base, sideSel.value, -1), label);
|
|
468
|
+
plus.onclick = () => applyClassTweak(spacingStep(s.el, base, sideSel.value, +1), label);
|
|
469
|
+
row.append(sideSel, minus, plus);
|
|
470
|
+
const cur = classList(s.el).filter((c) => new RegExp(`^${base}[trbl]?-`).test(c)).join(' ');
|
|
471
|
+
if (cur) row.append(el('span', 'twk-cur', cur));
|
|
472
|
+
return row;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function renderPopover() {
|
|
476
|
+
const s = state.selected;
|
|
477
|
+
if (!s) return;
|
|
478
|
+
pop.textContent = '';
|
|
479
|
+
|
|
480
|
+
// location now lives in the green tab above the panel (popLabel)
|
|
481
|
+
|
|
482
|
+
// Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
|
|
483
|
+
// DOM: animation libs split text into spans and expressions render as
|
|
484
|
+
// text, so DOM shape says nothing about what's editable in the JSX.
|
|
485
|
+
const literals = s.meta ? s.meta.texts : null;
|
|
486
|
+
if (literals && literals.length) {
|
|
487
|
+
// In-place editing only when the single literal IS the element's whole
|
|
488
|
+
// text (animation-split DOM still qualifies — same text, different
|
|
489
|
+
// nodes). Partial literals (mixed with {expressions}) get input fields
|
|
490
|
+
// showing exactly the editable part.
|
|
491
|
+
const wholeText =
|
|
492
|
+
literals.length === 1 && literals[0].value.trim() === s.el.textContent.trim();
|
|
493
|
+
if (wholeText) {
|
|
494
|
+
const row = el('div', 'twk-row');
|
|
495
|
+
row.append(el('span', 'twk-label', 'Copy'));
|
|
496
|
+
const b = el('button', null, '✎ Edit text in place');
|
|
497
|
+
b.onclick = () => startTextEdit();
|
|
498
|
+
row.appendChild(b);
|
|
499
|
+
pop.appendChild(row);
|
|
500
|
+
} else {
|
|
501
|
+
// DOM is transformed (or several literals) → edit the source text here
|
|
502
|
+
for (const t of literals) {
|
|
503
|
+
const row = el('div', 'twk-row');
|
|
504
|
+
row.append(el('span', 'twk-label', 'Copy'));
|
|
505
|
+
const input = el('input');
|
|
506
|
+
input.value = t.value;
|
|
507
|
+
const save = el('button', null, '✓');
|
|
508
|
+
const commit = async () => {
|
|
509
|
+
const newText = input.value.trim();
|
|
510
|
+
if (!newText || newText === t.value) return;
|
|
511
|
+
try {
|
|
512
|
+
await api('edit-text', { loc: s.loc, oldText: t.value, newText });
|
|
513
|
+
setTimeout(() => { reposition(); loadMeta(); }, 350);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `copy: ${e.message}` });
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
save.onclick = commit;
|
|
519
|
+
input.onkeydown = (e) => { if (e.key === 'Enter') commit(); e.stopPropagation(); };
|
|
520
|
+
row.append(input, save);
|
|
521
|
+
pop.appendChild(row);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Deterministic style controls. Tailwind apps get class edits; everything
|
|
527
|
+
// else gets inline-style edits — same zero-token lane, any styling system.
|
|
528
|
+
const tw = state.tailwind;
|
|
529
|
+
|
|
530
|
+
if (tw) {
|
|
531
|
+
pop.appendChild(spacingRow('Padding', 'p'));
|
|
532
|
+
pop.appendChild(spacingRow('Margin', 'm'));
|
|
533
|
+
const fontRow = el('div', 'twk-row');
|
|
534
|
+
fontRow.append(el('span', 'twk-label', 'Font'));
|
|
535
|
+
const fMinus = el('button', null, 'A−');
|
|
536
|
+
const fPlus = el('button', null, 'A+');
|
|
537
|
+
fMinus.onclick = () => applyClassTweak(fontStep(s.el, -1), 'font');
|
|
538
|
+
fPlus.onclick = () => applyClassTweak(fontStep(s.el, +1), 'font');
|
|
539
|
+
fontRow.append(fMinus, fPlus);
|
|
540
|
+
const curFont = classList(s.el).find((c) => readTheme().textSizes.includes(c));
|
|
541
|
+
fontRow.append(el('span', 'twk-cur', curFont || 'inherited'));
|
|
542
|
+
pop.appendChild(fontRow);
|
|
543
|
+
} else {
|
|
544
|
+
pop.appendChild(spacingRowPx('Padding', 'padding'));
|
|
545
|
+
pop.appendChild(spacingRowPx('Margin', 'margin'));
|
|
546
|
+
pop.appendChild(fontRowPx());
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// property editor
|
|
550
|
+
const propRow = el('div', 'twk-row');
|
|
551
|
+
propRow.append(el('span', 'twk-label', 'Style'));
|
|
552
|
+
const propSel = el('select');
|
|
553
|
+
propSel.appendChild(el('option', null, 'Choose property…'));
|
|
554
|
+
for (const name of Object.keys(tw ? PROPS : STYLE_PROPS)) {
|
|
555
|
+
const o = el('option', null, name);
|
|
556
|
+
o.value = name;
|
|
557
|
+
propSel.appendChild(o);
|
|
558
|
+
}
|
|
559
|
+
propRow.appendChild(propSel);
|
|
560
|
+
pop.appendChild(propRow);
|
|
561
|
+
const swatches = el('div', 'twk-swatches');
|
|
562
|
+
pop.appendChild(swatches);
|
|
563
|
+
propSel.onchange = () => {
|
|
564
|
+
swatches.textContent = '';
|
|
565
|
+
const p = (tw ? PROPS : STYLE_PROPS)[propSel.value];
|
|
566
|
+
if (!p) return;
|
|
567
|
+
if (p.type === 'color') {
|
|
568
|
+
if (tw) {
|
|
569
|
+
const { colors, fromDS } = readTheme();
|
|
570
|
+
if (!fromDS) {
|
|
571
|
+
swatches.append(el('span', 'twk-meta', 'no design-system colors found in page CSS'));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
for (const c of colors) {
|
|
575
|
+
const b = el('button', 'twk-swatch');
|
|
576
|
+
b.style.background = c.value;
|
|
577
|
+
b.title = `${p.prefix}-${c.name}`;
|
|
578
|
+
b.onclick = () => applyClassTweak(propChange(s.el, propSel.value, `${p.prefix}-${c.name}`), propSel.value);
|
|
579
|
+
swatches.appendChild(b);
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
// design tokens if the app defines color custom properties,
|
|
583
|
+
// otherwise the palette actually rendered on the page
|
|
584
|
+
const { varColors } = readTheme();
|
|
585
|
+
const palette = varColors.length ? varColors.slice(0, 48) : harvestPageColors();
|
|
586
|
+
for (const c of palette) {
|
|
587
|
+
const b = el('button', 'twk-swatch');
|
|
588
|
+
b.style.background = c.value;
|
|
589
|
+
b.title = c.name;
|
|
590
|
+
b.onclick = () => applyStyleProp(propSel.value, c.apply);
|
|
591
|
+
swatches.appendChild(b);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
p.options.forEach((opt, i) => {
|
|
596
|
+
const b = el('button', 'twk-chip', p.labels ? p.labels[i] : opt);
|
|
597
|
+
b.onclick = () =>
|
|
598
|
+
tw
|
|
599
|
+
? applyClassTweak(propChange(s.el, propSel.value, opt), propSel.value)
|
|
600
|
+
: applyStyleProp(propSel.value, opt);
|
|
601
|
+
swatches.appendChild(b);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
reposition();
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const nlRow = el('div', 'twk-row');
|
|
608
|
+
const input = el('input');
|
|
609
|
+
input.placeholder = 'Describe a change… (routed to the right model)';
|
|
610
|
+
const go = el('button', 'twk-primary', 'Go');
|
|
611
|
+
const send = async () => {
|
|
612
|
+
const instruction = input.value.trim();
|
|
613
|
+
if (!instruction) return;
|
|
614
|
+
input.value = '';
|
|
615
|
+
try {
|
|
616
|
+
const r = await api('nl', { loc: s.loc, instruction });
|
|
617
|
+
addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
|
|
618
|
+
} catch (e) {
|
|
619
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
go.onclick = send;
|
|
623
|
+
input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
|
|
624
|
+
nlRow.append(input, go);
|
|
625
|
+
pop.appendChild(nlRow);
|
|
626
|
+
// delete lives as a floating icon on the element itself (deleteBtn)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------- inline copy editing ----------
|
|
630
|
+
// Works on ANY element whose source has one text literal — titles,
|
|
631
|
+
// descriptions, buttons, links — even when animations have split the DOM
|
|
632
|
+
// into spans: we swap in the source text for editing and keep the original
|
|
633
|
+
// DOM aside so Esc restores it untouched.
|
|
634
|
+
function startTextEdit() {
|
|
635
|
+
const s = state.selected;
|
|
636
|
+
// the popover can outlive its selection (HMR, reloads) — never throw
|
|
637
|
+
const literal = s?.meta?.texts?.[0]?.value;
|
|
638
|
+
if (literal == null || !document.contains(s.el)) return;
|
|
639
|
+
const frag = document.createDocumentFragment();
|
|
640
|
+
while (s.el.firstChild) frag.appendChild(s.el.firstChild);
|
|
641
|
+
state.editing = {
|
|
642
|
+
el: s.el,
|
|
643
|
+
loc: s.loc,
|
|
644
|
+
literal,
|
|
645
|
+
frag,
|
|
646
|
+
prevUserSelect: s.el.style.userSelect,
|
|
647
|
+
};
|
|
648
|
+
s.el.textContent = literal;
|
|
649
|
+
s.el.style.userSelect = 'text'; // buttons often have user-select: none
|
|
650
|
+
try { s.el.contentEditable = 'plaintext-only'; } catch { s.el.contentEditable = 'true'; }
|
|
651
|
+
s.el.focus();
|
|
652
|
+
document.getSelection()?.selectAllChildren(s.el);
|
|
653
|
+
hint.textContent = 'editing copy — Enter saves · Esc cancels · click away saves';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function finishTextEdit(commit) {
|
|
657
|
+
const ed = state.editing;
|
|
658
|
+
if (!ed) return;
|
|
659
|
+
state.editing = null;
|
|
660
|
+
ed.el.removeAttribute('contenteditable');
|
|
661
|
+
ed.el.style.userSelect = ed.prevUserSelect;
|
|
662
|
+
if (state.selectMode) hint.textContent = 'select mode — click an element · Esc to exit';
|
|
663
|
+
const newText = ed.el.textContent.trim();
|
|
664
|
+
const restore = () => {
|
|
665
|
+
ed.el.textContent = '';
|
|
666
|
+
ed.el.appendChild(ed.frag);
|
|
667
|
+
};
|
|
668
|
+
if (!commit || !newText || newText === ed.literal.trim()) {
|
|
669
|
+
restore();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await api('edit-text', { loc: ed.loc, oldText: ed.literal, newText });
|
|
674
|
+
// keep the new text; HMR re-renders the component (animations included)
|
|
675
|
+
} catch (e) {
|
|
676
|
+
restore();
|
|
677
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `copy: ${e.message}` });
|
|
678
|
+
}
|
|
679
|
+
setTimeout(() => { reposition(); loadMeta(); }, 350);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------- tray ----------
|
|
683
|
+
const tray = el('div', 'twk-tray');
|
|
684
|
+
root.appendChild(tray);
|
|
685
|
+
const totalBar = el('div', 'twk-total');
|
|
686
|
+
totalBar.style.display = 'none';
|
|
687
|
+
tray.appendChild(totalBar);
|
|
688
|
+
const tweaks = new Map();
|
|
689
|
+
|
|
690
|
+
function showTotals(t) {
|
|
691
|
+
if (!t || !t.count) return;
|
|
692
|
+
totalBar.style.display = '';
|
|
693
|
+
totalBar.textContent = `≈ saved $${t.usd.toFixed(2)} · ${Math.round(t.ms / 1000)}s across ${t.count} tweak${t.count === 1 ? '' : 's'} (vs unscoped agent)`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function addTweak(t) {
|
|
697
|
+
let row = tweaks.get(String(t.id));
|
|
698
|
+
if (!row) {
|
|
699
|
+
row = el('div', 'twk-tweak');
|
|
700
|
+
row._dot = el('span', 'twk-dot');
|
|
701
|
+
row._label = el('span', null, '');
|
|
702
|
+
row._meta = el('span', 'twk-meta', '');
|
|
703
|
+
row._undo = el('button', null, 'undo');
|
|
704
|
+
row._undo.style.display = 'none';
|
|
705
|
+
row._undo.onclick = async () => {
|
|
706
|
+
try {
|
|
707
|
+
await api('undo', { id: t.id });
|
|
708
|
+
setTimeout(reposition, 350);
|
|
709
|
+
} catch (e) { row._meta.textContent = e.message; }
|
|
710
|
+
};
|
|
711
|
+
row.append(row._dot, row._label, row._meta, row._undo);
|
|
712
|
+
tray.insertBefore(row, totalBar.nextSibling);
|
|
713
|
+
tweaks.set(String(t.id), row);
|
|
714
|
+
while (tray.children.length > 7) tray.lastChild.remove();
|
|
715
|
+
}
|
|
716
|
+
if (t.label) row._label.textContent = t.label;
|
|
717
|
+
if (t.status) {
|
|
718
|
+
row._dot.className = 'twk-dot ' + t.status;
|
|
719
|
+
row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
|
|
720
|
+
}
|
|
721
|
+
const bits = [];
|
|
722
|
+
if (t.model) bits.push(t.model);
|
|
723
|
+
if (t.tokens === 0) bits.push('0 tokens');
|
|
724
|
+
if (t.durationMs) bits.push((t.durationMs / 1000).toFixed(1) + 's');
|
|
725
|
+
if (t.costUSD != null) bits.push('$' + t.costUSD.toFixed(3));
|
|
726
|
+
if (t.saved) bits.push(`saved ~$${t.saved.usd.toFixed(2)}`);
|
|
727
|
+
if (t.error) bits.push(t.error.slice(0, 80));
|
|
728
|
+
if (bits.length) row._meta.textContent = bits.join(' · ');
|
|
729
|
+
if (t.totals) showTotals(t.totals);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
const es = new EventSource(`${ORIGIN}/api/events`);
|
|
734
|
+
es.onmessage = (m) => {
|
|
735
|
+
const e = JSON.parse(m.data);
|
|
736
|
+
if (e.type === 'tweak') addTweak(e);
|
|
737
|
+
if (e.type === 'totals') showTotals(e.totals);
|
|
738
|
+
};
|
|
739
|
+
} catch { /* daemon offline */ }
|
|
740
|
+
fetch(`${ORIGIN}/api/health`)
|
|
741
|
+
.then((r) => r.json())
|
|
742
|
+
.then((h) => {
|
|
743
|
+
showTotals(h.totals);
|
|
744
|
+
if (h.tailwind === false) state.tailwind = false;
|
|
745
|
+
})
|
|
746
|
+
.catch(() => {});
|
|
747
|
+
|
|
748
|
+
// ---------- mode + events ----------
|
|
749
|
+
const hint = el('div', 'twk-hint', '⌘. select mode');
|
|
750
|
+
root.appendChild(hint);
|
|
751
|
+
|
|
752
|
+
function setMode(on) {
|
|
753
|
+
state.selectMode = on;
|
|
754
|
+
hint.textContent = on ? 'select mode — click an element · Esc to exit' : '⌘. select mode';
|
|
755
|
+
if (!on) { setHover(null); deselect(); }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
addEventListener('keydown', (e) => {
|
|
759
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '.') {
|
|
760
|
+
e.preventDefault();
|
|
761
|
+
setMode(!state.selectMode);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (!state.selectMode) return;
|
|
765
|
+
if (e.key === 'Escape') {
|
|
766
|
+
if (state.editing) return finishTextEdit(false);
|
|
767
|
+
if (state.selected) return deselect();
|
|
768
|
+
return setMode(false);
|
|
769
|
+
}
|
|
770
|
+
if (e.key === 'Enter' && state.editing) {
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
finishTextEdit(true);
|
|
773
|
+
}
|
|
774
|
+
}, true);
|
|
775
|
+
|
|
776
|
+
addEventListener('mousemove', (e) => {
|
|
777
|
+
if (!state.selectMode || state.editing) return;
|
|
778
|
+
if (inOverlay(e.target)) return setHover(null);
|
|
779
|
+
setHover(e.target instanceof Element ? e.target.closest('[data-twk]') : null);
|
|
780
|
+
}, true);
|
|
781
|
+
|
|
782
|
+
addEventListener('click', (e) => {
|
|
783
|
+
if (!state.selectMode) return;
|
|
784
|
+
if (inOverlay(e.target)) return;
|
|
785
|
+
if (state.editing) {
|
|
786
|
+
// clicks inside the editable text place the caret; clicks anywhere
|
|
787
|
+
// else save the edit (and never navigate/select mid-edit)
|
|
788
|
+
if (!state.editing.el.contains(e.target)) {
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
e.stopPropagation();
|
|
791
|
+
finishTextEdit(true);
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
e.preventDefault();
|
|
796
|
+
e.stopPropagation();
|
|
797
|
+
const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
|
|
798
|
+
if (target) select(target);
|
|
799
|
+
else deselect();
|
|
800
|
+
}, true);
|
|
801
|
+
|
|
802
|
+
addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
|
|
803
|
+
addEventListener('resize', () => reposition());
|
|
804
|
+
})();
|