cmdzero 0.7.2 → 0.8.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 CHANGED
@@ -1,8 +1,14 @@
1
+ # CmdZero
2
+
1
3
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/adamcwade/cmdzero/main/assets/hero.png" alt="CmdZero — tweak your UI live in the browser; changes land in your source" width="900">
4
+ <img src="https://raw.githubusercontent.com/adamcwade/cmdzero/main/assets/hero.png" alt="CmdZero — live in-browser UI editing with a style panel, inline copy editing, and a token-savings banner" width="900">
3
5
  </p>
4
6
 
5
- # CmdZero
7
+ <p align="center">
8
+ <strong><a href="https://cmdzero.xyz/demo">▶︎ Try the live demo — no install</a></strong>
9
+ </p>
10
+
11
+ Press `⌘0` and edit a real page with the real overlay, running entirely in your browser. Copy, style, reorder, delete and undo all work; natural-language prompts are scripted (no model is called) and nothing is written to disk.
6
12
 
7
13
  Tweak your UI live in the browser — the changes are written straight to your source files.
8
14
 
@@ -81,6 +81,9 @@
81
81
  .cz-banner .cz-stat b{color:#f1f5f9;font-weight:600;margin-left:5px}
82
82
  .cz-banner .cz-stat.cz-accent b{color:#6ee7b7}
83
83
  .cz-banner a{color:#6ee7b7;text-decoration:none;flex:none}
84
+ .cz-banner .cz-deploy{flex:none;background:#10b981;color:#052e1b;border:none;border-radius:7px;padding:5px 12px;font-size:11.5px;font-weight:700;cursor:pointer;pointer-events:auto}
85
+ .cz-banner .cz-deploy:hover{background:#34d399}
86
+ .cz-banner .cz-deploy:disabled{opacity:.6;cursor:default}
84
87
  .cz-banner a:hover{text-decoration:underline}
85
88
  .cz-banner .cz-idle{color:#64748b}
86
89
  .cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
@@ -262,6 +265,47 @@
262
265
  try { localStorage.setItem('cz-panel-collapsed', v ? '1' : '0'); } catch { /* no storage */ }
263
266
  applyPanelLayout();
264
267
  }
268
+ // Everything the overlay draws — outline, popover, move bar, delete button,
269
+ // hover badge — is position:fixed at coordinates measured once from
270
+ // getBoundingClientRect(). The page moves under those coordinates constantly:
271
+ // opening the panel animates <body>'s margin for .26s, that push narrows the
272
+ // viewport so text re-wraps and blocks shift down, an edit changes an
273
+ // element's size, fonts and images land, the page runs its own animations.
274
+ // reposition() on scroll/resize/select catches none of that, which is why a
275
+ // box could sit up to PANEL_W px from its element until something unrelated
276
+ // happened to re-measure.
277
+ //
278
+ // Chasing each cause separately is a losing game, so watch the element
279
+ // itself: compare its rect every frame and redraw only when it actually
280
+ // moved. rAF is the right clock — it's gated on painting exactly like the
281
+ // transitions and reflows we're chasing, so we never measure a layout the
282
+ // user hasn't seen yet. Idle cost is one getBoundingClientRect and a string
283
+ // compare per frame, and the loop only runs while the overlay has something
284
+ // on screen to keep glued.
285
+ let watchRaf = 0;
286
+ let lastRect = '';
287
+ function watchLayout() {
288
+ if (watchRaf) return; // already running
289
+ const step = () => {
290
+ if (!state.selectMode && !state.selected) { watchRaf = 0; lastRect = ''; return; }
291
+ const target = state.selected?.el || state.hoverEl;
292
+ if (target && document.contains(target)) {
293
+ const r = target.getBoundingClientRect();
294
+ const key = `${r.left},${r.top},${r.width},${r.height}`;
295
+ if (key !== lastRect) {
296
+ lastRect = key;
297
+ // the same trio the scroll handler runs — a scroll strands these
298
+ // boxes for exactly the same reason any other layout shift does
299
+ reposition();
300
+ positionMulti();
301
+ setHover(state.hoverEl);
302
+ }
303
+ }
304
+ watchRaf = requestAnimationFrame(step);
305
+ };
306
+ watchRaf = requestAnimationFrame(step);
307
+ }
308
+
265
309
  // Slide the panel in/out and push the page so nothing is covered.
266
310
  function applyPanelLayout() {
267
311
  const open = !!state.selected && !state.panelCollapsed;
@@ -447,6 +491,7 @@
447
491
  renderPopover();
448
492
  renderPanel();
449
493
  reposition();
494
+ watchLayout(); // renderPanel() just started the panel's .26s page push
450
495
  loadMeta();
451
496
  }
452
497
 
@@ -1083,11 +1128,26 @@
1083
1128
  const brand = el('div', 'cz-brand');
1084
1129
  brand.innerHTML = '<kbd>⌘0</kbd> CmdZero';
1085
1130
  const bannerStats = el('div', 'cz-stats');
1086
- const reportLink = el('a', null, 'report →');
1087
- reportLink.href = 'https://cmdzero.dev/report';
1088
- reportLink.target = '_blank';
1089
- reportLink.rel = 'noopener';
1090
- banner.append(brand, bannerStats, reportLink);
1131
+ // Build & Deploy commits the current tweaks and pushes them to the live site
1132
+ // (the daemon runs it through headless claude).
1133
+ const deployBtn = el('button', 'cz-deploy', 'Build & Deploy ↗');
1134
+ let deployId = null;
1135
+ deployBtn.onclick = async () => {
1136
+ if (deployBtn.disabled) return;
1137
+ if (!confirm('Build & deploy the current tweaks to your live site?')) return;
1138
+ deployBtn.disabled = true;
1139
+ deployBtn.textContent = 'Deploying…';
1140
+ try {
1141
+ const r = await api('deploy', {});
1142
+ deployId = String(r.id);
1143
+ addTweak({ id: r.id, status: 'queued', label: 'Build & Deploy — committing & deploying…' });
1144
+ } catch (e) {
1145
+ resetDeployBtn();
1146
+ addTweak({ id: 'x' + Date.now(), status: 'error', label: `deploy: ${e.message}` });
1147
+ }
1148
+ };
1149
+ function resetDeployBtn() { deployBtn.disabled = false; deployBtn.textContent = 'Build & Deploy ↗'; }
1150
+ banner.append(brand, bannerStats, deployBtn);
1091
1151
  root.appendChild(banner);
1092
1152
  // Push the page down so the banner sits ABOVE the site rather than over it.
1093
1153
  // Inject a stylesheet instead of mutating body's style attribute: frameworks
@@ -1225,8 +1285,10 @@
1225
1285
  row._dot.className = 'cz-dot ' + t.status;
1226
1286
  const inFlight = t.status === 'queued' || t.status === 'running';
1227
1287
  row._cancel.style.display = inFlight ? '' : 'none';
1228
- const undoable = t.status === 'done' && !key.startsWith('x');
1288
+ const undoable = t.status === 'done' && !key.startsWith('x') && t.kind !== 'deploy';
1229
1289
  row._undo.style.display = undoable ? '' : 'none';
1290
+ // re-enable the Build & Deploy button once its task settles
1291
+ if (typeof deployId !== 'undefined' && deployId && key === deployId && ['done', 'error', 'cancelled'].includes(t.status)) resetDeployBtn();
1230
1292
  if (undoable && !undoStack.includes(key)) undoStack.push(key);
1231
1293
  if (t.status === 'reverted') {
1232
1294
  const i = undoStack.indexOf(key);
@@ -1277,6 +1339,11 @@
1277
1339
  .then((h) => {
1278
1340
  showTotals(h.totals);
1279
1341
  if (h.tailwind === false) state.tailwind = false;
1342
+ // Show the running overlay version so a stale/cached overlay is obvious.
1343
+ if (h.version) {
1344
+ brand.innerHTML = `<kbd>⌘0</kbd> CmdZero <span style="opacity:.45;font-weight:400">v${h.version}</span>`;
1345
+ console.log(`[cmdzero] overlay v${h.version} — newest alerts at the bottom of the tray`);
1346
+ }
1280
1347
  })
1281
1348
  .catch(() => {});
1282
1349
 
@@ -1358,7 +1425,8 @@
1358
1425
  function setMode(on) {
1359
1426
  state.selectMode = on;
1360
1427
  hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘0 select mode';
1361
- if (!on) { setHover(null); deselect(); clearMulti(); }
1428
+ if (on) watchLayout(); // keep the boxes glued while anything on the page moves
1429
+ else { setHover(null); deselect(); clearMulti(); }
1362
1430
  }
1363
1431
 
1364
1432
  // Visible, stamped elements in document order — the Tab cycle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmdzero",
3
- "version": "0.7.2",
3
+ "version": "0.8.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/router.js CHANGED
@@ -274,3 +274,44 @@ export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
274
274
  onEvent?.({ status: 'running', model });
275
275
  });
276
276
  }
277
+
278
+ // "Build & Deploy": run the deploy through headless claude (Bash allowed) so it
279
+ // commits the current tweaks and pushes them to the live site.
280
+ const DEPLOY_PROMPT = `You are deploying this project to production. Using the Bash tool, do exactly this, in order:
281
+ 1. Run \`git add -A\`, then commit with a short message describing recent UI tweaks. Do NOT add any AI attribution/co-author trailer. If there is nothing to commit, continue.
282
+ 2. If a git remote named "origin" exists, run \`git push\`.
283
+ 3. Deploy to production. This is a Vercel project — run: \`vercel --prod --yes\`
284
+ On the FINAL line of your reply, output the production URL exactly as: DEPLOY_URL=<url>
285
+ If any step fails, stop and report the exact error.`;
286
+
287
+ export function runDeploy({ cwd, model = 'claude-sonnet-5', onEvent, onSpawn }) {
288
+ return new Promise((resolve) => {
289
+ const started = Date.now();
290
+ const args = [
291
+ '-p', DEPLOY_PROMPT,
292
+ '--model', model,
293
+ '--allowedTools', 'Read,Edit,Bash',
294
+ '--dangerously-skip-permissions', // headless: commit/push/deploy without prompts
295
+ '--output-format', 'json',
296
+ ];
297
+ const child = spawn('claude', args, { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true });
298
+ let cancelled = false;
299
+ const signalGroup = (sig) => { try { process.kill(-child.pid, sig); } catch { try { child.kill(sig); } catch {} } };
300
+ child.__cancel = () => { cancelled = true; signalGroup('SIGTERM'); setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.(); };
301
+ onSpawn?.(child);
302
+ let out = '', err = '';
303
+ child.stdout.on('data', (d) => (out += d));
304
+ child.stderr.on('data', (d) => (err += d));
305
+ child.on('close', (code) => {
306
+ const durationMs = Date.now() - started;
307
+ if (cancelled) return resolve({ ok: false, cancelled: true, durationMs });
308
+ if (code !== 0) return resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
309
+ let result = out;
310
+ try { result = JSON.parse(out).result || out; } catch { /* raw text */ }
311
+ const m = /DEPLOY_URL=(\S+)/.exec(result);
312
+ resolve({ ok: true, durationMs, url: m ? m[1] : null, result: String(result).slice(0, 500) });
313
+ });
314
+ child.on('error', (e) => resolve({ ok: false, error: `failed to spawn claude: ${e.message}` }));
315
+ onEvent?.({ status: 'running' });
316
+ });
317
+ }
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { describeTarget, applyTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
6
- import { classify, buildPrompt, runClaude } from './router.js';
6
+ import { classify, buildPrompt, runClaude, runDeploy } from './router.js';
7
7
  import { initTelemetry, DISCLOSURE } from './telemetry.js';
8
8
 
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -77,7 +77,7 @@ export function startServer({ root, port = 4100 }) {
77
77
  }
78
78
 
79
79
  if (req.method === 'GET' && url.pathname === '/api/health') {
80
- return json(res, { ok: true, root, totals, tailwind, telemetry: !telemetry.disabled });
80
+ return json(res, { ok: true, version: VERSION, root, totals, tailwind, telemetry: !telemetry.disabled });
81
81
  }
82
82
 
83
83
  if (req.method === 'GET' && url.pathname === '/api/events') {
@@ -184,6 +184,32 @@ export function startServer({ root, port = 4100 }) {
184
184
  return json(res, { ok: true, ...route });
185
185
  }
186
186
 
187
+ if (url.pathname === '/api/deploy') {
188
+ const id = nextId++;
189
+ telemetry.record('deploy');
190
+ broadcast({ type: 'tweak', id, kind: 'deploy', status: 'queued', label: 'Build & Deploy — committing & deploying…' });
191
+ json(res, { ok: true, id });
192
+ const result = await runDeploy({
193
+ cwd: root,
194
+ onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
195
+ onSpawn: (child) => running.set(String(id), child),
196
+ });
197
+ running.delete(String(id));
198
+ if (result.cancelled) {
199
+ broadcast({ type: 'tweak', id, status: 'cancelled', label: 'deploy cancelled' });
200
+ return;
201
+ }
202
+ broadcast({
203
+ type: 'tweak', id,
204
+ kind: 'deploy',
205
+ status: result.ok ? 'done' : 'error',
206
+ durationMs: result.durationMs,
207
+ label: result.ok ? (result.url ? `deployed → ${result.url}` : 'deployed to production') : 'deploy failed',
208
+ error: result.ok ? undefined : (result.error || '').slice(0, 200),
209
+ });
210
+ return;
211
+ }
212
+
187
213
  if (url.pathname === '/api/nl') {
188
214
  const id = nextId++;
189
215
  const { file } = parseLoc(body.loc);
package/src/telemetry.js CHANGED
@@ -53,7 +53,7 @@ export function initTelemetry({ version, tailwind }) {
53
53
  saveConfig(config);
54
54
  }
55
55
  const disabled = telemetryDisabled();
56
- const counts = { copy: 0, style: 0, delete: 0, nl: 0, move: 0 };
56
+ const counts = { copy: 0, style: 0, delete: 0, nl: 0, move: 0, deploy: 0 };
57
57
  let flushTimer = null;
58
58
 
59
59
  function send(event, extra = {}) {