tweaklocal 0.3.0 → 0.3.3

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 CHANGED
@@ -4,7 +4,20 @@ Tweak your UI live in the browser — the changes are written straight to your s
4
4
 
5
5
  - **Copy**: click any text, edit in place, Enter. Written to the JSX literal. 0 tokens.
6
6
  - **Style**: padding/margin per side, font sizes and colors from *your* design system tokens. Deterministic Tailwind class edits. 0 tokens.
7
- - **Anything else**: describe it — routed to a fast model for style/copy or a reasoning model for functionality (headless `claude -p`), scoped to the exact file your selection maps to.
7
+ - **Anything else**: describe it — a layered router picks a model *and* an effort level sized to the request, then runs headless `claude -p` scoped to the exact file your selection maps to.
8
+
9
+ ## Model routing
10
+
11
+ | Tier | What it covers | Model | Effort (by complexity) |
12
+ |---|---|---|---|
13
+ | 1 | Styles, copy, component tweaks, existing components | `claude-sonnet-5` | low / medium / high |
14
+ | 2 | New logic & functionality (handlers, state, forms, data) | `claude-opus-4-8` | medium / high / xhigh |
15
+ | 3 | Multi-feature or cross-cutting work | `claude-opus-4-8` | xhigh |
16
+ | 3+ | Mission-critical (auth, payments, data, security) or very high complexity | `claude-fable-5` | high |
17
+
18
+ Routing is layered (RouteLLM-style): deterministic lexicon + structure scoring first (<1ms, free); requests with no clear signal fall through to a Haiku classifier with structured output (~1–3s, ~$0.001). Inspect any routing decision without running it: `POST /api/classify {"instruction": "..."}`.
19
+
20
+ Overrides: `TWEAKLOCAL_T1_MODEL`, `TWEAKLOCAL_T2_MODEL`, `TWEAKLOCAL_T3_MODEL`, `TWEAKLOCAL_T3_CRITICAL_MODEL`, and `TWEAKLOCAL_ROUTER=heuristic|hybrid|llm` (default `hybrid`).
8
21
 
9
22
  ## Quickstart (Next.js)
10
23
 
@@ -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)}
@@ -61,8 +67,13 @@
61
67
  selected: null, // { el, loc }
62
68
  editing: null, // { el, original }
63
69
  tailwind: true, // daemon reports whether the app uses Tailwind
70
+ model: 'auto', // NL model override; 'auto' = router picks
71
+ multi: [], // [{ el, loc }] shift-click multi-selection
64
72
  };
65
73
 
74
+ // Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
75
+ const undoStack = [];
76
+
66
77
  // ---------- helpers ----------
67
78
  const el = (tag, cls, text) => {
68
79
  const n = document.createElement(tag);
@@ -192,30 +203,39 @@
192
203
  deleteBtn.style.display = 'none';
193
204
  deleteBtn.title = 'Delete element';
194
205
  root.appendChild(deleteBtn);
206
+ // All instances of a mapped template share one source stamp; the DOM position
207
+ // of a given instance tells the daemon WHICH data item it is (list items
208
+ // render in array order).
209
+ function instanceIndex(elm, loc) {
210
+ const instances = [...document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`)];
211
+ const i = instances.indexOf(elm);
212
+ return i >= 0 ? i : undefined;
213
+ }
214
+
215
+ async function deleteOne({ el: elm, loc }) {
216
+ const payload = { loc, index: instanceIndex(elm, loc) };
217
+ // Dry-run first so a refusal never flickers the element.
218
+ await api('delete', { ...payload, dryRun: true });
219
+ if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
220
+ await api('delete', payload);
221
+ }
222
+
195
223
  deleteBtn.onclick = async (e) => {
196
224
  e.preventDefault();
197
225
  e.stopPropagation();
198
226
  const sel = state.selected;
199
227
  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
228
  deleteBtn.disabled = true;
203
229
  try {
204
- await api('delete', { loc: sel.loc, dryRun: true });
230
+ await deleteOne(sel);
205
231
  } catch (err) {
232
+ if (document.contains(sel.el)) sel.el.style.display = '';
206
233
  deleteBtn.disabled = false;
207
- addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
234
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
208
235
  return;
209
236
  }
210
237
  deleteBtn.disabled = false;
211
- sel.el.style.display = 'none'; // optimistic; HMR makes it real
212
238
  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
239
  };
220
240
 
221
241
  function select(target) {
@@ -252,6 +272,97 @@
252
272
  deleteBtn.style.display = 'none';
253
273
  }
254
274
 
275
+ // ---------- multi-select (shift-click) ----------
276
+ const multiBoxes = []; // pooled outline divs
277
+ const multiBar = el('div', 'twk-multibar');
278
+ multiBar.style.display = 'none';
279
+ root.appendChild(multiBar);
280
+
281
+ function toggleMulti(target) {
282
+ const loc = target.getAttribute('data-twk');
283
+ const i = state.multi.findIndex((m) => m.el === target);
284
+ if (i >= 0) state.multi.splice(i, 1);
285
+ else {
286
+ // leaving single-selection mode: fold the current single selection in too
287
+ if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
288
+ state.multi.push({ el: state.selected.el, loc: state.selected.loc });
289
+ }
290
+ if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
291
+ deselect();
292
+ }
293
+ renderMulti();
294
+ }
295
+
296
+ function clearMulti() {
297
+ state.multi = [];
298
+ renderMulti();
299
+ }
300
+
301
+ function positionMulti() {
302
+ state.multi.forEach((m, i) => {
303
+ if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'twk-multi'); root.appendChild(multiBoxes[i]); }
304
+ const box = multiBoxes[i];
305
+ if (!document.contains(m.el)) { box.style.display = 'none'; return; }
306
+ box.style.display = 'block';
307
+ positionBox(box, m.el);
308
+ });
309
+ for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
310
+ }
311
+
312
+ let multiInput;
313
+ function renderMulti() {
314
+ positionMulti();
315
+ if (!state.multi.length) { multiBar.style.display = 'none'; return; }
316
+ multiBar.style.display = 'flex';
317
+ multiBar.textContent = '';
318
+ multiBar.append(el('span', 'twk-count', String(state.multi.length)));
319
+ multiBar.append(el('span', null, 'selected'));
320
+ multiInput = el('input');
321
+ multiInput.placeholder = 'Describe a change for all…';
322
+ multiInput.value = multiBar._draft || '';
323
+ multiInput.oninput = () => { multiBar._draft = multiInput.value; };
324
+ multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
325
+ const apply = el('button', 'twk-primary', 'Apply');
326
+ apply.onclick = applyMultiNL;
327
+ const del = el('button', 'twk-danger', 'Delete all');
328
+ del.onclick = deleteMulti;
329
+ const clear = el('button', null, 'Clear');
330
+ clear.onclick = clearMulti;
331
+ multiBar.append(multiInput, apply, del, clear);
332
+ }
333
+
334
+ async function applyMultiNL() {
335
+ const instruction = (multiInput?.value || '').trim();
336
+ if (!instruction) return;
337
+ const targets = state.multi.slice();
338
+ multiBar._draft = '';
339
+ clearMulti();
340
+ for (const t of targets) {
341
+ try {
342
+ const r = await api('nl', { loc: t.loc, instruction, model: state.model });
343
+ addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
344
+ } catch (e) {
345
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
346
+ }
347
+ }
348
+ }
349
+
350
+ async function deleteMulti() {
351
+ const targets = state.multi.slice();
352
+ clearMulti();
353
+ // Delete bottom-up so a mapped-list removal doesn't shift the index of a
354
+ // not-yet-deleted sibling in the same list.
355
+ targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
356
+ for (const t of targets) {
357
+ try {
358
+ await deleteOne(t);
359
+ } catch (e) {
360
+ if (document.contains(t.el)) t.el.style.display = '';
361
+ addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
362
+ }
363
+ }
364
+ }
365
+
255
366
  function reposition() {
256
367
  const s = state.selected;
257
368
  if (!s) return;
@@ -322,12 +433,25 @@
322
433
  function fontStep(target, dir) {
323
434
  const scale = readTheme().textSizes;
324
435
  const classes = classList(target);
436
+ // An arbitrary size like `text-[34px]` also sets font-size and would
437
+ // override the scale class we add — remove it too, and seed the step from
438
+ // the element's rendered px so the first bump goes the right direction.
439
+ const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
325
440
  const cur = classes.find((c) => scale.includes(c));
326
- let idx = cur ? scale.indexOf(cur) : scale.indexOf('text-base');
441
+ let idx;
442
+ if (cur) idx = scale.indexOf(cur);
443
+ else {
444
+ const px = parseFloat(getComputedStyle(target).fontSize) || 16;
445
+ // nearest scale entry by rendered size, else middle
446
+ idx = scale.indexOf('text-base');
447
+ if (idx < 0) idx = Math.floor(scale.length / 2);
448
+ }
327
449
  if (idx < 0) idx = Math.floor(scale.length / 2);
328
450
  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]] };
451
+ const add = scale[next];
452
+ const remove = [...(cur ? [cur] : []), ...arbitrary];
453
+ if (add === cur && !arbitrary.length) return null;
454
+ return { remove, add: [add] };
331
455
  }
332
456
 
333
457
  // Property editor: which classes to strip when setting each property.
@@ -608,14 +732,14 @@
608
732
 
609
733
  const nlRow = el('div', 'twk-row');
610
734
  const input = el('input');
611
- input.placeholder = 'Describe a change… (routed to the right model)';
735
+ input.placeholder = 'Describe a change…';
612
736
  const go = el('button', 'twk-primary', 'Go');
613
737
  const send = async () => {
614
738
  const instruction = input.value.trim();
615
739
  if (!instruction) return;
616
740
  input.value = '';
617
741
  try {
618
- const r = await api('nl', { loc: s.loc, instruction });
742
+ const r = await api('nl', { loc: s.loc, instruction, model: state.model });
619
743
  addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
620
744
  } catch (e) {
621
745
  addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
@@ -625,6 +749,20 @@
625
749
  input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
626
750
  nlRow.append(input, go);
627
751
  pop.appendChild(nlRow);
752
+
753
+ // Model picker: Auto (router) or a forced tier.
754
+ const modelRow = el('div', 'twk-row');
755
+ modelRow.append(el('span', 'twk-label', 'Model'));
756
+ const modelSel = el('select');
757
+ for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
758
+ const o = el('option', null, label);
759
+ o.value = value;
760
+ if (value === state.model) o.selected = true;
761
+ modelSel.appendChild(o);
762
+ }
763
+ modelSel.onchange = () => { state.model = modelSel.value; };
764
+ modelRow.append(modelSel);
765
+ pop.appendChild(modelRow);
628
766
  // delete lives as a floating icon on the element itself (deleteBtn)
629
767
  }
630
768
 
@@ -733,7 +871,14 @@
733
871
  row._dot.className = 'twk-dot ' + t.status;
734
872
  const inFlight = t.status === 'queued' || t.status === 'running';
735
873
  row._cancel.style.display = inFlight ? '' : 'none';
736
- row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
874
+ const undoable = t.status === 'done' && !String(t.id).startsWith('x');
875
+ row._undo.style.display = undoable ? '' : 'none';
876
+ const key = String(t.id);
877
+ if (undoable && !undoStack.includes(key)) undoStack.push(key);
878
+ if (t.status === 'reverted') {
879
+ const i = undoStack.indexOf(key);
880
+ if (i >= 0) undoStack.splice(i, 1);
881
+ }
737
882
  }
738
883
  const bits = [];
739
884
  if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
@@ -768,22 +913,64 @@
768
913
 
769
914
  function setMode(on) {
770
915
  state.selectMode = on;
771
- hint.textContent = on ? 'select mode — click an element · Esc to exit' : '⌘. select mode';
772
- if (!on) { setHover(null); deselect(); }
916
+ hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘. select mode';
917
+ if (!on) { setHover(null); deselect(); clearMulti(); }
773
918
  }
774
919
 
920
+ // Visible, stamped elements in document order — the Tab cycle.
921
+ function stampedEls() {
922
+ return [...document.querySelectorAll('[data-twk]')].filter((n) => n.getClientRects().length);
923
+ }
924
+ function selectNext(dir) {
925
+ const els = stampedEls();
926
+ if (!els.length) return;
927
+ const cur = state.selected?.el;
928
+ let idx = cur ? els.indexOf(cur) : -1;
929
+ idx = (idx + dir + els.length) % els.length;
930
+ const next = els[idx];
931
+ select(next);
932
+ next.scrollIntoView({ block: 'center', behavior: 'smooth' });
933
+ }
934
+
935
+ async function undoLast() {
936
+ const id = undoStack.pop();
937
+ if (id == null) return;
938
+ try {
939
+ await api('undo', { id });
940
+ setTimeout(reposition, 350);
941
+ } catch { /* already reverted / unknown — drop it */ }
942
+ }
943
+
944
+ // ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
945
+ const inTextField = (t) =>
946
+ t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
947
+
775
948
  addEventListener('keydown', (e) => {
776
949
  if ((e.metaKey || e.ctrlKey) && e.key === '.') {
777
950
  e.preventDefault();
778
951
  setMode(!state.selectMode);
779
952
  return;
780
953
  }
954
+ // Global undo (works whenever the overlay is active, not just in select
955
+ // mode) — but never hijack undo inside a text field or mid copy-edit.
956
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
957
+ if (state.editing || inTextField(e.target)) return;
958
+ e.preventDefault();
959
+ undoLast();
960
+ return;
961
+ }
781
962
  if (!state.selectMode) return;
782
963
  if (e.key === 'Escape') {
783
964
  if (state.editing) return finishTextEdit(false);
965
+ if (state.multi.length) return clearMulti();
784
966
  if (state.selected) return deselect();
785
967
  return setMode(false);
786
968
  }
969
+ if (e.key === 'Tab' && !state.editing) {
970
+ e.preventDefault();
971
+ selectNext(e.shiftKey ? -1 : 1);
972
+ return;
973
+ }
787
974
  if (e.key === 'Enter' && state.editing) {
788
975
  e.preventDefault();
789
976
  finishTextEdit(true);
@@ -812,10 +999,13 @@
812
999
  e.preventDefault();
813
1000
  e.stopPropagation();
814
1001
  const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
1002
+ // Shift/⌘-click toggles an element in the multi-selection set.
1003
+ if (target && (e.shiftKey || e.metaKey || e.ctrlKey)) return toggleMulti(target);
1004
+ if (state.multi.length) clearMulti();
815
1005
  if (target) select(target);
816
1006
  else deselect();
817
1007
  }, true);
818
1008
 
819
- addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
820
- addEventListener('resize', () => reposition());
1009
+ addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
1010
+ addEventListener('resize', () => { reposition(); positionMulti(); });
821
1011
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
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
@@ -49,7 +49,7 @@ export function loadTarget(root, loc) {
49
49
  });
50
50
  element = element || nearMiss;
51
51
  if (!element) throw new Error(`no JSX element at ${loc}`);
52
- return { file, abs, content, element };
52
+ return { file, abs, content, element, ast };
53
53
  }
54
54
 
55
55
  export function describeTarget(root, loc) {
@@ -94,27 +94,72 @@ export function applyClassEdit(root, loc, removes = [], adds = []) {
94
94
  const { abs, content, element } = loadTarget(root, loc);
95
95
  const opening = element.openingElement;
96
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'
97
+ (a) => a.type === 'JSXAttribute' && a.name.name === 'className'
102
98
  );
103
- let next;
104
- if (attr) {
105
- let classes = attr.value.value.split(/\s+/).filter(Boolean);
106
- classes = classes.filter((c) => !removes.includes(c));
99
+
100
+ const editList = (list) => {
101
+ let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
107
102
  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 {
103
+ return classes.join(' ');
104
+ };
105
+
106
+ // No className yet — add a fresh string attribute.
107
+ if (!attr) {
113
108
  const pos = opening.name.end;
114
- next =
115
- content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
109
+ return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
116
110
  }
117
- return writeChecked(abs, content, next);
111
+
112
+ // className="..." (plain string attribute)
113
+ if (attr.value && attr.value.type === 'StringLiteral') {
114
+ const next = content.slice(0, attr.value.start + 1) + editList(attr.value.value) + content.slice(attr.value.end - 1);
115
+ return writeChecked(abs, content, next);
116
+ }
117
+
118
+ const expr = attr.value && attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null;
119
+
120
+ // className={"..."} / className={'...'} (string inside an expression)
121
+ if (expr && expr.type === 'StringLiteral') {
122
+ const next = content.slice(0, expr.start) + JSON.stringify(editList(expr.value)) + content.slice(expr.end);
123
+ return writeChecked(abs, content, next);
124
+ }
125
+
126
+ // className={`... ${dyn} ...`} — edit only the STATIC class tokens; keep the
127
+ // dynamic `${...}` expressions verbatim, and stay a single className. (The
128
+ // old code appended a second className attribute here, which React silently
129
+ // ignored — the reported "font change didn't persist" bug.)
130
+ if (expr && expr.type === 'TemplateLiteral') {
131
+ const SENT = '\u0000'; // placeholder marking where each `${expr}` sits
132
+ const parts = [];
133
+ expr.quasis.forEach((q, i) => {
134
+ parts.push(q.value.raw);
135
+ if (i < expr.expressions.length) parts.push(SENT);
136
+ });
137
+ let tokens = parts
138
+ .join('')
139
+ .split(/\s+/)
140
+ .filter(Boolean)
141
+ .flatMap((t) => {
142
+ if (!t.includes(SENT)) return [t];
143
+ const out = [];
144
+ t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
145
+ return out;
146
+ });
147
+ tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
148
+ for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
149
+ let ei = 0;
150
+ const rebuilt = tokens
151
+ .map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
152
+ .join(' ');
153
+ const next = content.slice(0, expr.start) + '`' + rebuilt + '`' + content.slice(expr.end);
154
+ return writeChecked(abs, content, next);
155
+ }
156
+
157
+ // Computed className (cn(...), a variable, &&/ternary at the top level) —
158
+ // refuse rather than write a duplicate/broken attribute. The overlay surfaces
159
+ // this; size/color still work via the inline-style lane.
160
+ throw new Error(
161
+ "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."
162
+ );
118
163
  }
119
164
 
120
165
  /**
@@ -201,38 +246,141 @@ function removeElementFromContent(content, element, file) {
201
246
  }
202
247
  }
203
248
 
204
- // If `element` is the sole rendered output of a NAMED component function,
205
- // return that component's name. Returns null when the innermost enclosing
206
- // function is an anonymous callback (e.g. a `.map()` list item) — those are
207
- // data-driven and belong in the model lane, not a usage removal.
208
- function soleReturnComponentName(ast, element) {
209
- // innermost function whose span contains the element
210
- let inner = null;
211
- walk(ast, (n) => {
212
- if (
213
- n.type !== 'FunctionDeclaration' &&
214
- n.type !== 'FunctionExpression' &&
215
- n.type !== 'ArrowFunctionExpression'
216
- ) return;
217
- if (n.start <= element.start && n.end >= element.end) {
218
- if (!inner || (n.end - n.start) < (inner.end - inner.start)) inner = n;
249
+ // Ancestor chain from `element` up to the Program node (element first).
250
+ function ancestorChain(ast, element) {
251
+ const chain = [];
252
+ (function descend(node, trail) {
253
+ if (!node || typeof node.type !== 'string') return false;
254
+ if (node === element) {
255
+ chain.push(element, ...trail);
256
+ return true;
219
257
  }
220
- });
221
- if (!inner) return null;
258
+ if (node.start > element.start || node.end < element.end) return false;
259
+ for (const key of Object.keys(node)) {
260
+ if (key === 'loc') continue;
261
+ const v = node[key];
262
+ const kids = Array.isArray(v) ? v : [v];
263
+ for (const kid of kids) {
264
+ if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
265
+ }
266
+ }
267
+ return false;
268
+ })(ast, []);
269
+ return chain;
270
+ }
222
271
 
223
- // named function declaration: function Foo() {}
224
- if (inner.id && inner.id.name) return inner.id.name;
272
+ const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
273
+
274
+ // If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
275
+ // function (only Return/Block between them), return that function node.
276
+ // This gate matters: without it, a parse-breaking delete anywhere inside a
277
+ // named component would wrongly escalate to removing the component's usage.
278
+ function returningFunction(chain) {
279
+ for (let i = 1; i < chain.length; i++) {
280
+ const t = chain[i].type;
281
+ if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
282
+ return FN_TYPES.has(t) ? chain[i] : null;
283
+ }
284
+ return null;
285
+ }
225
286
 
226
- // arrow/function bound to a name: const Foo = () => {} / const Foo = function(){}
287
+ function nameOfFunction(ast, fn) {
288
+ if (!fn) return null;
289
+ if (fn.id && fn.id.name) return fn.id.name;
227
290
  let name = null;
228
291
  walk(ast, (n) => {
229
292
  if (
230
293
  n.type === 'VariableDeclarator' &&
231
- n.init && n.init.start === inner.start && n.init.end === inner.end &&
294
+ n.init && n.init.start === fn.start && n.init.end === fn.end &&
232
295
  n.id && n.id.type === 'Identifier'
233
296
  ) name = n.id.name;
234
297
  });
235
- return name; // null if the innermost fn is an anonymous callback (map, etc.)
298
+ return name;
299
+ }
300
+
301
+ // Walk up from the element looking for a `{...}` JSX expression whose whole
302
+ // removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
303
+ // (list template). Never crosses a function boundary except an inline callback
304
+ // (a function that is itself a call argument, e.g. the .map render callback).
305
+ // For lists, also returns the map CallExpression so the caller can target the
306
+ // underlying data array instead of the whole list.
307
+ function removableExpressionContainer(chain) {
308
+ for (let i = 1; i < chain.length; i++) {
309
+ const node = chain[i];
310
+ if (node.type === 'JSXExpressionContainer') {
311
+ const inner = chain[i - 1];
312
+ const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
313
+ return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
314
+ }
315
+ if (FN_TYPES.has(node.type)) {
316
+ // crossing a function is only allowed for inline callbacks (map etc.)
317
+ const parent = chain[i + 1];
318
+ if (!parent || parent.type !== 'CallExpression') return null;
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+
324
+ // Unwrap TS wrappers around an initializer (as const / satisfies / parens).
325
+ function unwrapExpr(node) {
326
+ let n = node;
327
+ while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
328
+ n = n.expression;
329
+ return n;
330
+ }
331
+
332
+ // For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
333
+ // ArrayExpression in the same file. Returns the array node or null.
334
+ function resolveMappedArray(ast, mapCall) {
335
+ let call = mapCall;
336
+ // chain may have handed us the arrow (expression-body case) — find the call
337
+ if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
338
+ if (!call || call.type !== 'CallExpression') return null;
339
+ const callee = call.callee;
340
+ if (
341
+ callee?.type !== 'MemberExpression' ||
342
+ callee.property?.type !== 'Identifier' ||
343
+ !['map', 'flatMap'].includes(callee.property.name) ||
344
+ callee.object?.type !== 'Identifier'
345
+ ) return null;
346
+ const arrName = callee.object.name;
347
+ let arr = null;
348
+ walk(ast, (n) => {
349
+ if (arr) return;
350
+ if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
351
+ const init = unwrapExpr(n.init);
352
+ if (init?.type === 'ArrayExpression') arr = init;
353
+ }
354
+ });
355
+ return arr;
356
+ }
357
+
358
+ // Remove the k-th element of an ArrayExpression, handling commas and whole
359
+ // lines. Returns new content, or null if the result doesn't parse.
360
+ function removeArrayElement(content, arrayNode, k, file) {
361
+ const el = arrayNode.elements[k];
362
+ if (!el) return null;
363
+ let start = el.start;
364
+ let end = el.end;
365
+ const after = content.slice(end).match(/^\s*,/);
366
+ if (after) end += after[0].length;
367
+ else {
368
+ const before = content.slice(0, start).match(/,\s*$/);
369
+ if (before) start -= before[0].length;
370
+ }
371
+ const lineStart = content.lastIndexOf('\n', start - 1) + 1;
372
+ const trailing = content.slice(end).match(/^[ \t]*\n/);
373
+ if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
374
+ start = lineStart;
375
+ end += trailing[0].length;
376
+ }
377
+ const next = content.slice(0, start) + content.slice(end);
378
+ try {
379
+ parseSource(next, file);
380
+ return next;
381
+ } catch {
382
+ return null;
383
+ }
236
384
  }
237
385
 
238
386
  // Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
@@ -267,15 +415,20 @@ function findUsages(root, name) {
267
415
  }
268
416
 
269
417
  /**
270
- * Delete behavior:
271
- * - Normal element (has siblings / non-sole child): remove it in place.
272
- * - Sole return of a component: the element IS the component's whole output,
273
- * so "delete this" means remove the component's rendered *usage*. If the
274
- * component is used in exactly one place, remove that usage deterministically.
275
- * Otherwise refuse with an actionable message (ambiguous → model lane).
418
+ * Delete behavior, in order:
419
+ * 1. Normal element (has siblings): remove it in place.
420
+ * 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
421
+ * identifies the data item remove that ONE entry from the local array
422
+ * literal. Deleting one card removes one card, not the list. (To remove a
423
+ * whole list, select its surrounding wrapper element instead.)
424
+ * 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
425
+ * 4. Sole return of a NAMED component used in exactly one place: remove that
426
+ * usage at its call site.
427
+ * Otherwise refuse with an actionable message (ambiguous → model lane).
276
428
  */
277
- export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
278
- const { abs, content, element, file } = loadTarget(root, loc);
429
+ export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
430
+ // reuse loadTarget's AST element identity must hold for the ancestor walk
431
+ const { abs, content, element, file, ast } = loadTarget(root, loc);
279
432
 
280
433
  const inPlace = removeElementFromContent(content, element, file);
281
434
  if (inPlace !== null) {
@@ -283,13 +436,41 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
283
436
  return writeChecked(abs, content, inPlace);
284
437
  }
285
438
 
286
- // Removing the element in place would break the file it's the sole return.
287
- const ast = parseSource(content, file);
288
- const name = soleReturnComponentName(ast, element);
439
+ // In-place removal breaks the file walk up for a deletable wrapper.
440
+ const chain = ancestorChain(ast, element);
441
+
442
+ const wrapper = removableExpressionContainer(chain);
443
+ if (wrapper && wrapper.kind === 'list') {
444
+ const arr = resolveMappedArray(ast, wrapper.mapCall);
445
+ if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
446
+ const next = removeArrayElement(content, arr, index, file);
447
+ if (next !== null) {
448
+ const detail = `item ${index + 1} of ${arr.elements.length}`;
449
+ if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
450
+ return { ...writeChecked(abs, content, next), removedBlock: detail };
451
+ }
452
+ }
453
+ // data not editable deterministically (imported/computed array, or the
454
+ // instance index is unknown) — never silently delete the whole list
455
+ throw new Error(
456
+ "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."
457
+ );
458
+ }
459
+ if (wrapper) {
460
+ const next = removeElementFromContent(content, wrapper.container, file);
461
+ if (next !== null) {
462
+ if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
463
+ return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
464
+ }
465
+ }
466
+
467
+ // Not a removable wrapper — usage removal, but ONLY when the element really
468
+ // is the returned output of a named component.
469
+ const fn = returningFunction(chain);
470
+ const name = nameOfFunction(ast, fn);
289
471
  if (!name || !/^[A-Z]/.test(name)) {
290
- // no resolvable component name (anonymous default, or a .map() list item)
291
472
  throw new Error(
292
- "can't delete this in place — it's the only thing rendered here (likely a list item or an unnamed component). Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
473
+ "can't delete this in place — it's the only thing rendered here and there's no removable wrapper. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
293
474
  );
294
475
  }
295
476
 
package/src/router.js CHANGED
@@ -32,8 +32,10 @@ const EXISTING_COMPONENT =
32
32
  const NEW_LOGIC =
33
33
  /(implement|build|create|add a (new )?(feature|form|modal|dropdown|toggle|filter|search|carousel|slider|tab|accordion|tooltip|menu|pagination|stepper|wizard)|when (clicked|submitted|hovered|selected|scrolled)|on (click|submit|change|load|scroll)|fetch|\bapi\b|endpoint|request|\bstate\b|store|track|count(er|down)?|validat(e|ion)|debounce|localstorage|session storage|cookie|websocket|subscribe|drag|sortable|upload|download|timer|interval|keyboard shortcut)/i;
34
34
 
35
+ // "signup form" is a UI task (tier 2); "sign in with google" is auth (tier 3) —
36
+ // the sign in/up pattern only counts when followed by an auth-flow preposition.
35
37
  const MISSION_CRITICAL =
36
- /(\bauth\b|authentication|log ?in|log ?out|sign ?(in|up)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
38
+ /(\bauth\b|authentication|oauth|\bsso\b|log ?in|log ?out|sign ?(in|up) (with|via|using|flow)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
37
39
 
38
40
  const MULTI_SCOPE =
39
41
  /(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
@@ -107,7 +109,10 @@ function llmClassify(instruction, cwd) {
107
109
  clearTimeout(timer);
108
110
  try {
109
111
  const envelope = JSON.parse(out);
110
- const parsed = typeof envelope.result === 'string' ? JSON.parse(envelope.result) : envelope.result;
112
+ // --json-schema puts the validated object in structured_output
113
+ const parsed =
114
+ envelope.structured_output ??
115
+ (typeof envelope.result === 'string' && envelope.result ? JSON.parse(envelope.result) : envelope.result);
111
116
  if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
112
117
  } catch { /* fall through */ }
113
118
  resolve(null);
package/src/server.js CHANGED
@@ -128,17 +128,19 @@ 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
140
140
  ? `removed <${write.removedUsage}> usage`
141
- : `deleted <${target.tagName}>`;
141
+ : write.removedBlock
142
+ ? `deleted ${write.removedBlock}`
143
+ : `deleted <${target.tagName}>`;
142
144
  broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
143
145
  return json(res, { ok: true, id });
144
146
  }
@@ -171,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
171
173
  const { file } = parseLoc(body.loc);
172
174
  const target = describeTarget(root, body.loc);
173
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
+ }
174
182
  const abs = path.resolve(root, file);
175
183
  remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
176
184
  telemetry.record('nl');