@floless/app 0.57.0 → 0.59.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.
package/dist/web/aware.js CHANGED
@@ -1386,26 +1386,43 @@
1386
1386
  const app = currentId && apps.get(currentId);
1387
1387
  if (!app || !app.rebakeInput) return;
1388
1388
  const name = app.rebakeInput;
1389
+ // Show what's currently baked in (the source the run uses) so the user knows what they're
1390
+ // replacing. Contract-backed apps carry it in source.name; suppress the shipped example, and
1391
+ // skip silently for apps that bake into config (no contract) — the "if there is anything" case.
1392
+ let current = '';
1393
+ try {
1394
+ const cr = await fetch(`/api/contract/${encodeURIComponent(app.id)}`);
1395
+ if (cr.ok) {
1396
+ const doc = await cr.json().catch(() => null);
1397
+ const nm = doc && doc.source && typeof doc.source.name === 'string' ? doc.source.name : '';
1398
+ if (nm && !/^example\b/i.test(nm)) current = nm;
1399
+ }
1400
+ } catch { /* no current source — fine, just don't show the row */ }
1401
+ const fields = [];
1402
+ if (current) fields.push({ type: 'current', name: '__current', label: 'Currently loaded', value: current });
1403
+ fields.push({ name, label: `${name} — new source drawing(s)`, type: 'files', accept: ['pdf', 'png', 'jpg', 'webp'], required: true });
1389
1404
  const res = await formModal({
1390
1405
  title: `Re-read & re-bake · ${app.displayName}`,
1391
- sub: 'Attach the new drawing (PDF or image). Your terminal AI re-reads it and updates this workflow, then asks you to approve — nothing changes until you do.',
1392
- fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['pdf', 'png', 'jpg', 'webp'] }],
1406
+ sub: 'Attach the new drawings (PDF or image) — one or several sheets. Your terminal AI re-reads them and updates this workflow, then asks you to approve — nothing changes until you do.',
1407
+ fields,
1393
1408
  okLabel: 'Queue re-bake',
1394
1409
  });
1395
1410
  if (!res) return;
1396
- const dataUrl = res[name];
1397
- const sourceName = res[name + '__filename'] || undefined;
1398
- if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
1411
+ const files = Array.isArray(res[name]) ? res[name] : [];
1412
+ if (!files.length) { showToast('attach a drawing to re-bake', 'warn'); return; }
1413
+ const sourceName = files[0].name || undefined; // provenance: the first file's name
1414
+ const n = files.length;
1399
1415
  try {
1400
1416
  const r = await fetch('/api/rebake', {
1401
1417
  method: 'POST',
1402
1418
  headers: { 'content-type': 'application/json' },
1403
- body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: [{ dataUrl }] }),
1419
+ body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: files.map((f) => ({ name: f.name, dataUrl: f.dataUrl })) }),
1404
1420
  });
1421
+ if (r.status === 413) { showToast('those files are too large to queue together — attach fewer or smaller sheets', 'warn'); return; }
1405
1422
  const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
1406
1423
  if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
1407
- showToast('Queued — your terminal AI will re-read it and update this workflow, then ask you to approve.', 'ok');
1408
- appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, update this workflow, and ask you to approve the new lock.`);
1424
+ showToast(`Queued — your terminal AI will re-read ${n} drawing${n > 1 ? 's' : ''} and update this workflow, then ask you to approve.`, 'ok');
1425
+ appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong> (${n} file${n > 1 ? 's' : ''}). Your terminal AI will re-read the drawing${n > 1 ? 's' : ''}, update this workflow, and ask you to approve the new lock.`);
1409
1426
  } catch { showToast('could not queue re-bake', 'err'); }
1410
1427
  }
1411
1428
 
@@ -2250,6 +2267,13 @@
2250
2267
  if (f.type === 'checkbox') {
2251
2268
  return `<label class="modal-check" for="${id}"><input type="checkbox" id="${id}" data-fm-check="${escapeHtml(f.name)}"${f.value ? ' checked' : ''}><span>${escapeHtml(f.label || f.name)}</span></label>`;
2252
2269
  }
2270
+ // Read-only "what's currently loaded" context row (e.g. the source drawing already
2271
+ // baked into a re-bake app). Inert — a muted label + glyph + filename, not a field.
2272
+ if (f.type === 'current') {
2273
+ const cext = (/\.([a-z0-9]+)$/i.exec(f.value || '') || [])[1];
2274
+ const cglyph = String(cext || '').toLowerCase() === 'pdf' ? 'pdf' : 'img';
2275
+ return `<div class="rebake-current"><span class="rebake-current-label">${escapeHtml(f.label || 'Currently loaded')}</span><span class="fm-file-glyph">${cglyph}</span><span class="fm-file-name" title="${escapeAttr(f.value)}">${escapeHtml(f.value)}</span></div>`;
2276
+ }
2253
2277
  let ctl;
2254
2278
  if (f.type === 'images') {
2255
2279
  ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
@@ -2266,6 +2290,17 @@
2266
2290
  <div class="fm-drop" role="button" tabindex="0" aria-label="Attach an image or PDF"></div>
2267
2291
  <input type="file" accept="${escapeAttr(acceptAttr)}" class="fm-file-input" tabindex="-1" aria-hidden="true">
2268
2292
  </div>`;
2293
+ } else if (f.type === 'files') {
2294
+ // MULTI image/PDF Visual Input (e.g. a whole framing set). Persistent drop zone +
2295
+ // a wrap-flow chip list below; drag-drop + click + paste; up to 8 (server cap).
2296
+ const accepts = (f.accept && f.accept.length) ? f.accept : ['pdf', 'png', 'jpg', 'webp'];
2297
+ const acceptAttr = accepts.map((e) => '.' + String(e).replace(/^\./, '')).join(',');
2298
+ const hint = accepts.join(', ').toUpperCase();
2299
+ ctl = `<div class="fm-files" data-fm-files="${escapeAttr(f.name)}" data-accept-hint="${escapeAttr(hint)}"${f.required ? ' data-required="1"' : ''}>
2300
+ <div class="fm-drop" role="button" tabindex="0" aria-label="Attach drawings — drop or click"><span class="fm-drop-main">Drop or click to attach files</span><span class="fm-drop-hint">${escapeHtml(hint)} · paste also works</span></div>
2301
+ <input type="file" accept="${escapeAttr(acceptAttr)}" multiple class="fm-file-input" tabindex="-1" aria-hidden="true">
2302
+ <div class="fm-files-list" aria-live="polite" hidden></div>
2303
+ </div>`;
2269
2304
  } else {
2270
2305
  ctl = f.multiline
2271
2306
  ? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
@@ -2281,10 +2316,18 @@
2281
2316
  $ok.classList.toggle('primary', !danger);
2282
2317
  $cancel.classList.toggle('primary', !!danger);
2283
2318
  showModal($formModal);
2284
- const first = $body.querySelector('input:not(.fm-file-input),textarea');
2319
+ // Prefer a real text input; else a file drop zone (so keyboard users land somewhere
2320
+ // actionable rather than a possibly-disabled OK button).
2321
+ const first = $body.querySelector('input:not(.fm-file-input),textarea')
2322
+ || $body.querySelector('.fm-files .fm-drop, .fm-file .fm-drop, .fm-images .fm-drop');
2285
2323
  if (first) { first.focus(); if (first.select) first.select(); }
2286
2324
  else if (danger) $cancel.focus();
2287
2325
  else $ok.focus();
2326
+ // OK-gate probes: required consent checks (below) AND required multi-file fields must be
2327
+ // satisfied. Declared here so the fm-files setup can re-run the gate; wired after all setups.
2328
+ let runGate = () => {};
2329
+ const fileGateProbes = [];
2330
+ let modalClosed = false; // set in done(); guards async FileReader callbacks from touching a closed modal
2288
2331
  // ── images-field setup ──────────────────────────────────────────────────────
2289
2332
  const imageStates = new Map();
2290
2333
  $body.querySelectorAll('.fm-images').forEach((box) => {
@@ -2362,6 +2405,90 @@
2362
2405
  fileStates.set(name, { st, setFile });
2363
2406
  render();
2364
2407
  });
2408
+ // ── multi-file field setup (image/PDF set, e.g. a framing set) ─────────────────
2409
+ const fileListStates = new Map();
2410
+ $body.querySelectorAll('.fm-files').forEach((box) => {
2411
+ const name = box.dataset.fmFiles;
2412
+ const MAX = 8;
2413
+ const hint = box.dataset.acceptHint || 'PDF, PNG, JPG, WEBP';
2414
+ const acceptExts = hint.toLowerCase().split(/[,\s]+/).filter(Boolean);
2415
+ const state = []; // { name, ext, dataUrl, reading }
2416
+ const drop = box.querySelector('.fm-drop');
2417
+ const main = drop.querySelector('.fm-drop-main');
2418
+ const hintEl = drop.querySelector('.fm-drop-hint');
2419
+ const fileInput = box.querySelector('.fm-file-input');
2420
+ const list = box.querySelector('.fm-files-list');
2421
+ const isFileDrag = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
2422
+ // Prefer the filename extension; fall back to the MIME type for a no-name paste. Normalize
2423
+ // jpeg→jpg so a `.jpeg` file (or a pasted image/jpeg) is accepted like the server does.
2424
+ const extOf = (fn, type) => {
2425
+ const m = /\.([a-z0-9]+)$/i.exec(fn || '');
2426
+ let e = (m ? m[1] : '').toLowerCase();
2427
+ if (!e && type) e = type === 'application/pdf' ? 'pdf' : type.startsWith('image/') ? type.slice(6) : '';
2428
+ return e === 'jpeg' ? 'jpg' : e;
2429
+ };
2430
+ const readyCount = () => state.filter((s) => !s.reading && s.dataUrl).length;
2431
+ const syncDrop = () => {
2432
+ const n = state.length;
2433
+ const atCap = n >= MAX;
2434
+ drop.classList.toggle('at-cap', atCap);
2435
+ if (atCap) { main.textContent = `Maximum ${MAX} files attached`; hintEl.textContent = 'Remove one to add another'; }
2436
+ else if (n > 0) { main.textContent = `${n} of ${MAX} attached — drop or click to add more`; hintEl.textContent = `${hint} · paste also works`; }
2437
+ else { main.textContent = 'Drop or click to attach files'; hintEl.textContent = `${hint} · paste also works · up to ${MAX}`; }
2438
+ };
2439
+ const renderList = () => {
2440
+ list.hidden = state.length === 0;
2441
+ list.innerHTML = state.map((s, i) => s.reading
2442
+ ? `<div class="fm-filechip is-reading"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name">Reading…</span></div>`
2443
+ : `<div class="fm-filechip"><span class="fm-file-glyph">${s.ext === 'pdf' ? 'pdf' : 'img'}</span><span class="fm-filechip-name" title="${escapeAttr(s.name)}">${escapeHtml(s.name)}</span><button type="button" class="fm-filechip-del" data-i="${i}" aria-label="Remove ${escapeAttr(s.name)}">×</button></div>`
2444
+ ).join('');
2445
+ list.querySelectorAll('.fm-filechip-del').forEach((b) => {
2446
+ b.onclick = () => {
2447
+ const idx = Number(b.dataset.i);
2448
+ state.splice(idx, 1);
2449
+ renderList(); syncDrop(); runGate();
2450
+ // Keep focus in the field, not lost to <body>: next chip's ×, else the drop zone.
2451
+ const dels = list.querySelectorAll('.fm-filechip-del');
2452
+ (dels[Math.min(idx, dels.length - 1)] || drop).focus();
2453
+ };
2454
+ });
2455
+ };
2456
+ const addFile = (file) => {
2457
+ if (!file) return;
2458
+ if (state.length >= MAX) { showToast(`Max ${MAX} files`, 'warn'); return; }
2459
+ const ext = extOf(file.name, file.type);
2460
+ if (acceptExts.length && !acceptExts.includes(ext)) { showToast(`${file.name || 'file'} — ${hint} only`, 'warn'); return; }
2461
+ const entry = { name: file.name || ('pasted.' + (ext || 'png')), ext, dataUrl: '', reading: true };
2462
+ state.push(entry);
2463
+ renderList(); syncDrop(); runGate(); // disable OK while this file reads (gate blocks on any reading)
2464
+ const reader = new FileReader();
2465
+ // Guard on modalClosed: a large read can finish after the modal is cancelled/closed; without
2466
+ // this the stale callback would mutate the SHARED $ok gate of the next formModal.
2467
+ reader.onload = () => { if (modalClosed) return; entry.dataUrl = reader.result; entry.reading = false; renderList(); syncDrop(); runGate(); };
2468
+ reader.onerror = () => { if (modalClosed) return; const i = state.indexOf(entry); if (i >= 0) state.splice(i, 1); renderList(); syncDrop(); runGate(); showToast(`could not read ${file.name || 'the file'}`, 'err'); };
2469
+ reader.readAsDataURL(file);
2470
+ };
2471
+ const addFiles = (files) => {
2472
+ const arr = Array.from(files || []);
2473
+ const room = Math.max(0, MAX - state.length);
2474
+ if (arr.length > room) showToast(`Max ${MAX} files`, 'warn');
2475
+ arr.slice(0, room).forEach(addFile);
2476
+ };
2477
+ drop.onclick = () => { if (state.length < MAX) fileInput.click(); };
2478
+ drop.onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && state.length < MAX) { e.preventDefault(); fileInput.click(); } };
2479
+ fileInput.onchange = () => { addFiles(fileInput.files); fileInput.value = ''; };
2480
+ // Native drag-drop: bind on the whole box (so the chip area isn't a dead zone), toggle the
2481
+ // visual on the inner drop. dragover MUST preventDefault or `drop` never fires.
2482
+ box.ondragenter = (e) => { if (isFileDrag(e)) { e.preventDefault(); if (state.length < MAX) { drop.classList.add('drag-over'); main.textContent = 'Drop to attach'; } } };
2483
+ box.ondragover = (e) => { if (isFileDrag(e)) { e.preventDefault(); e.dataTransfer.dropEffect = state.length < MAX ? 'copy' : 'none'; } };
2484
+ box.ondragleave = (e) => { if (!box.contains(e.relatedTarget)) { drop.classList.remove('drag-over'); syncDrop(); } };
2485
+ box.ondrop = (e) => { if (!isFileDrag(e)) return; e.preventDefault(); drop.classList.remove('drag-over'); addFiles(e.dataTransfer.files); syncDrop(); };
2486
+ fileListStates.set(name, { state, addFiles });
2487
+ // Gate probe returns 0 (blocks OK) while ANY file is still reading — so you can't submit a
2488
+ // partial set (collect() drops still-reading files); ready count only once all reads finish.
2489
+ if (box.dataset.required) fileGateProbes.push(() => (state.some((s) => s.reading) ? 0 : readyCount()));
2490
+ renderList(); syncDrop();
2491
+ });
2365
2492
  // ── paste support: an image into the modal → the images field, else the file field ──
2366
2493
  $body.onpaste = (e) => {
2367
2494
  const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
@@ -2386,6 +2513,14 @@
2386
2513
  });
2387
2514
  return;
2388
2515
  }
2516
+ const filesBox = $body.querySelector('.fm-files');
2517
+ if (filesBox) {
2518
+ if (!fileItems.length) return;
2519
+ e.preventDefault();
2520
+ const entry = fileListStates.get(filesBox.dataset.fmFiles);
2521
+ if (entry) entry.addFiles(fileItems.map((it) => it.getAsFile()).filter(Boolean));
2522
+ return;
2523
+ }
2389
2524
  const fileBox = $body.querySelector('.fm-file');
2390
2525
  if (!fileBox || !fileItems.length) return;
2391
2526
  e.preventDefault();
@@ -2393,15 +2528,26 @@
2393
2528
  const file = fileItems[0].getAsFile();
2394
2529
  if (entry && file) entry.setFile(file);
2395
2530
  };
2531
+ // Stray-drop guard: a file dropped onto the modal but MISSING a drop zone must not
2532
+ // navigate the page to the file. The .fm-files box handles real drops; this only
2533
+ // neutralizes the misses.
2534
+ $body.ondragover = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault(); };
2535
+ $body.ondrop = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault(); };
2396
2536
  // Required consent checkboxes (e.g. the fork liability terms) disable OK until ticked.
2397
2537
  const requiredChecks = Array.from($body.querySelectorAll('[data-fm-check]')).filter((el) => {
2398
2538
  const f = fields.find((x) => x.name === el.dataset.fmCheck);
2399
2539
  return f && f.required;
2400
2540
  });
2401
- if (requiredChecks.length) {
2402
- const syncOkGate = () => { $ok.disabled = requiredChecks.some((el) => !el.checked); };
2403
- requiredChecks.forEach((el) => { el.onchange = syncOkGate; });
2404
- syncOkGate();
2541
+ // Unified OK gate: every required consent check ticked AND every required multi-file field
2542
+ // has ≥1 ready file. (runGate was declared up top so fm-files callbacks can re-run it.)
2543
+ if (requiredChecks.length || fileGateProbes.length) {
2544
+ runGate = () => {
2545
+ const checksOk = !requiredChecks.some((el) => !el.checked);
2546
+ const filesOk = !fileGateProbes.some((getN) => getN() < 1);
2547
+ $ok.disabled = !(checksOk && filesOk);
2548
+ };
2549
+ requiredChecks.forEach((el) => { el.onchange = runGate; });
2550
+ runGate();
2405
2551
  }
2406
2552
  const collect = () => {
2407
2553
  const out = {};
@@ -2413,12 +2559,17 @@
2413
2559
  // expose filename alongside so callers can capture provenance (e.g. rebake → sourceName)
2414
2560
  out[name + '__filename'] = entry.st.name || '';
2415
2561
  });
2562
+ // Multi-file field → array of { name, dataUrl } (only fully-read files).
2563
+ fileListStates.forEach((entry, name) => {
2564
+ out[name] = entry.state.filter((s) => !s.reading && s.dataUrl).map((s) => ({ name: s.name, dataUrl: s.dataUrl }));
2565
+ });
2416
2566
  return out;
2417
2567
  };
2418
2568
  const done = (result) => {
2419
2569
  _formModalOpen = false;
2570
+ modalClosed = true;
2420
2571
  hideModal($formModal);
2421
- $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
2572
+ $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null; $body.ondragover = null; $body.ondrop = null;
2422
2573
  $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); $ok.disabled = false; // reset chrome for the next caller
2423
2574
  resolve(result);
2424
2575
  };