@floless/app 0.83.0 → 0.85.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/app.css CHANGED
@@ -3322,10 +3322,13 @@ body {
3322
3322
  .mode-switch button.active { background:var(--accent-soft); color:var(--accent-bright); }
3323
3323
 
3324
3324
  /* Mode gating: Workflows chrome hides wholesale in Workspaces (hidden, never disabled).
3325
- ≡ keeps only app-wide items; Dashboard is Workflows-scoped (spec §2). */
3325
+ ≡ keeps only app-wide items; Dashboard is Workflows-scoped (spec §2).
3326
+ #guide-beacon is NOT hidden here — it's the ONE shared control that serves both modes:
3327
+ aware.js repaints it as the Workspaces document-guide (drop → review → approve → export)
3328
+ for the open project, and toggles its `hidden` attribute in JS (hidden on the landing). */
3326
3329
  .app.mode-workspaces .controls > label,
3327
3330
  .app.mode-workspaces .wf-combo, .app.mode-workspaces #wf-update, .app.mode-workspaces #wf-forked,
3328
- .app.mode-workspaces #guide-beacon, .app.mode-workspaces #run-state,
3331
+ .app.mode-workspaces #run-state,
3329
3332
  .app.mode-workspaces #restore-btn, .app.mode-workspaces #customize-btn,
3330
3333
  .app.mode-workspaces #compile-btn, .app.mode-workspaces #sim-btn,
3331
3334
  .app.mode-workspaces #run-btn, .app.mode-workspaces #stop-run-btn,
@@ -3378,10 +3381,24 @@ body {
3378
3381
  color:var(--text-muted); font-size:12px; width:100%; text-align:center; padding:12px; }
3379
3382
  .ws-seed:hover { background:var(--accent-soft); border-color:var(--accent); }
3380
3383
  .ws-seed .plus { font-size:24px; color:var(--accent); line-height:1; }
3384
+ /* Drop-a-drawing seed card (vectorize): a click-to-pick + drag/drop target. .drag-over lights it up
3385
+ while a file hovers (same intent as the editor's .canvas-drop-target.active). */
3386
+ .ws-seed-drop.drag-over { background:var(--accent-soft); border-color:var(--accent); border-style:solid; }
3387
+ .ws-seed .ws-seed-headline { font-size:12.5px; font-weight:600; color:var(--text); line-height:1.35; }
3388
+ .ws-seed .ws-seed-sub { font-size:11px; color:var(--text-dim); line-height:1.4; max-width:220px; }
3381
3389
  .ws-empty-note { color:var(--text-dim); font-size:12px; margin-top:10px; }
3382
3390
 
3383
3391
  /* open project */
3384
3392
  .ws-project { flex:1; min-height:0; display:flex; flex-direction:column; }
3393
+ /* Source-step placeholder — a filter-less workspace app (vectorize) shows this centered pane where
3394
+ steel loads its filter iframe. Muted + baseline-faithful; guides forward (not a dead control).
3395
+ The [hidden] guard is load-bearing: display:flex would otherwise override the hidden attribute. */
3396
+ .ws-source-placeholder { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; padding:24px; }
3397
+ .ws-source-placeholder[hidden] { display:none; }
3398
+ .ws-source-inner { max-width:440px; text-align:center; }
3399
+ .ws-source-ico { font-size:34px; color:var(--accent); display:block; margin-bottom:12px; line-height:1; }
3400
+ .ws-source-title { font-size:14px; font-weight:600; color:var(--text); margin-bottom:6px; }
3401
+ .ws-source-sub { font-size:12px; color:var(--text-dim); line-height:1.55; }
3385
3402
  .ws-crumb-row { padding:12px 14px 8px; display:flex; justify-content:space-between; align-items:center; }
3386
3403
  .ws-crumb { font-size:12px; color:var(--text-muted); }
3387
3404
  .ws-crumb-link { background:none; border:none; padding:0; color:var(--text-muted); font-size:12px; cursor:pointer; }
package/dist/web/aware.js CHANGED
@@ -575,7 +575,66 @@
575
575
  // The beacon pulses while a workflow is new to the user.
576
576
  function beaconIsNew(id) { const g = guideFor(id); return !g.firstRunDone && !g.dismissed; }
577
577
 
578
+ // ── Workspaces variant of the SAME beacon ───────────────────────────────────
579
+ // Workspaces is the DOCUMENT archetype (drop → review → approve → export), not the
580
+ // pipeline archetype (inputs → compile → run). One beacon element, two content
581
+ // sources: in Workspaces mode with a project open, this layer drives #guide-beacon +
582
+ // #guide-modal instead of the workflow layer. It REUSES the LS_GUIDE store and its
583
+ // get/set helpers — no parallel system — but keys its record under `ws:<app>` so a
584
+ // Workflows Run of an app (firstRunDone) never calms its Workspaces beacon and vice
585
+ // versa (vectorize is BOTH a pipeline app and a workspace app). "New" here calms on the
586
+ // first Approve for the app, mirroring how the workflow beacon calms on the first Run.
587
+ // workspaces.js owns the project state, so it hands us a small snapshot (app id + which
588
+ // lifecycle signals are true) via flolessBridge; we own the chrome, glyphs, and copy.
589
+ let wsGuideCtx = null; // { app, name, displayName, approved, exported } | null (name = the open project's name)
590
+ const wsGuideKey = (app) => `ws:${app}`;
591
+ const inWorkspacesMode = () => { const a = document.getElementById('app'); return !!(a && a.classList.contains('mode-workspaces')); };
592
+ // The Workspaces beacon is active only in Workspaces mode with a project open (a ctx set).
593
+ const wsGuideActive = () => inWorkspacesMode() && !!wsGuideCtx;
594
+ function wsBeaconIsNew(app) { const g = guideFor(wsGuideKey(app)); return !g.approveDone && !g.dismissed; }
595
+
596
+ // The document-lifecycle checklist. Approve/Export carry an honest boolean from project
597
+ // state; "Review" borrows Approve's boolean (you can't sign off without having looked —
598
+ // Approve sits next to the editor), never a faked "opened the tab" signal; "Drop" is
599
+ // done whenever the guide is open (a project only exists after a drawing was dropped —
600
+ // a cancelled/failed drop is rolled back, never left as an empty project). The current
601
+ // step = the first not-done row, so the arrow lands on the next real action.
602
+ function workspaceGuideSteps(ctx) {
603
+ return [
604
+ { label: 'Drop a drawing', note: 'PDF or image — vectors come out editable', done: true },
605
+ { label: 'Review the vectors', note: 'check layers, isolate low-confidence traces', done: !!ctx.approved },
606
+ { label: 'Approve', note: 'signs off this version, unlocks Export', done: !!ctx.approved },
607
+ { label: 'Export SVG', note: 'clean vectors, take them anywhere', done: !!ctx.exported },
608
+ ];
609
+ }
610
+
611
+ // Paint #guide-beacon for the open workspace project (called by paintBeacon when in
612
+ // Workspaces mode). Same glyphs/label/pulse dialect as the workflow beacon; names the
613
+ // open PROJECT (not the app id), matching the crumb/picker.
614
+ function paintWorkspaceBeacon() {
615
+ const $b = document.getElementById('guide-beacon');
616
+ if (!$b) return;
617
+ const ctx = wsGuideCtx;
618
+ if (!ctx) { $b.hidden = true; $b.classList.remove('is-new', 'spotlit'); return; }
619
+ const isNew = wsBeaconIsNew(ctx.app);
620
+ $b.hidden = false;
621
+ $b.classList.remove('spotlit');
622
+ $b.classList.toggle('is-new', isNew);
623
+ const ico = $b.querySelector('.guide-beacon-ico');
624
+ const label = $b.querySelector('.guide-beacon-label');
625
+ if (ico) ico.textContent = isNew ? '✦' : '?';
626
+ if (label) label.textContent = isNew ? 'Start here' : 'Guide';
627
+ // No "workflow"/"Run" vocabulary — this is a document project.
628
+ $b.dataset.tip = isNew
629
+ ? 'New here? See what this project does and how to take it from drawing to export.'
630
+ : 'Open the guide for this project.';
631
+ $b.setAttribute('aria-label', (isNew ? 'Start here — guide for ' : 'Guide for ') + (ctx.name || ctx.displayName || ctx.app));
632
+ }
633
+
578
634
  function paintBeacon(app) {
635
+ // In Workspaces mode the beacon belongs to the open project, not the last-loaded
636
+ // workflow — hand off to the workspace layer (which hides it when no project is open).
637
+ if (inWorkspacesMode()) { paintWorkspaceBeacon(); return; }
579
638
  const $b = document.getElementById('guide-beacon');
580
639
  if (!$b) return;
581
640
  if (!app) { $b.hidden = true; $b.classList.remove('is-new', 'spotlit'); return; }
@@ -624,30 +683,66 @@
624
683
  return steps;
625
684
  }
626
685
 
686
+ // Shared checklist renderer — shape carries state (✓ done · → current · ☐ to-do), never
687
+ // colour alone. The current step is the first not-done row. Labels/notes are authored
688
+ // constants but still pass escapeHtml (defence-in-depth; the security hook forbids raw
689
+ // string→DOM). Both the workflow and workspace guides render through this.
690
+ function guideStepsHtml(steps) {
691
+ const curIdx = steps.findIndex((s) => !s.done);
692
+ return `<ol class="guide-steps" role="list">${steps.map((s, i) => {
693
+ const state = s.done ? 'done' : i === curIdx ? 'current' : 'todo';
694
+ const box = s.done ? '✓' : i === curIdx ? '→' : '☐';
695
+ const cur = i === curIdx ? ' aria-current="step"' : '';
696
+ return `<li class="guide-step ${state}"${cur}><span class="guide-box" aria-hidden="true">${box}</span>` +
697
+ `<span class="guide-step-text"><span class="guide-step-label">${escapeHtml(s.label)}</span> ` +
698
+ `<span class="guide-step-note">— ${escapeHtml(s.note)}</span></span>` +
699
+ `<span class="sr-only">${s.done ? '(done)' : i === curIdx ? '(next step)' : '(to do)'}</span></li>`;
700
+ }).join('')}</ol>`;
701
+ }
702
+
703
+ // The Workspaces guide body — the document life-cycle, not node mechanics. No
704
+ // "Compile"/"Run"/"Save" language (those pipeline verbs are absent here; the verb is
705
+ // Approve). "Walk me through it" is a Workflows relay (terminal AI + the workflow's own
706
+ // skill), so it's hidden for a workspace — the beacon is a self-contained checklist here.
707
+ // The title is the open project's user-authored name → set via textContent only (never
708
+ // into innerHTML); the body innerHTML is authored constants + escaped step labels/notes.
709
+ function renderWorkspaceGuide() {
710
+ const ctx = wsGuideCtx;
711
+ const $body = document.getElementById('guide-body');
712
+ const $title = document.getElementById('guide-title');
713
+ const $eyebrow = document.getElementById('guide-eyebrow');
714
+ const $sub = document.querySelector('#guide-modal .modal-sub');
715
+ if (!ctx || !$body) return;
716
+ if ($title) $title.textContent = ctx.name || ctx.displayName || ctx.app;
717
+ if ($eyebrow) $eyebrow.textContent = wsBeaconIsNew(ctx.app) ? '✦ Start here' : '✦ Project guide';
718
+ if ($sub) $sub.textContent = 'What this project does, and how to take it from drawing to export.';
719
+ const descHtml = `<div class="guide-desc"><p>Drop in a drawing — a PDF, scan, or photo — and it comes back as clean, editable 2D vectors. Anything the trace wasn’t sure about gets flagged for you to check before you approve and export.</p></div>`;
720
+ const stepsHtml = guideStepsHtml(workspaceGuideSteps(ctx));
721
+ $body.innerHTML =
722
+ `<div class="guide-section-label">What this does</div>` + descHtml +
723
+ `<div class="guide-section-label">How to go through it</div>` + stepsHtml;
724
+ }
725
+
627
726
  function renderGuide() {
727
+ // In Workspaces mode with a project open, the guide describes the document life-cycle.
728
+ if (wsGuideActive()) { renderWorkspaceGuide(); return; }
628
729
  const app = currentId && apps.get(currentId);
629
730
  const $body = document.getElementById('guide-body');
630
731
  const $title = document.getElementById('guide-title');
631
732
  const $eyebrow = document.getElementById('guide-eyebrow');
733
+ const $sub = document.querySelector('#guide-modal .modal-sub');
632
734
  if (!app || !$body) return;
633
735
  if ($title) $title.textContent = app.displayName || app.id;
634
736
  if ($eyebrow) $eyebrow.textContent = beaconIsNew(app.id) ? '✦ Start here' : '✦ Workflow guide';
737
+ // Restore the workflow sub in case a prior Workspaces open overwrote it.
738
+ if ($sub) $sub.textContent = 'What this workflow does, and how to go through it.';
635
739
  const desc = (app.description || '').trim();
636
740
  const descHtml = desc
637
741
  ? `<div class="guide-desc">${desc.split(/\n{2,}/).map((p) => `<p>${mdInline(escapeHtml(p.trim()))}</p>`).join('')}</div>`
638
742
  : `<div class="guide-desc guide-desc-empty"><p>A ${app.nodes.length}-step workflow. Set its inputs, Compile to freeze the approved lock, then Run it against your live host.</p></div>`;
639
- const steps = guideSteps(app);
640
- const curIdx = steps.findIndex((s) => !s.done);
641
- const stepsHtml = `<ol class="guide-steps" role="list">${steps.map((s, i) => {
642
- const state = s.done ? 'done' : i === curIdx ? 'current' : 'todo';
643
- // Shape carries state, not colour alone: ✓ done · → current · ☐ to-do.
644
- const box = s.done ? '✓' : i === curIdx ? '→' : '☐';
645
- const cur = i === curIdx ? ' aria-current="step"' : '';
646
- return `<li class="guide-step ${state}"${cur}><span class="guide-box" aria-hidden="true">${box}</span>` +
647
- `<span class="guide-step-text"><span class="guide-step-label">${escapeHtml(s.label)}</span> ` +
648
- `<span class="guide-step-note">— ${escapeHtml(s.note)}</span></span>` +
649
- `<span class="sr-only">${s.done ? '(done)' : i === curIdx ? '(next step)' : '(to do)'}</span></li>`;
650
- }).join('')}</ol>`;
743
+ // Static authored strings + escapeHtml on every dynamic value (app.description, step
744
+ // label/note) no raw user string reaches the DOM (the established guide pattern).
745
+ const stepsHtml = guideStepsHtml(guideSteps(app));
651
746
  const walkHint = `<div class="guide-walk-hint">A bigger workflow? <strong>Walk me through it</strong> hands off to your terminal AI for a step-by-step tour using this workflow's own skill.</div>`;
652
747
  $body.innerHTML =
653
748
  `<div class="guide-section-label">What this does</div>` + descHtml +
@@ -655,9 +750,15 @@
655
750
  }
656
751
 
657
752
  function openGuide() {
658
- if (!currentId) { showToast('open a workflow first', 'warn'); return; }
753
+ const ws = wsGuideActive();
754
+ if (!ws && !currentId) { showToast('open a workflow first', 'warn'); return; }
659
755
  renderGuide();
660
756
  const $m = document.getElementById('guide-modal');
757
+ // "Walk me through it →" relays to the terminal AI using a WORKFLOW's own skill — a
758
+ // pipeline concept with no document-workspace analog. Hide it (never disable — an
759
+ // absent control, not a dead one) for the self-contained workspace guide.
760
+ const $walk = document.getElementById('guide-walk');
761
+ if ($walk) $walk.hidden = ws;
661
762
  showModal($m);
662
763
  // Focus the dialog shell (not a button) so the checklist is read first; Tab then
663
764
  // moves to Got it → Walk me through it. Focus returns to the beacon on close.
@@ -665,9 +766,13 @@
665
766
  if (shell) { try { shell.focus(); } catch { /* not focusable */ } }
666
767
  }
667
768
  // Closing the guide (Got it / backdrop / Esc) marks it seen → the beacon stops
668
- // pulsing even before a first run (the user has looked; don't nag).
769
+ // pulsing even before a first run/approve (the user has looked; don't nag). In
770
+ // Workspaces mode this marks the project's app workspace-guide key, not the workflow.
669
771
  function closeGuide() {
670
- if (currentId) {
772
+ if (wsGuideActive()) {
773
+ setGuide(wsGuideKey(wsGuideCtx.app), { dismissed: true });
774
+ paintWorkspaceBeacon();
775
+ } else if (currentId) {
671
776
  setGuide(currentId, { dismissed: true });
672
777
  const app = apps.get(currentId);
673
778
  if (app) paintBeacon(app);
@@ -4660,7 +4765,14 @@
4660
4765
  ? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
4661
4766
  : '';
4662
4767
  const note = req.instruction ? `\nMy note: ${req.instruction}` : '';
4663
- return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract. First GET /api/contract/${req.appId}?project=${req.project} to read the CURRENT contract and PRESERVE each still-present member's id AND its sheet label (mint a NEW id only for a genuinely new member; drop the id of a member the revision deleted) so the version comparison matches members — do NOT renumber the whole model. Then POST /api/projects/${req.project}/revision-read with { contract, message: "<plain-English what changed>", requestId: "${req.id}" }. The server derives the base version + source provenance from this queued request, records a "revision-read" version, and clears the request — do NOT DELETE the request yourself. If you can't read the set, POST /api/projects/${req.project}/revision-requests/${req.id}/fail with { error }.${snaps}${note}`;
4768
+ const post = `Then POST /api/projects/${req.project}/revision-read with { contract, message: "<plain-English what changed>", requestId: "${req.id}" }. The server derives the base version + source provenance from this queued request, records a "revision-read" version, and clears the request — do NOT DELETE the request yourself. If you can't read the set, POST /api/projects/${req.project}/revision-requests/${req.id}/fail with { error }.`;
4769
+ // App-aware: the target contract shape differs per workspace app. A vectorize project holds a
4770
+ // drawing.vector/v1 (clean editable 2D linework), NOT a steel takeoff — instructing the AI to
4771
+ // read steel members would produce the wrong contract for the vector editor (spec §3.3g).
4772
+ if (req.appId === 'vectorize') {
4773
+ return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing was attached — re-read (vectorize) it into the SAME project as a new version (do NOT create a new project). Vectorize the drawing per the floless-app-vectorize skill into a drawing.vector/v1 contract: emit clean, editable 2D vector geometry (sheets → elements: lines / polylines / curves / text, each on its layer), NOT steel members or a takeoff. A vector PDF is read deterministically (exact geometry, no guessing); a photo, scan or hand sketch is traced by a fenced vision extraction that flags every uncertain stroke (low confidence) for the user's review. ${post}${snaps}${note}`;
4774
+ }
4775
+ return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract. First GET /api/contract/${req.appId}?project=${req.project} to read the CURRENT contract and PRESERVE each still-present member's id AND its sheet label (mint a NEW id only for a genuinely new member; drop the id of a member the revision deleted) so the version comparison matches members — do NOT renumber the whole model. ${post}${snaps}${note}`;
4664
4776
  }
4665
4777
  return '';
4666
4778
  }
@@ -4688,7 +4800,10 @@
4688
4800
  function markedInstruction(req) {
4689
4801
  const body = instructionFor(req);
4690
4802
  if (!body) return '';
4691
- const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
4803
+ // revision-read's skill is app-specific: steel takeoff by default, floless-app-vectorize for a
4804
+ // vector project — keep the clipboard marker in step with the app-aware instruction body above.
4805
+ let skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
4806
+ if (req.type === 'revision-read' && req.appId === 'vectorize') skill = 'floless-app-vectorize';
4692
4807
  const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
4693
4808
  // A revision-read is cleared by its own /revision-read POST — a separate DELETE would 404. Every
4694
4809
  // other type is applied-then-DELETEd by the terminal AI.
@@ -6459,6 +6574,41 @@
6459
6574
  instructionFor,
6460
6575
  markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
6461
6576
  formModal, // the shared styled prompt/form modal — workspaces.js reuses it (never a native prompt)
6577
+ // ── Workspaces guide beacon (the SAME #guide-beacon/#guide-modal, document variant) ──
6578
+ // workspaces.js owns the open project + its lifecycle state; aware.js owns the beacon
6579
+ // chrome. workspaces.js calls setWorkspaceGuide() with a small snapshot whenever the
6580
+ // open project (or its approve/export state) changes, clearWorkspaceGuide() on close/
6581
+ // Workflows-mode, and markWorkspaceApproved() the first time an Approve succeeds (which
6582
+ // calms the beacon, mirroring markFirstRunDone for a workflow's first Run).
6583
+ setWorkspaceGuide(ctx) {
6584
+ // ctx: { app, name, displayName?, approved, exported }. Null/!app → treat as clear.
6585
+ wsGuideCtx = (ctx && ctx.app) ? { ...ctx } : null;
6586
+ if (inWorkspacesMode()) {
6587
+ paintWorkspaceBeacon();
6588
+ const $m = document.getElementById('guide-modal');
6589
+ if ($m && $m.classList.contains('show') && wsGuideCtx) renderWorkspaceGuide();
6590
+ }
6591
+ },
6592
+ clearWorkspaceGuide() {
6593
+ wsGuideCtx = null;
6594
+ // Repaint through the mode-correct painter. In Workspaces mode paintWorkspaceBeacon() hides the
6595
+ // (now-cleared) workspace beacon. On the mode-switch-BACK path we're already in Workflows mode,
6596
+ // so repaint the WORKFLOW beacon for the current app instead of no-op'ing — otherwise the stale
6597
+ // workspace label/tooltip/is-new lingers until the next workflow load. paintBeacon(null) is safe
6598
+ // (it just hides the workflow beacon when no app is open).
6599
+ if (inWorkspacesMode()) paintWorkspaceBeacon();
6600
+ else paintBeacon(currentId ? apps.get(currentId) : null);
6601
+ },
6602
+ markWorkspaceApproved(app) {
6603
+ if (!app || guideFor(wsGuideKey(app)).approveDone) return;
6604
+ setGuide(wsGuideKey(app), { approveDone: true });
6605
+ if (wsGuideCtx && wsGuideCtx.app === app) {
6606
+ wsGuideCtx.approved = true;
6607
+ if (inWorkspacesMode()) paintWorkspaceBeacon();
6608
+ const $m = document.getElementById('guide-modal');
6609
+ if ($m && $m.classList.contains('show')) renderWorkspaceGuide();
6610
+ }
6611
+ },
6462
6612
  };
6463
6613
 
6464
6614
  // ── boot ──────────────────────────────────────────────────────────────────────
@@ -80,7 +80,7 @@
80
80
  </button>
81
81
  <span class="ctl-sep" aria-hidden="true"></span>
82
82
  <button id="ws-approve" type="button" class="primary"
83
- data-tip="Locks in this version of the model — your edits already auto-save; this is the sign-off.">✓ Approve</button>
83
+ data-tip="Locks in this version — your edits already auto-save; this is the sign-off.">✓ Approve</button>
84
84
  </div>
85
85
  <!-- Per-workflow onboarding beacon. Pulses "✨ Start here" while this workflow is
86
86
  new to the user (no first Run completed, not dismissed); calms to a quiet
@@ -173,7 +173,7 @@
173
173
  <!-- Drawings step: a shell toolbar band + revision-read status card ABOVE the filter iframe
174
174
  (chrome around it, never inside — the filter owns its own layout). Shown only on Drawings. -->
175
175
  <div class="ws-drawings-bar" id="ws-drawings-bar" hidden>
176
- <span class="ws-drawings-bar-label">Drawing set</span>
176
+ <span class="ws-drawings-bar-label">Source</span>
177
177
  <span class="ws-drawings-bar-sep"></span>
178
178
  <button type="button" id="ws-attach-revision" class="btn-mini" data-tip="Attach a revised drawing set — your terminal AI reads it into a new version">⤒ Attach revised drawings…</button>
179
179
  <input type="file" id="ws-revision-file" accept=".pdf,image/*" multiple hidden>
@@ -183,6 +183,16 @@
183
183
  Same-origin like #contract-editor-frame (they call /api/contract directly). -->
184
184
  <iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
185
185
  <iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
186
+ <!-- Source-step placeholder: a workspace app with NO filter iframe (vectorize) shows this
187
+ where steel shows its filter. The ws-drawings-bar (attach/replace to re-vectorize) sits
188
+ above; this pane explains the Source step. Toggled by setStep() in workspaces.js. -->
189
+ <div class="ws-source-placeholder" id="ws-source-placeholder" hidden>
190
+ <div class="ws-source-inner">
191
+ <span class="ws-source-ico" aria-hidden="true">▨</span>
192
+ <div class="ws-source-title">This project's source drawing</div>
193
+ <div class="ws-source-sub">The vectors on the Vectors tab were read from the drawing you imported. Drop a revised drawing in the bar above to re-vectorize it as a new version.</div>
194
+ </div>
195
+ </div>
186
196
  <!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
187
197
  contract. workspaces.js fills it on step-switch (renderExports). -->
188
198
  <div class="ws-exports" id="ws-exports" hidden></div>
@@ -828,6 +838,7 @@
828
838
  nothing project-specific (spec §2). Populated per-open in workspaces.js. -->
829
839
  <div class="menu ws-proj-menu" id="ws-proj-menu" role="menu" hidden></div>
830
840
 
841
+ <script src="analytics.js"></script>
831
842
  <script src="app.js"></script>
832
843
  <script src="renderers.js"></script>
833
844
  <script src="aware.js"></script>
@@ -1493,6 +1493,16 @@ function onKey(e) {
1493
1493
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
1494
1494
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
1495
1495
  if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
1496
+ if (pending && e.key === 'Escape' && (pending.epDrag || pending.origMm)) { // Esc mid-drag → cancel a member move / endpoint stretch and revert to the pre-drag state (no commit)
1497
+ e.preventDefault();
1498
+ if (pending.mesh && pending.meshPos0) pending.mesh.position.copy(pending.meshPos0); // put a live-moved (or Alt-vertical) member mesh back
1499
+ const wasEp = pending.epDrag;
1500
+ pending = dragging = null; dragEp = null;
1501
+ if (controls) controls.enabled = true;
1502
+ if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (epPreview) epPreview.visible = false; if (epMovedLine) epMovedLine.visible = false; clearCopyGhost();
1503
+ if (wasEp) rebuildEndpoints(); // restore the dragged endpoint dot to its original spot
1504
+ return;
1505
+ }
1496
1506
  if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
1497
1507
  if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1498
1508
  if (basePickCol && e.key === 'Escape') { e.preventDefault(); setBasePickMode(null); if (api && api.toast) api.toast('Trim cancelled'); return; } // Esc disarms the Mode B column-base pick
@@ -3112,6 +3112,11 @@ addEventListener('keydown',e=>{
3112
3112
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
3113
3113
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
3114
3114
  if(e.key==='Escape'&&snapMenuOpen()){closeSnapMenu();return;}
3115
+ if(e.key==='Escape'&&drag){e.preventDefault();const pre=drag.pre,wasGrid=drag.type==='gridline'; // Esc mid-drag → cancel the command and revert to the pre-drag state (no undo entry committed)
3116
+ if(drag.type==='draw'&&drag.line)drag.line.remove();else if(drag.type==='marquee'&&drag.rect)drag.rect.remove();
3117
+ drag=null;snapClear();epPrevClear();gridReadoutHide();if(wasGrid||!anyToolActive())snapOnlyClear2d(); // a gridline drag's single-shot snap override is always cleared (it can run with the grid panel open, so don't gate on anyToolActive) — matches the normal release
3118
+ if(pre)apply(pre);else render(); // apply() restores the snapshot + re-renders (2D+3D); draw/marquee have no snapshot, just refresh
3119
+ return;}
3115
3120
  if(e.key==='Escape'&&!view3d&&snapOnly){snapOnlyClear2d();return;} // own Esc rung: 1st Esc drops the snap override, the next cancels the tool/draft (mirrors the set-axes two-step)
3116
3121
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
3117
3122
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
@@ -68,6 +68,9 @@
68
68
  #toast.show{display:flex}
69
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
70
  #toast .ghost:hover{color:var(--text);border-color:var(--brand)}
71
+ /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
72
+ #tooltip{position:fixed;z-index:80;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:11.5px;line-height:1.35;color:var(--text);pointer-events:none;max-width:260px;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;transition:opacity .12s}
73
+ #tooltip.show{opacity:1}
71
74
  </style>
72
75
  </head>
73
76
  <body>
@@ -76,7 +79,7 @@
76
79
  <span class="stat" id="title">—</span>
77
80
  <span class="spacer"></span>
78
81
  <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>
82
+ <span id="saveStat" data-tip="Edits save to the app's contract automatically" hidden>Auto-save on</span>
80
83
  <button class="primary" id="exportBtn" disabled>Export SVG</button>
81
84
  </header>
82
85
  <div id="wrap">
@@ -84,7 +87,7 @@
84
87
  <svg id="svg" xmlns="http://www.w3.org/2000/svg"></svg>
85
88
  <div id="state" class="show"><div class="spin"></div><div id="stateMsg">Loading drawing…</div></div>
86
89
  <div id="zoombar">
87
- <button class="ghost" id="fitBtn" title="Fit to view">Fit</button>
90
+ <button class="ghost" id="fitBtn" data-tip="Fit to view">Fit</button>
88
91
  <input type="range" id="zoom" min="10" max="800" value="100" aria-label="Zoom">
89
92
  <span id="zpct">100%</span>
90
93
  </div>
@@ -120,7 +123,12 @@
120
123
  const svg = $('svg');
121
124
  const params = new URLSearchParams(location.search);
122
125
  const app = params.get('app');
123
- const src = params.get('src') || (app ? ('/api/contract/' + encodeURIComponent(app)) : '/vector-example.json');
126
+ // Workspaces: a project-keyed contract lives at projects/<id>/contract.json. Empty PROJECT =
127
+ // legacy app-keyed behaviour (byte-for-byte today's URLs). PROJECT_QS is appended to every
128
+ // /api/contract call so two vectorize projects of the same app never share a draft (spec §4.3).
129
+ const PROJECT = params.get('project') || '';
130
+ const PROJECT_QS = PROJECT ? ('?project=' + encodeURIComponent(PROJECT)) : '';
131
+ const src = params.get('src') || (app ? ('/api/contract/' + encodeURIComponent(app) + PROJECT_QS) : '/vector-example.json');
124
132
  const WEAK = 0.5;
125
133
 
126
134
  let sheet = null;
@@ -307,7 +315,7 @@
307
315
  let saveQueue = Promise.resolve(true);
308
316
  async function doPut() {
309
317
  try {
310
- const res = await fetch('/api/contract/' + encodeURIComponent(app), {
318
+ const res = await fetch('/api/contract/' + encodeURIComponent(app) + PROJECT_QS, {
311
319
  method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
312
320
  });
313
321
  setSaved(res.ok ? 'ok' : 'err');
@@ -382,14 +390,14 @@
382
390
  for (const el of weak) {
383
391
  const row = document.createElement('div'); row.className = 'wrow'; row.dataset.id = el.id || '';
384
392
  const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = elLabel(el);
385
- lbl.title = 'Show this trace on the drawing';
393
+ lbl.dataset.tip = 'Show this trace on the drawing';
386
394
  lbl.addEventListener('click', () => locateOnCanvas(el.id));
387
395
  const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = Math.round((el.confidence || 0) * 100) + '%';
388
396
  const ok = document.createElement('button'); ok.className = 'ghost'; ok.textContent = 'Accept';
389
- ok.title = 'Mark this trace as correct — clears its low-confidence flag.';
397
+ ok.dataset.tip = 'Mark this trace as correct — clears its low-confidence flag.';
390
398
  ok.addEventListener('click', () => acceptEl(el.id));
391
399
  const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Delete';
392
- del.title = 'Remove this trace from the drawing (Undo available).';
400
+ del.dataset.tip = 'Remove this trace from the drawing (Undo available).';
393
401
  del.addEventListener('click', () => deleteEl(el.id));
394
402
  row.append(lbl, pct, ok, del); box.appendChild(row);
395
403
  }
@@ -461,6 +469,20 @@
461
469
 
462
470
  window.addEventListener('resize', () => { if (sheet) syncZoom(); });
463
471
  })();
472
+
473
+ // Themed tooltips (replaces native title=) — one shared element, shown on data-tip hover after a
474
+ // short delay, positioned below the control and clamped to the viewport (same handler as the steel
475
+ // editor's 3D toolbar). Reads data-tip via getAttribute, so JS-set el.dataset.tip works too.
476
+ (function(){ const tip=document.createElement('div'); tip.id='tooltip'; document.body.appendChild(tip); let t=null;
477
+ function show(el){ const txt=el.getAttribute('data-tip'); if(!txt) return; tip.textContent=txt; tip.classList.add('show');
478
+ const r=el.getBoundingClientRect(), tw=tip.offsetWidth, th=tip.offsetHeight;
479
+ let x=Math.max(6,Math.min(r.left+r.width/2-tw/2, innerWidth-tw-6)), y=r.bottom+6; if(y+th>innerHeight-6) y=r.top-th-6;
480
+ tip.style.left=x+'px'; tip.style.top=y+'px'; }
481
+ function hide(){ clearTimeout(t); tip.classList.remove('show'); }
482
+ document.addEventListener('pointerover', e=>{ const el=e.target.closest('[data-tip]'); if(!el) return; clearTimeout(t); t=setTimeout(()=>show(el),400); });
483
+ document.addEventListener('pointerout', e=>{ if(e.target.closest('[data-tip]')) hide(); });
484
+ document.addEventListener('pointerdown', hide, true); // a click hides the tip immediately
485
+ })();
464
486
  </script>
465
487
  </body>
466
488
  </html>