tweaklocal 0.3.1 → 0.3.4

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.
@@ -16,6 +16,12 @@
16
16
  #twk-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
17
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
18
  .twk-outline.twk-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
19
+ .twk-multi{position:fixed;border:1.5px solid #f59e0b;border-radius:3px;background:rgba(245,158,11,.10);pointer-events:none}
20
+ .twk-multibar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:#111827;color:#f9fafb;border:1.5px solid #f59e0b;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:8px;pointer-events:auto;display:flex;gap:6px;align-items:center;font-size:12px;z-index:2147483001}
21
+ .twk-multibar input{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none;width:220px}
22
+ .twk-multibar button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
23
+ .twk-multibar button.twk-danger{background:#dc2626}
24
+ .twk-multibar .twk-count{color:#fbbf24;font-weight:700}
19
25
  .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
26
  .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
27
  .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)}
@@ -29,6 +35,8 @@
29
35
  .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
36
  .twk-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
31
37
  .twk-label{color:#9ca3af;min-width:50px}
38
+ .twk-note{color:#fbbf24;font-size:11px;line-height:1.35;background:rgba(245,158,11,.1);border-radius:6px;padding:4px 7px}
39
+ .twk-note.twk-info{color:#93c5fd;background:rgba(59,130,246,.12)}
32
40
  .twk-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
33
41
  .twk-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
34
42
  .twk-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
@@ -61,8 +69,13 @@
61
69
  selected: null, // { el, loc }
62
70
  editing: null, // { el, original }
63
71
  tailwind: true, // daemon reports whether the app uses Tailwind
72
+ model: 'auto', // NL model override; 'auto' = router picks
73
+ multi: [], // [{ el, loc }] shift-click multi-selection
64
74
  };
65
75
 
76
+ // Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
77
+ const undoStack = [];
78
+
66
79
  // ---------- helpers ----------
67
80
  const el = (tag, cls, text) => {
68
81
  const n = document.createElement(tag);
@@ -192,36 +205,48 @@
192
205
  deleteBtn.style.display = 'none';
193
206
  deleteBtn.title = 'Delete element';
194
207
  root.appendChild(deleteBtn);
208
+ // All instances of a mapped template share one source stamp; the DOM position
209
+ // of a given instance tells the daemon WHICH data item it is (list items
210
+ // render in array order).
211
+ function instanceIndex(elm, loc) {
212
+ const instances = [...document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`)];
213
+ const i = instances.indexOf(elm);
214
+ return i >= 0 ? i : undefined;
215
+ }
216
+
217
+ async function deleteOne({ el: elm, loc }) {
218
+ const payload = { loc, index: instanceIndex(elm, loc) };
219
+ // Dry-run first so a refusal never flickers the element.
220
+ await api('delete', { ...payload, dryRun: true });
221
+ if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
222
+ await api('delete', payload);
223
+ }
224
+
195
225
  deleteBtn.onclick = async (e) => {
196
226
  e.preventDefault();
197
227
  e.stopPropagation();
198
228
  const sel = state.selected;
199
229
  if (!sel || !document.contains(sel.el)) return;
200
- // Check first so a refusal never flickers the element or loses the
201
- // selection — only hide optimistically once we know it'll succeed.
202
230
  deleteBtn.disabled = true;
203
231
  try {
204
- await api('delete', { loc: sel.loc, dryRun: true });
232
+ await deleteOne(sel);
205
233
  } catch (err) {
234
+ if (document.contains(sel.el)) sel.el.style.display = '';
206
235
  deleteBtn.disabled = false;
207
- addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
236
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
208
237
  return;
209
238
  }
210
239
  deleteBtn.disabled = false;
211
- sel.el.style.display = 'none'; // optimistic; HMR makes it real
212
240
  deselect();
213
- try {
214
- await api('delete', { loc: sel.loc });
215
- } catch (err) {
216
- sel.el.style.display = '';
217
- addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message}` });
218
- }
219
241
  };
220
242
 
221
243
  function select(target) {
222
244
  finishTextEdit(false);
223
245
  const loc = target.getAttribute('data-twk');
224
- state.selected = { el: target, loc, meta: null };
246
+ // How many rendered elements map to this same source line — i.e. how many
247
+ // instances a shared-source edit (style/functionality) will change.
248
+ const instances = document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`).length;
249
+ state.selected = { el: target, loc, meta: null, instances };
225
250
  selBox.style.display = 'block';
226
251
  renderPopover();
227
252
  reposition();
@@ -252,6 +277,97 @@
252
277
  deleteBtn.style.display = 'none';
253
278
  }
254
279
 
280
+ // ---------- multi-select (shift-click) ----------
281
+ const multiBoxes = []; // pooled outline divs
282
+ const multiBar = el('div', 'twk-multibar');
283
+ multiBar.style.display = 'none';
284
+ root.appendChild(multiBar);
285
+
286
+ function toggleMulti(target) {
287
+ const loc = target.getAttribute('data-twk');
288
+ const i = state.multi.findIndex((m) => m.el === target);
289
+ if (i >= 0) state.multi.splice(i, 1);
290
+ else {
291
+ // leaving single-selection mode: fold the current single selection in too
292
+ if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
293
+ state.multi.push({ el: state.selected.el, loc: state.selected.loc });
294
+ }
295
+ if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
296
+ deselect();
297
+ }
298
+ renderMulti();
299
+ }
300
+
301
+ function clearMulti() {
302
+ state.multi = [];
303
+ renderMulti();
304
+ }
305
+
306
+ function positionMulti() {
307
+ state.multi.forEach((m, i) => {
308
+ if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'twk-multi'); root.appendChild(multiBoxes[i]); }
309
+ const box = multiBoxes[i];
310
+ if (!document.contains(m.el)) { box.style.display = 'none'; return; }
311
+ box.style.display = 'block';
312
+ positionBox(box, m.el);
313
+ });
314
+ for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
315
+ }
316
+
317
+ let multiInput;
318
+ function renderMulti() {
319
+ positionMulti();
320
+ if (!state.multi.length) { multiBar.style.display = 'none'; return; }
321
+ multiBar.style.display = 'flex';
322
+ multiBar.textContent = '';
323
+ multiBar.append(el('span', 'twk-count', String(state.multi.length)));
324
+ multiBar.append(el('span', null, 'selected'));
325
+ multiInput = el('input');
326
+ multiInput.placeholder = 'Describe a change for all…';
327
+ multiInput.value = multiBar._draft || '';
328
+ multiInput.oninput = () => { multiBar._draft = multiInput.value; };
329
+ multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
330
+ const apply = el('button', 'twk-primary', 'Apply');
331
+ apply.onclick = applyMultiNL;
332
+ const del = el('button', 'twk-danger', 'Delete all');
333
+ del.onclick = deleteMulti;
334
+ const clear = el('button', null, 'Clear');
335
+ clear.onclick = clearMulti;
336
+ multiBar.append(multiInput, apply, del, clear);
337
+ }
338
+
339
+ async function applyMultiNL() {
340
+ const instruction = (multiInput?.value || '').trim();
341
+ if (!instruction) return;
342
+ const targets = state.multi.slice();
343
+ multiBar._draft = '';
344
+ clearMulti();
345
+ for (const t of targets) {
346
+ try {
347
+ const r = await api('nl', { loc: t.loc, instruction, model: state.model });
348
+ addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
349
+ } catch (e) {
350
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
351
+ }
352
+ }
353
+ }
354
+
355
+ async function deleteMulti() {
356
+ const targets = state.multi.slice();
357
+ clearMulti();
358
+ // Delete bottom-up so a mapped-list removal doesn't shift the index of a
359
+ // not-yet-deleted sibling in the same list.
360
+ targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
361
+ for (const t of targets) {
362
+ try {
363
+ await deleteOne(t);
364
+ } catch (e) {
365
+ if (document.contains(t.el)) t.el.style.display = '';
366
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
367
+ }
368
+ }
369
+ }
370
+
255
371
  function reposition() {
256
372
  const s = state.selected;
257
373
  if (!s) return;
@@ -268,7 +384,7 @@
268
384
  const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
269
385
  Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
270
386
  popLabel.style.display = 'block';
271
- popLabel.textContent = shortLoc(s.loc);
387
+ popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
272
388
  Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
273
389
  deleteBtn.style.display = 'flex';
274
390
  const inset = 4;
@@ -322,12 +438,25 @@
322
438
  function fontStep(target, dir) {
323
439
  const scale = readTheme().textSizes;
324
440
  const classes = classList(target);
441
+ // An arbitrary size like `text-[34px]` also sets font-size and would
442
+ // override the scale class we add — remove it too, and seed the step from
443
+ // the element's rendered px so the first bump goes the right direction.
444
+ const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
325
445
  const cur = classes.find((c) => scale.includes(c));
326
- let idx = cur ? scale.indexOf(cur) : scale.indexOf('text-base');
446
+ let idx;
447
+ if (cur) idx = scale.indexOf(cur);
448
+ else {
449
+ const px = parseFloat(getComputedStyle(target).fontSize) || 16;
450
+ // nearest scale entry by rendered size, else middle
451
+ idx = scale.indexOf('text-base');
452
+ if (idx < 0) idx = Math.floor(scale.length / 2);
453
+ }
327
454
  if (idx < 0) idx = Math.floor(scale.length / 2);
328
455
  const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
329
- if (scale[next] === cur) return null;
330
- return { remove: cur ? [cur] : [], add: [scale[next]] };
456
+ const add = scale[next];
457
+ const remove = [...(cur ? [cur] : []), ...arbitrary];
458
+ if (add === cur && !arbitrary.length) return null;
459
+ return { remove, add: [add] };
331
460
  }
332
461
 
333
462
  // Property editor: which classes to strip when setting each property.
@@ -481,11 +610,24 @@
481
610
 
482
611
  // location now lives in the green tab above the panel (popLabel)
483
612
 
613
+ // Reused component: this source line renders in >1 place, so style and
614
+ // functionality edits (which change the shared source) apply everywhere.
615
+ // Copy is the exception — it must stay on this one instance (below).
616
+ const shared = s.instances > 1;
617
+ if (shared) {
618
+ pop.appendChild(el('div', 'twk-note twk-info', `Reused component — ${s.instances} instances. Style & functionality changes apply to all of them.`));
619
+ }
620
+
484
621
  // Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
485
622
  // DOM: animation libs split text into spans and expressions render as
486
623
  // text, so DOM shape says nothing about what's editable in the JSX.
487
624
  const literals = s.meta ? s.meta.texts : null;
488
- if (literals && literals.length) {
625
+ if (shared && literals && literals.length) {
626
+ // The text literal is shared by every instance, so editing it here would
627
+ // change all of them — which is not what a copy edit should do. Keep copy
628
+ // local: block the in-place edit and point to the per-instance route.
629
+ pop.appendChild(el('div', 'twk-note', 'Copy here is shared across all instances — editing it would change every one. To change just this instance, make its text a prop/data value (describe it below and it\'ll route through the model).'));
630
+ } else if (literals && literals.length) {
489
631
  // In-place editing only when the single literal IS the element's whole
490
632
  // text (animation-split DOM still qualifies — same text, different
491
633
  // nodes). Partial literals (mixed with {expressions}) get input fields
@@ -608,14 +750,14 @@
608
750
 
609
751
  const nlRow = el('div', 'twk-row');
610
752
  const input = el('input');
611
- input.placeholder = 'Describe a change… (routed to the right model)';
753
+ input.placeholder = 'Describe a change…';
612
754
  const go = el('button', 'twk-primary', 'Go');
613
755
  const send = async () => {
614
756
  const instruction = input.value.trim();
615
757
  if (!instruction) return;
616
758
  input.value = '';
617
759
  try {
618
- const r = await api('nl', { loc: s.loc, instruction });
760
+ const r = await api('nl', { loc: s.loc, instruction, model: state.model });
619
761
  addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
620
762
  } catch (e) {
621
763
  addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
@@ -625,6 +767,20 @@
625
767
  input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
626
768
  nlRow.append(input, go);
627
769
  pop.appendChild(nlRow);
770
+
771
+ // Model picker: Auto (router) or a forced tier.
772
+ const modelRow = el('div', 'twk-row');
773
+ modelRow.append(el('span', 'twk-label', 'Model'));
774
+ const modelSel = el('select');
775
+ for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
776
+ const o = el('option', null, label);
777
+ o.value = value;
778
+ if (value === state.model) o.selected = true;
779
+ modelSel.appendChild(o);
780
+ }
781
+ modelSel.onchange = () => { state.model = modelSel.value; };
782
+ modelRow.append(modelSel);
783
+ pop.appendChild(modelRow);
628
784
  // delete lives as a floating icon on the element itself (deleteBtn)
629
785
  }
630
786
 
@@ -733,7 +889,14 @@
733
889
  row._dot.className = 'twk-dot ' + t.status;
734
890
  const inFlight = t.status === 'queued' || t.status === 'running';
735
891
  row._cancel.style.display = inFlight ? '' : 'none';
736
- row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
892
+ const undoable = t.status === 'done' && !String(t.id).startsWith('x');
893
+ row._undo.style.display = undoable ? '' : 'none';
894
+ const key = String(t.id);
895
+ if (undoable && !undoStack.includes(key)) undoStack.push(key);
896
+ if (t.status === 'reverted') {
897
+ const i = undoStack.indexOf(key);
898
+ if (i >= 0) undoStack.splice(i, 1);
899
+ }
737
900
  }
738
901
  const bits = [];
739
902
  if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
@@ -768,22 +931,64 @@
768
931
 
769
932
  function setMode(on) {
770
933
  state.selectMode = on;
771
- hint.textContent = on ? 'select mode — click an element · Esc to exit' : '⌘. select mode';
772
- if (!on) { setHover(null); deselect(); }
934
+ hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘. select mode';
935
+ if (!on) { setHover(null); deselect(); clearMulti(); }
773
936
  }
774
937
 
938
+ // Visible, stamped elements in document order — the Tab cycle.
939
+ function stampedEls() {
940
+ return [...document.querySelectorAll('[data-twk]')].filter((n) => n.getClientRects().length);
941
+ }
942
+ function selectNext(dir) {
943
+ const els = stampedEls();
944
+ if (!els.length) return;
945
+ const cur = state.selected?.el;
946
+ let idx = cur ? els.indexOf(cur) : -1;
947
+ idx = (idx + dir + els.length) % els.length;
948
+ const next = els[idx];
949
+ select(next);
950
+ next.scrollIntoView({ block: 'center', behavior: 'smooth' });
951
+ }
952
+
953
+ async function undoLast() {
954
+ const id = undoStack.pop();
955
+ if (id == null) return;
956
+ try {
957
+ await api('undo', { id });
958
+ setTimeout(reposition, 350);
959
+ } catch { /* already reverted / unknown — drop it */ }
960
+ }
961
+
962
+ // ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
963
+ const inTextField = (t) =>
964
+ t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
965
+
775
966
  addEventListener('keydown', (e) => {
776
967
  if ((e.metaKey || e.ctrlKey) && e.key === '.') {
777
968
  e.preventDefault();
778
969
  setMode(!state.selectMode);
779
970
  return;
780
971
  }
972
+ // Global undo (works whenever the overlay is active, not just in select
973
+ // mode) — but never hijack undo inside a text field or mid copy-edit.
974
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
975
+ if (state.editing || inTextField(e.target)) return;
976
+ e.preventDefault();
977
+ undoLast();
978
+ return;
979
+ }
781
980
  if (!state.selectMode) return;
782
981
  if (e.key === 'Escape') {
783
982
  if (state.editing) return finishTextEdit(false);
983
+ if (state.multi.length) return clearMulti();
784
984
  if (state.selected) return deselect();
785
985
  return setMode(false);
786
986
  }
987
+ if (e.key === 'Tab' && !state.editing) {
988
+ e.preventDefault();
989
+ selectNext(e.shiftKey ? -1 : 1);
990
+ return;
991
+ }
787
992
  if (e.key === 'Enter' && state.editing) {
788
993
  e.preventDefault();
789
994
  finishTextEdit(true);
@@ -812,10 +1017,13 @@
812
1017
  e.preventDefault();
813
1018
  e.stopPropagation();
814
1019
  const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
1020
+ // Shift-click toggles an element in the multi-selection set.
1021
+ if (target && e.shiftKey) return toggleMulti(target);
1022
+ if (state.multi.length) clearMulti();
815
1023
  if (target) select(target);
816
1024
  else deselect();
817
1025
  }, true);
818
1026
 
819
- addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
820
- addEventListener('resize', () => reposition());
1027
+ addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1028
+ addEventListener('resize', () => { reposition(); positionMulti(); });
821
1029
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/resolver.js CHANGED
@@ -90,30 +90,106 @@ export function applyTextEdit(root, loc, oldText, newText) {
90
90
  return writeChecked(abs, content, next);
91
91
  }
92
92
 
93
+ // Rebuild a template-literal className, editing only the STATIC class tokens
94
+ // and keeping every `${...}` expression verbatim. Returns the `...` source.
95
+ function rebuildTemplateClasses(content, expr, removes, adds) {
96
+ const SENT = '\u0000';
97
+ const parts = [];
98
+ expr.quasis.forEach((q, i) => {
99
+ parts.push(q.value.raw);
100
+ if (i < expr.expressions.length) parts.push(SENT);
101
+ });
102
+ let tokens = parts
103
+ .join('')
104
+ .split(/\s+/)
105
+ .filter(Boolean)
106
+ .flatMap((t) => {
107
+ if (!t.includes(SENT)) return [t];
108
+ const out = [];
109
+ t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
110
+ return out;
111
+ });
112
+ tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
113
+ for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
114
+ let ei = 0;
115
+ const rebuilt = tokens
116
+ .map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
117
+ .join(' ');
118
+ return '`' + rebuilt + '`';
119
+ }
120
+
121
+ // Resolve a local `const name = "..."` (or template) referenced by
122
+ // className={name}. Returns the string/template literal node, or null.
123
+ function resolveClassConst(ast, name) {
124
+ let found = null;
125
+ walk(ast, (n) => {
126
+ if (found) return;
127
+ if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === name) {
128
+ const init = unwrapExpr(n.init);
129
+ if (init && (init.type === 'StringLiteral' || init.type === 'TemplateLiteral')) found = init;
130
+ }
131
+ });
132
+ return found;
133
+ }
134
+
135
+ // The literal node we can actually edit for a className attribute — the
136
+ // attribute's own string/template, or the `const` it references.
137
+ function classValueNode(ast, attr) {
138
+ const v = attr.value;
139
+ if (v && v.type === 'StringLiteral') return v;
140
+ const expr = v && v.type === 'JSXExpressionContainer' ? v.expression : null;
141
+ if (!expr) return null;
142
+ if (expr.type === 'StringLiteral' || expr.type === 'TemplateLiteral') return expr;
143
+ if (expr.type === 'Identifier') return resolveClassConst(ast, expr.name);
144
+ return null;
145
+ }
146
+
93
147
  export function applyClassEdit(root, loc, removes = [], adds = []) {
94
- const { abs, content, element } = loadTarget(root, loc);
148
+ const { abs, content, element, ast } = loadTarget(root, loc);
95
149
  const opening = element.openingElement;
96
- const attr = opening.attributes.find(
97
- (a) =>
98
- a.type === 'JSXAttribute' &&
99
- a.name.name === 'className' &&
100
- a.value &&
101
- a.value.type === 'StringLiteral'
150
+ const classAttrs = opening.attributes.filter(
151
+ (a) => a.type === 'JSXAttribute' && a.name.name === 'className'
102
152
  );
103
- let next;
104
- if (attr) {
105
- let classes = attr.value.value.split(/\s+/).filter(Boolean);
106
- classes = classes.filter((c) => !removes.includes(c));
153
+
154
+ const editList = (list) => {
155
+ let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
107
156
  for (const a of adds) if (!classes.includes(a)) classes.push(a);
108
- next =
109
- content.slice(0, attr.value.start + 1) +
110
- classes.join(' ') +
111
- content.slice(attr.value.end - 1);
112
- } else {
157
+ return classes.join(' ');
158
+ };
159
+
160
+ // No className yet — add a fresh string attribute.
161
+ if (!classAttrs.length) {
113
162
  const pos = opening.name.end;
114
- next =
115
- content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
163
+ return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
116
164
  }
165
+
166
+ // When an element has DUPLICATE className attributes (a corruption the old
167
+ // class editor could introduce), React renders only the LAST one. Edit that
168
+ // effective className, and strip the earlier dead duplicates so the element
169
+ // ends up valid and single-className.
170
+ const attr = classAttrs[classAttrs.length - 1];
171
+ const node = classValueNode(ast, attr);
172
+ if (!node) {
173
+ throw new Error(
174
+ "className here is computed, so I can't edit its classes deterministically. Use the size/color controls (inline style) or describe the change to route it through the model."
175
+ );
176
+ }
177
+
178
+ const replacement =
179
+ node.type === 'TemplateLiteral'
180
+ ? rebuildTemplateClasses(content, node, removes, adds)
181
+ : JSON.stringify(editList(node.value));
182
+
183
+ // Apply the value replacement plus removal of each dead duplicate attribute
184
+ // (with its leading space), right-to-left so offsets stay valid.
185
+ const edits = [{ start: node.start, end: node.end, text: replacement }];
186
+ for (const d of classAttrs.slice(0, -1)) {
187
+ const start = content[d.start - 1] === ' ' ? d.start - 1 : d.start;
188
+ edits.push({ start, end: d.end, text: '' });
189
+ }
190
+ edits.sort((a, b) => b.start - a.start);
191
+ let next = content;
192
+ for (const e of edits) next = next.slice(0, e.start) + e.text + next.slice(e.end);
117
193
  return writeChecked(abs, content, next);
118
194
  }
119
195
 
@@ -255,16 +331,17 @@ function nameOfFunction(ast, fn) {
255
331
 
256
332
  // Walk up from the element looking for a `{...}` JSX expression whose whole
257
333
  // removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
258
- // (list template: removing it removes the rendered list). Never crosses a
259
- // function boundary except an inline callback (a function that is itself a
260
- // call argument, e.g. the .map render callback).
334
+ // (list template). Never crosses a function boundary except an inline callback
335
+ // (a function that is itself a call argument, e.g. the .map render callback).
336
+ // For lists, also returns the map CallExpression so the caller can target the
337
+ // underlying data array instead of the whole list.
261
338
  function removableExpressionContainer(chain) {
262
339
  for (let i = 1; i < chain.length; i++) {
263
340
  const node = chain[i];
264
341
  if (node.type === 'JSXExpressionContainer') {
265
342
  const inner = chain[i - 1];
266
343
  const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
267
- return { container: node, kind: isList ? 'list' : 'conditional block' };
344
+ return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
268
345
  }
269
346
  if (FN_TYPES.has(node.type)) {
270
347
  // crossing a function is only allowed for inline callbacks (map etc.)
@@ -275,6 +352,68 @@ function removableExpressionContainer(chain) {
275
352
  return null;
276
353
  }
277
354
 
355
+ // Unwrap TS wrappers around an initializer (as const / satisfies / parens).
356
+ function unwrapExpr(node) {
357
+ let n = node;
358
+ while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
359
+ n = n.expression;
360
+ return n;
361
+ }
362
+
363
+ // For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
364
+ // ArrayExpression in the same file. Returns the array node or null.
365
+ function resolveMappedArray(ast, mapCall) {
366
+ let call = mapCall;
367
+ // chain may have handed us the arrow (expression-body case) — find the call
368
+ if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
369
+ if (!call || call.type !== 'CallExpression') return null;
370
+ const callee = call.callee;
371
+ if (
372
+ callee?.type !== 'MemberExpression' ||
373
+ callee.property?.type !== 'Identifier' ||
374
+ !['map', 'flatMap'].includes(callee.property.name) ||
375
+ callee.object?.type !== 'Identifier'
376
+ ) return null;
377
+ const arrName = callee.object.name;
378
+ let arr = null;
379
+ walk(ast, (n) => {
380
+ if (arr) return;
381
+ if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
382
+ const init = unwrapExpr(n.init);
383
+ if (init?.type === 'ArrayExpression') arr = init;
384
+ }
385
+ });
386
+ return arr;
387
+ }
388
+
389
+ // Remove the k-th element of an ArrayExpression, handling commas and whole
390
+ // lines. Returns new content, or null if the result doesn't parse.
391
+ function removeArrayElement(content, arrayNode, k, file) {
392
+ const el = arrayNode.elements[k];
393
+ if (!el) return null;
394
+ let start = el.start;
395
+ let end = el.end;
396
+ const after = content.slice(end).match(/^\s*,/);
397
+ if (after) end += after[0].length;
398
+ else {
399
+ const before = content.slice(0, start).match(/,\s*$/);
400
+ if (before) start -= before[0].length;
401
+ }
402
+ const lineStart = content.lastIndexOf('\n', start - 1) + 1;
403
+ const trailing = content.slice(end).match(/^[ \t]*\n/);
404
+ if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
405
+ start = lineStart;
406
+ end += trailing[0].length;
407
+ }
408
+ const next = content.slice(0, start) + content.slice(end);
409
+ try {
410
+ parseSource(next, file);
411
+ return next;
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
278
417
  // Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
279
418
  function findUsages(root, name) {
280
419
  const usages = [];
@@ -309,15 +448,16 @@ function findUsages(root, name) {
309
448
  /**
310
449
  * Delete behavior, in order:
311
450
  * 1. Normal element (has siblings): remove it in place.
312
- * 2. Element inside a `{...}` JSX expression whose removal parses — a list
313
- * template (`{arr.map(...)}`) or a conditional (`{cond && <el/>}`):
314
- * remove the whole expression. Deleting a list template removes the
315
- * rendered list that's what the on-screen "container" is.
316
- * 3. Sole return of a NAMED component used in exactly one place: remove that
451
+ * 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
452
+ * identifies the data item remove that ONE entry from the local array
453
+ * literal. Deleting one card removes one card, not the list. (To remove a
454
+ * whole list, select its surrounding wrapper element instead.)
455
+ * 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
456
+ * 4. Sole return of a NAMED component used in exactly one place: remove that
317
457
  * usage at its call site.
318
458
  * Otherwise refuse with an actionable message (ambiguous → model lane).
319
459
  */
320
- export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
460
+ export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
321
461
  // reuse loadTarget's AST — element identity must hold for the ancestor walk
322
462
  const { abs, content, element, file, ast } = loadTarget(root, loc);
323
463
 
@@ -331,6 +471,22 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
331
471
  const chain = ancestorChain(ast, element);
332
472
 
333
473
  const wrapper = removableExpressionContainer(chain);
474
+ if (wrapper && wrapper.kind === 'list') {
475
+ const arr = resolveMappedArray(ast, wrapper.mapCall);
476
+ if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
477
+ const next = removeArrayElement(content, arr, index, file);
478
+ if (next !== null) {
479
+ const detail = `item ${index + 1} of ${arr.elements.length}`;
480
+ if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
481
+ return { ...writeChecked(abs, content, next), removedBlock: detail };
482
+ }
483
+ }
484
+ // data not editable deterministically (imported/computed array, or the
485
+ // instance index is unknown) — never silently delete the whole list
486
+ throw new Error(
487
+ "this is one item in a rendered list whose data I can't safely edit here. Describe the change (e.g. \"remove the second stat\") and it'll route through the model — or select the list's surrounding container to delete the whole list."
488
+ );
489
+ }
334
490
  if (wrapper) {
335
491
  const next = removeElementFromContent(content, wrapper.container, file);
336
492
  if (next !== null) {
package/src/server.js CHANGED
@@ -128,12 +128,12 @@ export function startServer({ root, port = 4100 }) {
128
128
 
129
129
  if (url.pathname === '/api/delete') {
130
130
  if (body.dryRun) {
131
- applyDeleteElement(root, body.loc, { dryRun: true }); // throws if unsafe, writes nothing
131
+ applyDeleteElement(root, body.loc, { dryRun: true, index: body.index }); // throws if unsafe, writes nothing
132
132
  return json(res, { ok: true, dryRun: true });
133
133
  }
134
134
  const id = nextId++;
135
135
  const target = describeTarget(root, body.loc);
136
- const write = applyDeleteElement(root, body.loc);
136
+ const write = applyDeleteElement(root, body.loc, { index: body.index });
137
137
  remember(id, write);
138
138
  telemetry.record('delete');
139
139
  const label = write.removedUsage
@@ -173,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
173
173
  const { file } = parseLoc(body.loc);
174
174
  const target = describeTarget(root, body.loc);
175
175
  const route = await classify(body.instruction, { cwd: root });
176
+ // Manual model override from the picker: keep the router's effort/tier
177
+ // (still sized to the request) but force the model the user chose.
178
+ if (body.model && body.model !== 'auto') {
179
+ route.model = body.model;
180
+ route.tier = 'manual';
181
+ }
176
182
  const abs = path.resolve(root, file);
177
183
  remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
178
184
  telemetry.record('nl');