@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/floless-server.cjs +87 -21
- package/dist/skills/floless-app-rebake/SKILL.md +7 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +56 -19
- package/dist/web/app.css +15 -0
- package/dist/web/aware.js +165 -14
- package/dist/web/steel-3d-view.js +755 -38
- package/dist/web/steel-editor.html +484 -76
- package/package.json +1 -1
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
|
|
1392
|
-
fields
|
|
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
|
|
1397
|
-
|
|
1398
|
-
|
|
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:
|
|
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(
|
|
1408
|
-
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong
|
|
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
|
-
|
|
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
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
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
|
};
|