tweaklocal 0.3.0 → 0.3.1
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 +14 -1
- package/package.json +1 -1
- package/src/resolver.js +91 -35
- package/src/router.js +7 -2
- package/src/server.js +3 -1
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 —
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweaklocal",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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) {
|
|
@@ -201,38 +201,78 @@ function removeElementFromContent(content, element, file) {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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;
|
|
204
|
+
// Ancestor chain from `element` up to the Program node (element first).
|
|
205
|
+
function ancestorChain(ast, element) {
|
|
206
|
+
const chain = [];
|
|
207
|
+
(function descend(node, trail) {
|
|
208
|
+
if (!node || typeof node.type !== 'string') return false;
|
|
209
|
+
if (node === element) {
|
|
210
|
+
chain.push(element, ...trail);
|
|
211
|
+
return true;
|
|
219
212
|
}
|
|
220
|
-
|
|
221
|
-
|
|
213
|
+
if (node.start > element.start || node.end < element.end) return false;
|
|
214
|
+
for (const key of Object.keys(node)) {
|
|
215
|
+
if (key === 'loc') continue;
|
|
216
|
+
const v = node[key];
|
|
217
|
+
const kids = Array.isArray(v) ? v : [v];
|
|
218
|
+
for (const kid of kids) {
|
|
219
|
+
if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
})(ast, []);
|
|
224
|
+
return chain;
|
|
225
|
+
}
|
|
222
226
|
|
|
223
|
-
|
|
224
|
-
if (inner.id && inner.id.name) return inner.id.name;
|
|
227
|
+
const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
225
228
|
|
|
226
|
-
|
|
229
|
+
// If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
|
|
230
|
+
// function (only Return/Block between them), return that function node.
|
|
231
|
+
// This gate matters: without it, a parse-breaking delete anywhere inside a
|
|
232
|
+
// named component would wrongly escalate to removing the component's usage.
|
|
233
|
+
function returningFunction(chain) {
|
|
234
|
+
for (let i = 1; i < chain.length; i++) {
|
|
235
|
+
const t = chain[i].type;
|
|
236
|
+
if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
|
|
237
|
+
return FN_TYPES.has(t) ? chain[i] : null;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function nameOfFunction(ast, fn) {
|
|
243
|
+
if (!fn) return null;
|
|
244
|
+
if (fn.id && fn.id.name) return fn.id.name;
|
|
227
245
|
let name = null;
|
|
228
246
|
walk(ast, (n) => {
|
|
229
247
|
if (
|
|
230
248
|
n.type === 'VariableDeclarator' &&
|
|
231
|
-
n.init && n.init.start ===
|
|
249
|
+
n.init && n.init.start === fn.start && n.init.end === fn.end &&
|
|
232
250
|
n.id && n.id.type === 'Identifier'
|
|
233
251
|
) name = n.id.name;
|
|
234
252
|
});
|
|
235
|
-
return name;
|
|
253
|
+
return name;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
257
|
+
// 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).
|
|
261
|
+
function removableExpressionContainer(chain) {
|
|
262
|
+
for (let i = 1; i < chain.length; i++) {
|
|
263
|
+
const node = chain[i];
|
|
264
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
265
|
+
const inner = chain[i - 1];
|
|
266
|
+
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
267
|
+
return { container: node, kind: isList ? 'list' : 'conditional block' };
|
|
268
|
+
}
|
|
269
|
+
if (FN_TYPES.has(node.type)) {
|
|
270
|
+
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
271
|
+
const parent = chain[i + 1];
|
|
272
|
+
if (!parent || parent.type !== 'CallExpression') return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
236
276
|
}
|
|
237
277
|
|
|
238
278
|
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
@@ -267,15 +307,19 @@ function findUsages(root, name) {
|
|
|
267
307
|
}
|
|
268
308
|
|
|
269
309
|
/**
|
|
270
|
-
* Delete behavior:
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
310
|
+
* Delete behavior, in order:
|
|
311
|
+
* 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
|
|
317
|
+
* usage at its call site.
|
|
318
|
+
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
276
319
|
*/
|
|
277
320
|
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
278
|
-
|
|
321
|
+
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
322
|
+
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
279
323
|
|
|
280
324
|
const inPlace = removeElementFromContent(content, element, file);
|
|
281
325
|
if (inPlace !== null) {
|
|
@@ -283,13 +327,25 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
|
283
327
|
return writeChecked(abs, content, inPlace);
|
|
284
328
|
}
|
|
285
329
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
|
|
330
|
+
// In-place removal breaks the file — walk up for a deletable wrapper.
|
|
331
|
+
const chain = ancestorChain(ast, element);
|
|
332
|
+
|
|
333
|
+
const wrapper = removableExpressionContainer(chain);
|
|
334
|
+
if (wrapper) {
|
|
335
|
+
const next = removeElementFromContent(content, wrapper.container, file);
|
|
336
|
+
if (next !== null) {
|
|
337
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
|
|
338
|
+
return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Not a removable wrapper — usage removal, but ONLY when the element really
|
|
343
|
+
// is the returned output of a named component.
|
|
344
|
+
const fn = returningFunction(chain);
|
|
345
|
+
const name = nameOfFunction(ast, fn);
|
|
289
346
|
if (!name || !/^[A-Z]/.test(name)) {
|
|
290
|
-
// no resolvable component name (anonymous default, or a .map() list item)
|
|
291
347
|
throw new Error(
|
|
292
|
-
"can't delete this in place — it's the only thing rendered here
|
|
348
|
+
"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
349
|
);
|
|
294
350
|
}
|
|
295
351
|
|
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
|
-
|
|
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
|
@@ -138,7 +138,9 @@ export function startServer({ root, port = 4100 }) {
|
|
|
138
138
|
telemetry.record('delete');
|
|
139
139
|
const label = write.removedUsage
|
|
140
140
|
? `removed <${write.removedUsage}> usage`
|
|
141
|
-
:
|
|
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
|
}
|