@floless/app 0.60.0 → 0.62.0

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.
@@ -47,6 +47,27 @@
47
47
  #zoombar #zpct{min-width:42px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
48
48
  #zoombar .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
49
49
  #zoombar .ghost:hover{color:var(--text);border-color:var(--brand)}
50
+ /* Auto-save chip — same states/colors as steel-editor's #saveStat. */
51
+ #saveStat{color:var(--mut);font-size:12px}
52
+ #saveStat.dirty{color:#fbbf24} #saveStat.err{color:#f87171}
53
+ /* Weak-trace review list (inner scroll box — inherits the themed scrollbar rules above). */
54
+ #weakList{max-height:280px;overflow:auto;display:flex;flex-direction:column;gap:2px;margin-top:4px}
55
+ .wrow{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px}
56
+ .wrow:hover{background:var(--line)}
57
+ .wrow .lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
58
+ .wrow .pct{color:#f59e0b;font-variant-numeric:tabular-nums;font-size:11px}
59
+ .wrow button{height:22px;padding:0 8px;font-size:11px;border-radius:5px}
60
+ .wrow .ghost{background:transparent;border:1px solid var(--line);color:var(--mut)}
61
+ .wrow .ghost:hover{color:var(--text);border-color:var(--brand);background:transparent}
62
+ .wrow .danger{background:#7f1d1d;border-color:#991b1b;color:#fecaca}
63
+ .wrow .danger:hover{background:#991b1b}
64
+ .wrow.flash{background:var(--line)}
65
+ #svg .locfl{stroke:var(--brand)!important} #svg text.locfl{fill:var(--brand)!important}
66
+ /* Single-level Undo toast (styled modal pattern — never a native dialog). */
67
+ #toast{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 12px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:6;color:var(--text)}
68
+ #toast.show{display:flex}
69
+ #toast .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
70
+ #toast .ghost:hover{color:var(--text);border-color:var(--brand)}
50
71
  </style>
51
72
  </head>
52
73
  <body>
@@ -55,6 +76,7 @@
55
76
  <span class="stat" id="title">—</span>
56
77
  <span class="spacer"></span>
57
78
  <span class="stat"><b id="nEl">0</b> elements · <b id="nTx">0</b> text</span>
79
+ <span id="saveStat" title="Edits save to the app's contract automatically" hidden>Auto-save on</span>
58
80
  <button class="primary" id="exportBtn" disabled>Export SVG</button>
59
81
  </header>
60
82
  <div id="wrap">
@@ -66,6 +88,7 @@
66
88
  <input type="range" id="zoom" min="10" max="800" value="100" aria-label="Zoom">
67
89
  <span id="zpct">100%</span>
68
90
  </div>
91
+ <div id="toast" role="status"><span id="toastMsg"></span><button class="ghost" id="undoBtn">Undo</button></div>
69
92
  </div>
70
93
  <aside>
71
94
  <div>
@@ -83,7 +106,9 @@
83
106
  <div>
84
107
  <h3>Confidence</h3>
85
108
  <label class="ck" id="weakRow" hidden><input type="checkbox" id="weakOnly"><span>Isolate weak traces</span><span class="ct" id="cWeak">0</span></label>
86
- <div class="muted" id="weakNote">No weak traces — read deterministically.</div>
109
+ <div class="muted" id="revLine" hidden>Reviewed 0/0</div>
110
+ <div id="weakList" hidden></div>
111
+ <div class="muted" id="weakNote">No weak traces — traced exactly from the vector PDF (no AI involved).</div>
87
112
  </div>
88
113
  </aside>
89
114
  </div>
@@ -100,6 +125,12 @@
100
125
 
101
126
  let sheet = null;
102
127
  let view = { x: 0, y: 0, w: 100, h: 100 };
128
+ let C = null; // the full contract (PUT body) — `sheet` is a reference into it
129
+ // Edits are only possible against the app's live contract store; a static ?src= view stays read-only.
130
+ const editable = !!app && src.indexOf('/api/contract/') === 0;
131
+ let initialWeak = 0; // M in "Reviewed N/M" — the weak count when the drawing loaded
132
+ let lastDeleted = null; // single-level undo: { el, i } of the last deleted trace
133
+ let toastT = 0;
103
134
 
104
135
  function showState(msg) { $('stateMsg').textContent = msg; $('state').classList.add('show'); $('state').querySelector('.spin').style.display = msg ? 'none' : ''; }
105
136
  function hideState() { $('state').classList.remove('show'); }
@@ -257,20 +288,166 @@
257
288
  }
258
289
  }
259
290
 
291
+ // ── auto-save (mirrors steel-editor: debounce-chip + flushContract for the shell's Approve) ──
292
+ let saveT = 0;
293
+ function setSaved(state) {
294
+ if (!editable) return;
295
+ const el = $('saveStat'); el.hidden = false; el.classList.remove('dirty', 'err');
296
+ if (state === 'dirty') { el.classList.add('dirty'); el.textContent = 'Saving…'; }
297
+ else if (state === 'err') { el.classList.add('err'); el.textContent = 'Save failed'; }
298
+ else el.textContent = 'Saved';
299
+ }
300
+ // PUT the full contract as the server-side draft — the copy Approve bakes. Saves are
301
+ // SERIALIZED through a generation-checked queue: overlapping PUTs could land out of order
302
+ // (last-write-wins on the server), letting an older in-flight snapshot overwrite a newer one
303
+ // right before Approve. Each queued write stringifies C at send time, and a write that has
304
+ // been superseded by a newer request skips itself — so the queue's tail always leaves the
305
+ // LATEST state on the server. Returns the queue tail (ok boolean).
306
+ let saveGen = 0;
307
+ let saveQueue = Promise.resolve(true);
308
+ async function doPut() {
309
+ try {
310
+ const res = await fetch('/api/contract/' + encodeURIComponent(app), {
311
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
312
+ });
313
+ setSaved(res.ok ? 'ok' : 'err');
314
+ if (!res.ok) console.error('server save rejected (' + res.status + ')', await res.text().catch(() => ''));
315
+ return res.ok;
316
+ } catch (e) { setSaved('err'); console.error('server save failed', e); return false; }
317
+ }
318
+ function persistServer() {
319
+ if (!editable || !C) return Promise.resolve(true);
320
+ const gen = ++saveGen;
321
+ saveQueue = saveQueue.then(() => (gen === saveGen ? doPut() : true)); // superseded → the newer write carries C
322
+ return saveQueue;
323
+ }
324
+ function scheduleSave() { if (!editable) return; setSaved('dirty'); clearTimeout(saveT); saveT = setTimeout(persistServer, 500); }
325
+ // The shell's Approve button awaits this before baking — throw so a rejected save aborts the bake.
326
+ window.flushContract = async function flushContract() {
327
+ if (!editable) return;
328
+ clearTimeout(saveT);
329
+ const ok = await persistServer(); // queued behind any in-flight write → latest state, in order
330
+ if (!ok) throw new Error('Save failed — the edited contract was rejected; fix it before Approve');
331
+ };
332
+
333
+ // ── weak-trace review: Accept clears the flag (absent = trusted), Delete removes with 1-level Undo ──
334
+ function isWeak(el) { return el.confidence != null && el.confidence < WEAK; }
335
+ function elLabel(el) {
336
+ if (el.kind === 'text') return '“' + (el.text || '') + '”';
337
+ return (el.kind || 'trace') + ' · ' + (el.id || '?');
338
+ }
339
+ function acceptEl(id) {
340
+ const el = sheet.elements.find((e) => e.id === id);
341
+ if (!el) return;
342
+ delete el.confidence; // schema: absent = trusted — cleaner than a magic 1.0
343
+ afterEdit();
344
+ }
345
+ function deleteEl(id) {
346
+ const i = sheet.elements.findIndex((e) => e.id === id);
347
+ if (i < 0) return;
348
+ // Prune the id from any group memberships too — a dangling groups[].elementIds reference
349
+ // would survive PUT/Approve (the schema only checks it's a string array). Remember each
350
+ // pruned spot so Undo restores memberships along with the element.
351
+ const memberships = [];
352
+ for (const g of sheet.groups || []) {
353
+ const gi = (g.elementIds || []).indexOf(id);
354
+ if (gi >= 0) { memberships.push({ g, gi }); g.elementIds.splice(gi, 1); }
355
+ }
356
+ lastDeleted = { el: sheet.elements[i], i, memberships };
357
+ sheet.elements.splice(i, 1);
358
+ showToast('Trace deleted');
359
+ afterEdit();
360
+ }
361
+ function undoDelete() {
362
+ if (!lastDeleted) return;
363
+ sheet.elements.splice(Math.min(lastDeleted.i, sheet.elements.length), 0, lastDeleted.el);
364
+ for (const m of lastDeleted.memberships) m.g.elementIds.splice(Math.min(m.gi, m.g.elementIds.length), 0, lastDeleted.el.id);
365
+ lastDeleted = null;
366
+ hideToast();
367
+ afterEdit();
368
+ }
369
+ function showToast(msg) {
370
+ $('toastMsg').textContent = msg;
371
+ $('toast').classList.add('show');
372
+ clearTimeout(toastT); toastT = setTimeout(hideToast, 6000);
373
+ }
374
+ function hideToast() { $('toast').classList.remove('show'); clearTimeout(toastT); }
375
+ $('undoBtn').addEventListener('click', undoDelete);
376
+
377
+ function buildWeakList() {
378
+ const box = $('weakList');
379
+ while (box.firstChild) box.removeChild(box.firstChild);
380
+ const weak = editable ? sheet.elements.filter(isWeak) : [];
381
+ box.hidden = weak.length === 0;
382
+ for (const el of weak) {
383
+ const row = document.createElement('div'); row.className = 'wrow'; row.dataset.id = el.id || '';
384
+ const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = elLabel(el);
385
+ lbl.title = 'Show this trace on the drawing';
386
+ lbl.addEventListener('click', () => locateOnCanvas(el.id));
387
+ const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = Math.round((el.confidence || 0) * 100) + '%';
388
+ const ok = document.createElement('button'); ok.className = 'ghost'; ok.textContent = 'Accept';
389
+ ok.title = 'Mark this trace as correct — clears its low-confidence flag.';
390
+ ok.addEventListener('click', () => acceptEl(el.id));
391
+ const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Delete';
392
+ del.title = 'Remove this trace from the drawing (Undo available).';
393
+ del.addEventListener('click', () => deleteEl(el.id));
394
+ row.append(lbl, pct, ok, del); box.appendChild(row);
395
+ }
396
+ }
397
+ // Locate: list row → flash the trace on the canvas; canvas click on a weak trace → flash its row.
398
+ function locateOnCanvas(id) {
399
+ const node = [...svg.children].find((n) => n.dataset.id === id);
400
+ if (!node) return;
401
+ node.classList.add('locfl');
402
+ setTimeout(() => node.classList.remove('locfl'), 1200);
403
+ }
404
+ svg.addEventListener('click', (e) => {
405
+ const id = e.target && e.target.dataset && e.target.dataset.id;
406
+ if (!id || !e.target.classList.contains('weak')) return;
407
+ const row = [...document.querySelectorAll('#weakList .wrow')].find((r) => r.dataset.id === id);
408
+ if (!row) return;
409
+ row.scrollIntoView({ block: 'nearest' });
410
+ row.classList.add('flash');
411
+ setTimeout(() => row.classList.remove('flash'), 1200);
412
+ });
413
+
414
+ // Header stats + Confidence section (counts, note wording, Reviewed N/M) — rerun after every edit.
415
+ function refreshStats() {
416
+ const c = counts();
417
+ $('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
418
+ $('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
419
+ $('cWeak').textContent = c.weak;
420
+ // The zero-weak wording must match HOW the drawing was read: after a vision read the honest
421
+ // message is "scored above the bar, still spot-check", never the exact-extraction claim
422
+ // (the extractor field is the provenance; absent/non-vision = the no-AI deterministic path).
423
+ const visionRead = /^vision/.test((C && C.source && C.source.extractor) || '');
424
+ $('weakNote').textContent = c.weak
425
+ ? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' scored below ' + WEAK + ' confidence.')
426
+ : (visionRead ? 'No weak traces — every element scored above ' + WEAK + '. Spot-check against the original.' : 'No weak traces — traced exactly from the vector PDF (no AI involved).');
427
+ $('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
428
+ if (c.weak === 0) $('weakOnly').checked = false; // nothing left to isolate — release the filter
429
+ // Reviewed N/M — M is the weak count at load; Accept and Delete both count as reviewed.
430
+ const reviewed = Math.max(0, initialWeak - c.weak);
431
+ $('revLine').hidden = !editable || initialWeak === 0;
432
+ $('revLine').textContent = c.weak === 0 && initialWeak > 0
433
+ ? 'All ' + initialWeak + ' reviewed'
434
+ : 'Reviewed ' + reviewed + '/' + initialWeak;
435
+ }
436
+ function afterEdit() { refreshStats(); render(); buildWeakList(); scheduleSave(); }
437
+
260
438
  function load(contract) {
261
439
  if (!contract || !Array.isArray(contract.sheets) || !contract.sheets.length) { showState('This app has no vectorized drawing yet. Drop a PDF and re-bake to read one.'); return; }
440
+ C = contract;
262
441
  sheet = contract.sheets[contract.active || 0] || contract.sheets[0];
263
442
  if (!sheet || !Array.isArray(sheet.elements)) { showState('The drawing has no elements.'); return; }
264
- const c = counts();
443
+ initialWeak = sheet.elements.filter(isWeak).length;
265
444
  const name = (contract.source && contract.source.name) ? contract.source.name : (sheet.label || 'drawing');
266
445
  $('title').textContent = name;
267
446
  document.title = 'Vectorize — ' + name;
268
- $('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
269
- $('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
270
- $('cWeak').textContent = c.weak;
271
- $('weakNote').textContent = c.weak ? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' below ' + WEAK + ' confidence.') : 'No weak traces — read deterministically.';
272
- $('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
447
+ if (editable) setSaved('ok');
448
+ refreshStats();
273
449
  buildLayers();
450
+ buildWeakList();
274
451
  render(); fit(); $('exportBtn').disabled = false; hideState();
275
452
  }
276
453
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {