tweaklocal 0.3.3 → 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.
@@ -35,6 +35,8 @@
35
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}
36
36
  .twk-pop select{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:3px 4px;font-size:11.5px;outline:none}
37
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)}
38
40
  .twk-cur{color:#6ee7b7;font-size:11px;margin-left:auto}
39
41
  .twk-swatches{display:flex;gap:4px;flex-wrap:wrap;max-height:96px;overflow-y:auto;padding:2px}
40
42
  .twk-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}
@@ -241,7 +243,10 @@
241
243
  function select(target) {
242
244
  finishTextEdit(false);
243
245
  const loc = target.getAttribute('data-twk');
244
- 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 };
245
250
  selBox.style.display = 'block';
246
251
  renderPopover();
247
252
  reposition();
@@ -379,7 +384,7 @@
379
384
  const left = Math.min(Math.max(r.left, 8), innerWidth - 356);
380
385
  Object.assign(pop.style, { left: left + 'px', top: Math.max(top, 8 + labelH) + 'px' });
381
386
  popLabel.style.display = 'block';
382
- popLabel.textContent = shortLoc(s.loc);
387
+ popLabel.textContent = s.instances > 1 ? `${shortLoc(s.loc)} · ${s.instances}×` : shortLoc(s.loc);
383
388
  Object.assign(popLabel.style, { left: (left + pop.offsetWidth / 2) + 'px', top: pop.style.top });
384
389
  deleteBtn.style.display = 'flex';
385
390
  const inset = 4;
@@ -605,11 +610,24 @@
605
610
 
606
611
  // location now lives in the green tab above the panel (popLabel)
607
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
+
608
621
  // Copy lane, driven by the SOURCE text literals (s.meta.texts), not the
609
622
  // DOM: animation libs split text into spans and expressions render as
610
623
  // text, so DOM shape says nothing about what's editable in the JSX.
611
624
  const literals = s.meta ? s.meta.texts : null;
612
- 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) {
613
631
  // In-place editing only when the single literal IS the element's whole
614
632
  // text (animation-split DOM still qualifies — same text, different
615
633
  // nodes). Partial literals (mixed with {expressions}) get input fields
@@ -999,8 +1017,8 @@
999
1017
  e.preventDefault();
1000
1018
  e.stopPropagation();
1001
1019
  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);
1020
+ // Shift-click toggles an element in the multi-selection set.
1021
+ if (target && e.shiftKey) return toggleMulti(target);
1004
1022
  if (state.multi.length) clearMulti();
1005
1023
  if (target) select(target);
1006
1024
  else deselect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tweaklocal",
3
- "version": "0.3.3",
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,10 +90,64 @@ 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(
150
+ const classAttrs = opening.attributes.filter(
97
151
  (a) => a.type === 'JSXAttribute' && a.name.name === 'className'
98
152
  );
99
153
 
@@ -104,62 +158,39 @@ export function applyClassEdit(root, loc, removes = [], adds = []) {
104
158
  };
105
159
 
106
160
  // No className yet — add a fresh string attribute.
107
- if (!attr) {
161
+ if (!classAttrs.length) {
108
162
  const pos = opening.name.end;
109
163
  return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
110
164
  }
111
165
 
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);
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
+ );
124
176
  }
125
177
 
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);
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: '' });
155
189
  }
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
- );
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);
193
+ return writeChecked(abs, content, next);
163
194
  }
164
195
 
165
196
  /**