@floless/app 0.34.2 → 0.35.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.
@@ -33,13 +33,10 @@
33
33
  </span>
34
34
  <span class="name">FloLess</span>
35
35
  </div>
36
- <!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the
37
- user's custom panels from ~/.floless/ui/extensions.json). The dot on
38
- Dashboard lights when panels changed while the user was on Canvas. -->
39
- <div class="view-toggle" id="view-toggle" role="group" aria-label="Workspace view">
40
- <button type="button" class="view-btn active" data-view="canvas" aria-pressed="true">Canvas</button>
41
- <button type="button" class="view-btn" data-view="dashboard" aria-pressed="false">Dashboard<span class="view-dot" id="dash-dot" hidden aria-label="Dashboard updated">●</span></button>
42
- </div>
36
+ <!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the user's
37
+ custom panels from ~/.floless/ui/extensions.json) moved into the ≡ menu in #149.
38
+ The "dashboard updated" signal now rides the hamburger badge (.has-dash-update)
39
+ and the menu item's dot (#dash-dot, relocated into the menu). -->
43
40
  <!-- Shown only while custom panels exist; opens Undo · History · Reset. -->
44
41
  <button type="button" id="ext-badge" class="ext-badge" hidden aria-haspopup="menu" aria-expanded="false" aria-controls="ext-menu" data-tip="Your dashboard is customized — undo, history, reset to default">Customized</button>
45
42
  </div>
@@ -57,8 +54,8 @@
57
54
  </div>
58
55
  </div>
59
56
  <select id="prompt-select" hidden></select>
60
- <button id="browse-btn" data-tip="Browse all installed agents">⊞ Agents</button>
61
- <button id="routines-btn" data-tip="Routines run a workflow on a schedule or a live trigger">⏱ Routines</button>
57
+ <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
58
+ (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
62
59
  <span class="ctl-sep" aria-hidden="true"></span>
63
60
  <span class="run-state" id="run-state" role="status" aria-live="polite"></span>
64
61
  <button id="compile-btn" data-tip="Compile + approve → freeze the .lock">⎙ Compile</button>
@@ -265,7 +262,22 @@
265
262
  <span class="menu-label">Find on canvas</span>
266
263
  <span class="menu-kbd">Ctrl+F</span>
267
264
  </button>
265
+ <!-- Workspace view switch (was the header Canvas|Dashboard toggle, #149). One
266
+ contextual item: the label names the OTHER view; the dot lights when the
267
+ dashboard changed while on Canvas. Wired in panels.js (owns the view state). -->
268
+ <button class="menu-item" data-action="view-switch" role="menuitem">
269
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg></span>
270
+ <span class="menu-label" id="menu-view-label">Switch to Dashboard</span>
271
+ <span class="view-dot" id="dash-dot" hidden aria-label="Dashboard updated">●</span>
272
+ </button>
268
273
  <div class="menu-divider"></div>
274
+ <!-- Agents library (was the header ⊞ Agents button, #149). Heads the agent group
275
+ (Agents / Graft into agent / Bake into agent). Ctrl+G. -->
276
+ <button class="menu-item" data-action="agents" role="menuitem">
277
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span>
278
+ <span class="menu-label">Agents</span>
279
+ <span class="menu-kbd">Ctrl+G</span>
280
+ </button>
269
281
  <button class="menu-item" data-action="graft" role="menuitem">
270
282
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg></span>
271
283
  <span class="menu-label">Graft into agent</span>
@@ -283,6 +295,7 @@
283
295
  <button class="menu-item" data-action="routines" role="menuitem">
284
296
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg></span>
285
297
  <span class="menu-label">Routines</span>
298
+ <span class="menu-kbd">Ctrl+R</span>
286
299
  </button>
287
300
  <div class="menu-divider"></div>
288
301
  <button class="menu-item" data-action="report-issue" role="menuitem">
@@ -0,0 +1,5 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "//": "Marks web/*.js as ES modules so node (the steel-3d-core unit tests) and the browser agree. Not an npm package — just a module-type marker. The browser ignores it; the bundle copies it harmlessly."
5
+ }
@@ -28,8 +28,9 @@
28
28
 
29
29
  const $canvasMain = document.getElementById('canvas-main');
30
30
  const $dashboard = document.getElementById('dashboard');
31
- const $viewToggle = document.getElementById('view-toggle');
32
- const $dashDot = document.getElementById('dash-dot');
31
+ const $dashDot = document.getElementById('dash-dot'); // the "updated" dot, now in the ≡ menu item
32
+ const $menuBtn = document.getElementById('menu-btn'); // hosts the dashboard-updated badge (#149)
33
+ const $menuViewLabel = document.getElementById('menu-view-label'); // the ≡ "Switch to Dashboard/Canvas" label
33
34
  const $extBadge = document.getElementById('ext-badge');
34
35
  const $extMenu = document.getElementById('ext-menu');
35
36
  const $extUndo = document.getElementById('ext-undo');
@@ -38,7 +39,7 @@
38
39
  const $centerName = document.getElementById('center-panel-name');
39
40
  const $centerRole = document.getElementById('center-panel-role');
40
41
  const $resetModal = document.getElementById('ext-reset-modal');
41
- if (!$dashboard || !$viewToggle) return; // markup absent — nothing to wire
42
+ if (!$dashboard) return; // markup absent — nothing to wire
42
43
 
43
44
  const LS_VIEW = 'floless:view';
44
45
  let view = 'canvas';
@@ -65,18 +66,23 @@
65
66
  const descriptorInvalid = () => !!(ext.validation && ext.validation.valid === false);
66
67
 
67
68
  // ── view toggle (Canvas | Dashboard) ─────────────────────────────────────────
69
+ // The "dashboard updated" signal: a dot on the ≡ menu's view-switch item AND a badge on
70
+ // the ≡ hamburger, so it's visible without opening the menu (#149 — the old toggle that
71
+ // hosted the dot is gone).
72
+ function setDashUpdated(on) {
73
+ if ($dashDot) $dashDot.hidden = !on;
74
+ if ($menuBtn) $menuBtn.classList.toggle('has-dash-update', on);
75
+ }
68
76
  function applyView() {
69
77
  const dash = view === 'dashboard';
70
78
  if ($canvasMain) $canvasMain.classList.toggle('view-dashboard', dash);
71
79
  $dashboard.hidden = !dash;
72
- $viewToggle.querySelectorAll('.view-btn').forEach((b) => {
73
- const on = b.dataset.view === view;
74
- b.classList.toggle('active', on);
75
- b.setAttribute('aria-pressed', on ? 'true' : 'false');
76
- });
80
+ // The Canvas|Dashboard toggle now lives in the ≡ menu as one contextual item (#149):
81
+ // its label names the OTHER view the destination of the switch.
82
+ if ($menuViewLabel) $menuViewLabel.textContent = dash ? 'Switch to Canvas' : 'Switch to Dashboard';
77
83
  if ($centerName) $centerName.textContent = dash ? 'Dashboard' : 'Canvas';
78
84
  if ($centerRole) $centerRole.textContent = dash ? 'your panels · composed by your terminal AI' : 'transparency layer · read-mostly';
79
- if (dash && $dashDot) $dashDot.hidden = true; // seen — clear the "updated" dot
85
+ if (dash) setDashUpdated(false); // seen — clear the "updated" signal
80
86
  }
81
87
  function setView(v) {
82
88
  view = v === 'dashboard' ? 'dashboard' : 'canvas';
@@ -84,7 +90,15 @@
84
90
  applyView();
85
91
  if (view === 'dashboard') renderDashboard();
86
92
  }
87
- $viewToggle.querySelectorAll('.view-btn').forEach((b) => { b.onclick = () => setView(b.dataset.view); });
93
+ function toggleView() { setView(view === 'dashboard' ? 'canvas' : 'dashboard'); }
94
+ // The ≡ menu's "Switch to Dashboard/Canvas" item drives the view (the header toggle was
95
+ // removed in #149). handleMenuAction lives in app.js and is already wrapped in aware.js
96
+ // for graft/bake; wrap once more here for view-switch and fall through for everything else.
97
+ const _prevHandleMenuAction = handleMenuAction;
98
+ handleMenuAction = function (action) {
99
+ if (action === 'view-switch') { toggleView(); return; }
100
+ _prevHandleMenuAction(action);
101
+ };
88
102
 
89
103
  // ── block renderers (all output escaped; placeholders over errors) ──────────
90
104
  const payloadFor = (source) => (typeof source === 'string' ? ext.data[source] : undefined);
@@ -685,7 +699,7 @@
685
699
  renderDashboard();
686
700
  // Content changed while the user is on Canvas → light the Dashboard dot
687
701
  // (never auto-switch — don't yank their context).
688
- if (opts.fromChange && view === 'canvas' && allPanels().length && $dashDot) $dashDot.hidden = false;
702
+ if (opts.fromChange && view === 'canvas' && allPanels().length) setDashUpdated(true);
689
703
  refreshPending();
690
704
  }
691
705
 
@@ -0,0 +1,137 @@
1
+ /*
2
+ * steel-3d-core.js — PURE, framework-free core for the steel 3D editor (no Three.js, no DOM, no
3
+ * window). Unit-tested in node (server/steel-3d-core.test.ts) AND loaded by web/steel-3d-view.js.
4
+ * This is the extraction-ready unit: the generic snap engine + the typed-candidate model are what
5
+ * later lift into AWARE viewer-3d; only `snapCandidates` (beam top-flange centerline, column
6
+ * plan-center) is steel-specific.
7
+ *
8
+ * Coordinate map MUST match server/contract-to-scene.ts (the bake) so editing-space == render-space:
9
+ * scene is mm, Z-up. mmX = dispX·k, mmY = -dispY·k (k = FT_TO_MM / pt_per_ft, Y flipped),
10
+ * mmZ = elevation_inches · IN_TO_MM. planPointToWp is the inverse (editing-only; the bake never
11
+ * needs it).
12
+ */
13
+
14
+ export const FT_TO_MM = 304.8;
15
+ export const IN_TO_MM = 25.4;
16
+
17
+ const kOf = (ptPerFt) => FT_TO_MM / (ptPerFt > 0 ? ptPerFt : 1);
18
+
19
+ const nz = (n) => n + 0; // normalize -0 → 0 (negating 0 yields -0, which surprises equality checks)
20
+
21
+ /** display-space wp point → scene mm [x,y] (Y flipped, matching contractToScene.dispToMm). */
22
+ export function dispToMm(pt, ptPerFt) { const k = kOf(ptPerFt); return [nz(pt[0] * k), nz(-pt[1] * k)]; }
23
+
24
+ /** scene mm point [x,y,…] → display-space wp [px,py] (inverse of dispToMm). */
25
+ export function planPointToWp(p, ptPerFt) { const k = kOf(ptPerFt); return [nz(p[0] / k), nz(-p[1] / k)]; }
26
+
27
+ /**
28
+ * member → its 3D centerline `{ kind, line:[[x,y,z],[x,y,z]] }` in mm, matching contractToScene's
29
+ * from/to. Beam: work-line at T.O.S. Column: vertical at the plan point, bos→tos.
30
+ */
31
+ export function memberGeometry(m, ptPerFt, defaultTosMm = 0) {
32
+ const a = dispToMm(m.wp[0], ptPerFt), b = dispToMm(m.wp[1], ptPerFt);
33
+ if (m.role === 'column') {
34
+ const bos = m.col && m.col.bos != null ? m.col.bos * IN_TO_MM : 0;
35
+ const tos = m.col && m.col.tos != null ? m.col.tos * IN_TO_MM : defaultTosMm;
36
+ return { kind: 'column', line: [[a[0], a[1], bos], [a[0], a[1], tos]] };
37
+ }
38
+ const e = m.ends || [];
39
+ const z0 = e[0] && e[0].tos != null ? e[0].tos * IN_TO_MM : defaultTosMm;
40
+ const z1 = e[1] && e[1].tos != null ? e[1].tos * IN_TO_MM : defaultTosMm;
41
+ return { kind: 'beam', line: [[a[0], a[1], z0], [b[0], b[1], z1]] };
42
+ }
43
+
44
+ /** XY intersection point of segments a-b and c-d (within both), or null (parallel / outside). */
45
+ function segXY(a, b, c, d) {
46
+ const rx = b[0] - a[0], ry = b[1] - a[1], sx = d[0] - c[0], sy = d[1] - c[1];
47
+ const rxs = rx * sy - ry * sx;
48
+ if (Math.abs(rxs) < 1e-9) return null; // parallel/collinear
49
+ const qpx = c[0] - a[0], qpy = c[1] - a[1];
50
+ const t = (qpx * sy - qpy * sx) / rxs, u = (qpx * ry - qpy * rx) / rxs;
51
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null; // crossing lies outside a segment
52
+ return [a[0] + rx * t, a[1] + ry * t];
53
+ }
54
+
55
+ /**
56
+ * Snap targets from the other members (the dragged one excluded). Beam → its top-flange centerline
57
+ * (the work-line at T.O.S) + endpoint vertices + a midpoint. Column → a vertical axis at its plan
58
+ * point + endpoint vertices. Plus member-member centerline INTERSECTIONS (Tekla-standard). Each
59
+ * tagged `fromId`. Generic `grid-line` targets (from plan grids) plug in here too.
60
+ */
61
+ export function snapCandidates(members, ptPerFt, defaultTosMm, excludeId) {
62
+ const out = [];
63
+ const lines = []; // beam centerlines, for pairwise intersections
64
+ for (const m of members || []) {
65
+ if (!m || m.id === excludeId || !Array.isArray(m.wp) || m.wp.length < 2) continue;
66
+ const { kind, line } = memberGeometry(m, ptPerFt, defaultTosMm);
67
+ const [p0, p1] = line;
68
+ if (kind === 'column') { out.push({ type: 'vertical-axis', p: p0, fromId: m.id }); }
69
+ else {
70
+ out.push({ type: 'centerline', a: p0, b: p1, fromId: m.id });
71
+ out.push({ type: 'midpoint', p: [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2], fromId: m.id });
72
+ lines.push({ id: m.id, p0, p1 });
73
+ }
74
+ out.push({ type: 'vertex', p: p0, fromId: m.id });
75
+ out.push({ type: 'vertex', p: p1, fromId: m.id });
76
+ }
77
+ for (let i = 0; i < lines.length; i++) {
78
+ for (let j = i + 1; j < lines.length; j++) {
79
+ const x = segXY(lines[i].p0, lines[i].p1, lines[j].p0, lines[j].p1);
80
+ if (x) out.push({ type: 'intersection', p: [x[0], x[1], (lines[i].p0[2] + lines[j].p0[2]) / 2], fromIds: [lines[i].id, lines[j].id] });
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Distinct elevation levels (mm, sorted) among the OTHER members' endpoints — the snap targets for a
88
+ * vertical (Alt) drag, so a member can be raised/lowered onto another's T.O.S level. The caller turns
89
+ * each level into a point candidate at the dragged member's plan X/Y and reuses snapPoint().
90
+ */
91
+ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
92
+ const set = new Set();
93
+ for (const m of members || []) {
94
+ if (!m || m.id === excludeId || !Array.isArray(m.wp) || m.wp.length < 2) continue;
95
+ const { line } = memberGeometry(m, ptPerFt, defaultTosMm);
96
+ set.add(Math.round(line[0][2])); set.add(Math.round(line[1][2]));
97
+ }
98
+ return [...set].sort((a, b) => a - b);
99
+ }
100
+
101
+ // Lower wins when two candidates are both within tolerance.
102
+ const PRECEDENCE = { vertex: 0, intersection: 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
103
+
104
+ function closestOnSeg(p, a, b) {
105
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
106
+ const len2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2] || 1;
107
+ let t = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1] + (p[2] - a[2]) * ab[2]) / len2;
108
+ t = Math.max(0, Math.min(1, t));
109
+ return [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t];
110
+ }
111
+
112
+ /** The 3D point a candidate proposes for the dragged point. */
113
+ function candidatePoint(c, dragged) {
114
+ if (c.type === 'vertex' || c.type === 'intersection' || c.type === 'midpoint' || c.type === 'level') return c.p; // fixed points
115
+ if (c.type === 'vertical-axis') return [c.p[0], c.p[1], dragged[2]]; // lock plan X/Y to the axis, keep dragged Z
116
+ return closestOnSeg(dragged, c.a, c.b); // centerline / grid-line
117
+ }
118
+
119
+ /**
120
+ * Snap `dragged` (3D mm) to the nearest candidate within `tolPx` SCREEN pixels. `toScreen` maps a 3D
121
+ * mm point → `{x,y}` screen px (injected — keeps this DOM-free). Ties within tolerance break by
122
+ * PRECEDENCE (endpoint > intersection > centerline > axis > grid). Returns `{ snapped, candidate }`;
123
+ * candidate is null (and snapped === dragged) when nothing is in range.
124
+ */
125
+ export function snapPoint(dragged, candidates, toScreen, tolPx) {
126
+ const ds = toScreen(dragged);
127
+ let best = null, bestPt = null, bestPrec = Infinity, bestD = Infinity;
128
+ for (const c of candidates || []) {
129
+ const pt = candidatePoint(c, dragged);
130
+ const s = toScreen(pt);
131
+ const d = Math.hypot(s.x - ds.x, s.y - ds.y);
132
+ if (d > tolPx) continue;
133
+ const prec = PRECEDENCE[c.type] ?? 9;
134
+ if (prec < bestPrec || (prec === bestPrec && d < bestD)) { best = c; bestPt = pt; bestPrec = prec; bestD = d; }
135
+ }
136
+ return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
137
+ }