@pauloricca/spagbol 0.1.0 → 0.1.3

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.
Files changed (66) hide show
  1. package/README.md +42 -4
  2. package/dist/core/controller.js +4 -4
  3. package/dist/core/controller.js.map +1 -1
  4. package/dist/core/model.d.ts +2 -1
  5. package/dist/core/model.d.ts.map +1 -1
  6. package/dist/core/model.js.map +1 -1
  7. package/dist/editors.css +157 -0
  8. package/dist/react/SpagbolCanvas.d.ts.map +1 -1
  9. package/dist/react/SpagbolCanvas.js +106 -29
  10. package/dist/react/SpagbolCanvas.js.map +1 -1
  11. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -1
  12. package/dist/react/SpagbolCanvasControls.js +5 -5
  13. package/dist/react/SpagbolCanvasControls.js.map +1 -1
  14. package/dist/react/SpagbolControlJoystick.d.ts +9 -0
  15. package/dist/react/SpagbolControlJoystick.d.ts.map +1 -0
  16. package/dist/react/SpagbolControlJoystick.js +111 -0
  17. package/dist/react/SpagbolControlJoystick.js.map +1 -0
  18. package/dist/react/SpagbolControlZoom.d.ts.map +1 -1
  19. package/dist/react/SpagbolControlZoom.js +3 -2
  20. package/dist/react/SpagbolControlZoom.js.map +1 -1
  21. package/dist/react/SpagbolCurveEditor.d.ts +45 -0
  22. package/dist/react/SpagbolCurveEditor.d.ts.map +1 -0
  23. package/dist/react/SpagbolCurveEditor.js +142 -0
  24. package/dist/react/SpagbolCurveEditor.js.map +1 -0
  25. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -1
  26. package/dist/react/SpagbolNodeFrame.js +18 -16
  27. package/dist/react/SpagbolNodeFrame.js.map +1 -1
  28. package/dist/react/SpagbolNodeFrameTypePicker.js +1 -1
  29. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -1
  30. package/dist/react/SpagbolSequencer.d.ts +35 -0
  31. package/dist/react/SpagbolSequencer.d.ts.map +1 -0
  32. package/dist/react/SpagbolSequencer.js +166 -0
  33. package/dist/react/SpagbolSequencer.js.map +1 -0
  34. package/dist/react/index.d.ts +3 -0
  35. package/dist/react/index.d.ts.map +1 -1
  36. package/dist/react/index.js +3 -0
  37. package/dist/react/index.js.map +1 -1
  38. package/dist/react/nodeDrag.d.ts +7 -0
  39. package/dist/react/nodeDrag.d.ts.map +1 -0
  40. package/dist/react/nodeDrag.js +27 -0
  41. package/dist/react/nodeDrag.js.map +1 -0
  42. package/dist/react/zoom.d.ts +5 -0
  43. package/dist/react/zoom.d.ts.map +1 -0
  44. package/dist/react/zoom.js +19 -0
  45. package/dist/react/zoom.js.map +1 -0
  46. package/dist/styles.css +112 -41
  47. package/docs/language/standard-node-comparison.md +92 -0
  48. package/docs/language/standard-node-contracts.md +237 -0
  49. package/docs/language/standard-nodes.md +198 -0
  50. package/docs/language/ui-components.md +79 -0
  51. package/package.json +5 -3
  52. package/src/core/controller.ts +4 -4
  53. package/src/core/model.ts +2 -1
  54. package/src/react/SpagbolCanvas.tsx +121 -31
  55. package/src/react/SpagbolCanvasControls.tsx +2 -7
  56. package/src/react/SpagbolControlJoystick.tsx +147 -0
  57. package/src/react/SpagbolControlZoom.tsx +3 -2
  58. package/src/react/SpagbolCurveEditor.tsx +166 -0
  59. package/src/react/SpagbolNodeFrame.tsx +30 -24
  60. package/src/react/SpagbolNodeFrameTypePicker.tsx +1 -1
  61. package/src/react/SpagbolSequencer.tsx +265 -0
  62. package/src/react/editors.css +157 -0
  63. package/src/react/index.ts +4 -0
  64. package/src/react/nodeDrag.ts +33 -0
  65. package/src/react/styles.css +112 -41
  66. package/src/react/zoom.ts +18 -0
@@ -0,0 +1,157 @@
1
+ /* Controlled editors extracted from Teia; hosts own storage and execution. */
2
+ .spagbol-curve-editor {
3
+ display: grid;
4
+ grid-column: 1 / -1;
5
+ gap: 0;
6
+ width: 100%;
7
+ min-width: 0;
8
+ }
9
+
10
+ .spagbol-curve-editor-chart {
11
+ position: relative;
12
+ height: var(--node-display-height, 128px);
13
+ overflow: hidden;
14
+ }
15
+
16
+ .spagbol-curve-editor-canvas {
17
+ position: relative;
18
+ z-index: 1;
19
+ display: block;
20
+ width: 100%;
21
+ height: 100%;
22
+ border: 0;
23
+ background: transparent;
24
+ cursor: crosshair;
25
+ touch-action: none;
26
+ user-select: none;
27
+ -webkit-user-select: none;
28
+ }
29
+
30
+ .spagbol-curve-editor-value-label {
31
+ --spagbol-curve-editor-value-offset: calc(7px * var(--graph-screen-scale, 1));
32
+ position: absolute;
33
+ z-index: 3;
34
+ padding: calc(2px * var(--graph-screen-scale, 1)) calc(4px * var(--graph-screen-scale, 1));
35
+ transform: translate(var(--spagbol-curve-editor-value-offset), calc(-100% - var(--spagbol-curve-editor-value-offset)));
36
+ border: calc(1px * var(--graph-screen-scale, 1)) solid var(--color-foreground-62, rgba(255,255,255,.62));
37
+ background: var(--color-background, var(--spagbol-bg, #000));
38
+ color: var(--color-foreground, var(--spagbol-foreground, #fff));
39
+ font-size: calc(1.1em * var(--graph-screen-scale, 1));
40
+ font-variant-numeric: tabular-nums;
41
+ line-height: 1;
42
+ white-space: nowrap;
43
+ pointer-events: none;
44
+ }
45
+
46
+ .spagbol-curve-editor-value-label.is-left {
47
+ transform: translate(calc(-100% - var(--spagbol-curve-editor-value-offset)), calc(-100% - var(--spagbol-curve-editor-value-offset)));
48
+ }
49
+
50
+ .spagbol-curve-editor-value-label.is-below {
51
+ transform: translate(var(--spagbol-curve-editor-value-offset), var(--spagbol-curve-editor-value-offset));
52
+ }
53
+
54
+ .spagbol-curve-editor-value-label.is-left.is-below {
55
+ transform: translate(calc(-100% - var(--spagbol-curve-editor-value-offset)), var(--spagbol-curve-editor-value-offset));
56
+ }
57
+
58
+ .spagbol-curve-editor-chrome {
59
+ pointer-events: none;
60
+ }
61
+
62
+ .spagbol-curve-editor-path {
63
+ fill: none;
64
+ stroke: var(--color-accent, var(--spagbol-accent, #2fd061));
65
+ stroke-linecap: square;
66
+ stroke-linejoin: round;
67
+ stroke-width: calc(2px * var(--graph-stroke-scale, 1));
68
+ vector-effect: non-scaling-stroke;
69
+ }
70
+ .spagbol-curve-editor-path.is-background { opacity: .38; }
71
+ .spagbol-curve-editor-hit-target {
72
+ fill: transparent;
73
+ cursor: grab;
74
+ pointer-events: all;
75
+ }
76
+
77
+ .spagbol-curve-editor-hit-target.is-locked {
78
+ cursor: default;
79
+ }
80
+
81
+ .spagbol-curve-editor-handle {
82
+ fill: var(--color-background, var(--spagbol-bg, #000));
83
+ stroke: var(--color-accent, var(--spagbol-accent, #2fd061));
84
+ stroke-width: calc(2px * var(--graph-stroke-scale, 1) * var(--spagbol-curve-editor-point-scale, 1));
85
+ cursor: grab;
86
+ pointer-events: none;
87
+ vector-effect: non-scaling-stroke;
88
+ }
89
+
90
+ .spagbol-curve-editor-endpoint {
91
+ stroke: var(--color-foreground, var(--spagbol-foreground, #fff));
92
+ }
93
+
94
+ .spagbol-curve-editor-handle.is-locked {
95
+ cursor: default;
96
+ }
97
+
98
+
99
+ .spagbol-sequencer { display: grid; grid-auto-rows: minmax(0, 1fr); width: 100%; min-width: 0; }
100
+ .spagbol-sequencer-row { display: grid; grid-template-columns: 32px minmax(0, 1fr); min-width: 0; }
101
+ .spagbol-sequencer-label-right .spagbol-sequencer-row { grid-template-columns: minmax(0, 1fr) var(--spagbol-sequencer-label-width, 44px); }
102
+ .spagbol-sequencer-label { display: flex; align-items: center; justify-content: flex-end; padding-right: 5px; }
103
+ .spagbol-sequencer-label-right .spagbol-sequencer-label { justify-content: flex-start; padding-right: 0; }
104
+ .spagbol-sequencer-cells { position: relative; display: grid; grid-template-columns: repeat(var(--spagbol-sequencer-steps), minmax(0, 1fr)); min-width: 0; overflow: hidden; }
105
+ .spagbol-sequencer-cell { min-width: 0; padding: 0; border: 0; background: transparent; }
106
+ .spagbol-sequencer-gate { position: absolute; z-index: 2; bottom: 0; min-width: 2px; background: var(--color-accent, var(--spagbol-accent, #2fd061)); cursor: grab; touch-action: none; }
107
+ .spagbol-sequencer-create-preview { pointer-events: none; opacity: 0.7; }
108
+ .spagbol-sequencer-gate-handle { position: absolute; top: 0; bottom: 0; width: 7px; cursor: ew-resize; }
109
+ .spagbol-sequencer-gate-handle-left { left: 0; }.spagbol-sequencer-gate-handle-right { right: 0; }
110
+ .spagbol-sequencer-level-handle { position: absolute; top: 0; left: 0; right: 0; height: 7px; cursor: ns-resize; }
111
+
112
+
113
+ .spagbol-sequencer .spagbol-sequencer-cells { height: 100%; }
114
+ .spagbol-sequencer .spagbol-sequencer-cell {
115
+ width: 100%;
116
+ height: 100%;
117
+ border-right: 1px solid var(--color-foreground-44, rgba(255,255,255,.44));
118
+ border-bottom: 1px solid var(--color-foreground-44, rgba(255,255,255,.44));
119
+ box-sizing: border-box;
120
+ cursor: pointer;
121
+ }
122
+ .spagbol-sequencer .spagbol-sequencer-row:first-child .spagbol-sequencer-cell { border-top: 1px solid var(--color-foreground-44, rgba(255,255,255,.44)); }
123
+ .spagbol-sequencer .spagbol-sequencer-cell:first-child { border-left: 1px solid var(--color-foreground-44, rgba(255,255,255,.44)); }
124
+ .spagbol-sequencer .spagbol-sequencer-cell-beat-highlight { background: var(--color-foreground-15, rgba(255,255,255,.15)); }
125
+ .spagbol-sequencer .spagbol-sequencer-cell:hover,
126
+ .spagbol-sequencer .spagbol-sequencer-cell:focus-visible { background: var(--color-foreground-20, rgba(255,255,255,.2)); outline: 0; }
127
+ .spagbol-sequencer .spagbol-sequencer-cell-current {
128
+ background: var(--color-foreground-12, rgba(255,255,255,.12));
129
+ box-shadow: inset 0 0 0 2px var(--color-foreground-28, rgba(255,255,255,.28));
130
+ }
131
+ .spagbol-sequencer .spagbol-sequencer-gate { border: 0; border-radius: 0; box-shadow: none; }
132
+
133
+
134
+ .spagbol-control-joystick {
135
+ --joystick-handle-size: 12px;
136
+ position: relative;
137
+ height: var(--node-display-height, 120px);
138
+ min-width: 0;
139
+ box-sizing: border-box;
140
+ border: calc(1px * var(--graph-stroke-scale, 1)) solid var(--color-foreground, var(--spagbol-foreground, #fff));
141
+ background: transparent;
142
+ cursor: crosshair;
143
+ overflow: hidden;
144
+ touch-action: none;
145
+ user-select: none;
146
+ }
147
+
148
+ .spagbol-control-joystick-handle {
149
+ position: absolute;
150
+ width: var(--joystick-handle-size);
151
+ height: var(--joystick-handle-size);
152
+ box-sizing: border-box;
153
+ border: calc(1px * var(--graph-stroke-scale, 1)) solid var(--color-foreground, var(--spagbol-foreground, #fff));
154
+ background: var(--color-accent, var(--spagbol-accent, #2fd061));
155
+ pointer-events: none;
156
+ }
157
+
@@ -59,3 +59,7 @@ export {
59
59
  type SpagbolCanvasMenuItem,
60
60
  type SpagbolCanvasMenuSeparator,
61
61
  } from "./SpagbolCanvasControls.js";
62
+
63
+ export { SpagbolCurveEditor, type SpagbolCurveEditorProps, type SpagbolCurveEditorPoint } from "./SpagbolCurveEditor.js";
64
+ export { SpagbolSequencer, type SpagbolSequencerProps, type SpagbolSequencerItem } from "./SpagbolSequencer.js";
65
+ export { SpagbolControlJoystick, type SpagbolControlJoystickProps } from "./SpagbolControlJoystick.js";
@@ -0,0 +1,33 @@
1
+ import type { Node, NodeChange } from "@xyflow/react";
2
+ import type { GraphDocument, Point } from "../core/index.js";
3
+
4
+ /** Accumulate only changed native node positions; keep model records immutable. */
5
+ export function previewNodePositions<N extends Node>(
6
+ previous: ReadonlyMap<string, Point>,
7
+ changes: readonly NodeChange<N>[],
8
+ ): ReadonlyMap<string, Point> {
9
+ let next: Map<string, Point> | undefined;
10
+ for (const change of changes) {
11
+ if (change.type !== "position" || !change.position || !change.id.startsWith("node:"))
12
+ continue;
13
+ const { x, y } = change.position;
14
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
15
+ const id = change.id.slice(5);
16
+ const before = (next ?? previous).get(id);
17
+ if (before?.x === x && before.y === y) continue;
18
+ next ??= new Map(previous);
19
+ next.set(id, { x, y });
20
+ }
21
+ return next ?? previous;
22
+ }
23
+
24
+ /** Apply a completed multi-node drag as one atomic controller edit. */
25
+ export function applyNodePositions(
26
+ graph: GraphDocument,
27
+ positions: ReadonlyMap<string, Point>,
28
+ ): void {
29
+ for (const node of graph.nodes) {
30
+ const position = positions.get(node.id);
31
+ if (position) node.position = { ...position };
32
+ }
33
+ }
@@ -1,3 +1,4 @@
1
+ @import "./editors.css";
1
2
  @import "./link-controls.css";
2
3
  @import "./controls.css";
3
4
  @import "@xyflow/react/dist/style.css";
@@ -19,6 +20,7 @@
19
20
  --spagbol-control: rgba(0, 0, 0, 0.62);
20
21
  --spagbol-link-underlay: #0b0b0b;
21
22
  --spagbol-control-size: 28px;
23
+ --spagbol-pin-size: 8px;
22
24
  --spagbol-font:
23
25
  ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
24
26
  monospace;
@@ -81,6 +83,17 @@
81
83
  .spagbol-canvas .react-flow__edgeupdater { fill: transparent; stroke: transparent; stroke-width: 0; cursor: grab; pointer-events: all; }
82
84
  .spagbol-canvas .react-flow__edgeupdater-source { transform: translateX(-12px); }
83
85
  .spagbol-canvas .react-flow__edgeupdater-target { transform: translateX(12px); }
86
+ /* Match Teia's square pins; selected endpoints only change colour. */
87
+ .spagbol-canvas .react-flow__handle.spagbol-node-port,
88
+ .spagbol-canvas .react-flow__handle.spagbol-property-port {
89
+ width: var(--spagbol-pin-size);
90
+ height: var(--spagbol-pin-size);
91
+ min-width: var(--spagbol-pin-size);
92
+ min-height: var(--spagbol-pin-size);
93
+ border: 1px solid var(--spagbol-foreground);
94
+ border-radius: 0;
95
+ background: var(--spagbol-control);
96
+ }
84
97
  .spagbol-canvas .spagbol-node-port.spagbol-node-port-selected-link { background: var(--spagbol-accent); border-color: var(--spagbol-accent); box-shadow: 0 0 0 2px var(--spagbol-link-underlay); pointer-events: none; }
85
98
  .spagbol-canvas-link-overlay { position: absolute; inset: 0; z-index: 25; pointer-events: none; }
86
99
 
@@ -124,7 +137,8 @@
124
137
  opacity: 0.48;
125
138
  border-style: dashed;
126
139
  }
127
- .spagbol-node-frame-header {
140
+ .spagbol-node-frame-header,
141
+ .spagbol-canvas-picker-heading {
128
142
  min-height: 31px;
129
143
  padding: 4px 8px;
130
144
  border-bottom: 1px solid var(--spagbol-border);
@@ -136,7 +150,8 @@
136
150
  cursor: grab;
137
151
  user-select: none;
138
152
  }
139
- .spagbol-node-frame-title {
153
+ .spagbol-node-frame-title,
154
+ .spagbol-canvas-picker-title {
140
155
  flex: 1;
141
156
  min-width: 0;
142
157
  overflow: visible;
@@ -145,7 +160,8 @@
145
160
  font-size: calc(13px * var(--spagbol-title-scale, 1));
146
161
  font-weight: 700;
147
162
  }
148
- .spagbol-canvas .spagbol-node-frame-header-button {
163
+ .spagbol-canvas .spagbol-node-frame-header-button,
164
+ .spagbol-canvas .spagbol-canvas-picker-heading button {
149
165
  border: 0;
150
166
  background: transparent;
151
167
  color: inherit;
@@ -220,11 +236,11 @@
220
236
  height: 1px;
221
237
  pointer-events: none;
222
238
  }
223
- .spagbol-canvas .spagbol-property-port {
239
+ .spagbol-canvas .react-flow__handle.spagbol-property-port {
224
240
  top: 0;
225
241
  left: 0;
226
- width: 9px;
227
- height: 9px;
242
+ width: var(--spagbol-pin-size);
243
+ height: var(--spagbol-pin-size);
228
244
  border: 1px solid var(--spagbol-accent);
229
245
  background: var(--spagbol-bg);
230
246
  pointer-events: auto;
@@ -232,11 +248,17 @@
232
248
  .spagbol-canvas-area {
233
249
  position: relative;
234
250
  border: 1px solid var(--spagbol-border);
235
- background: #ffffff03;
251
+ background:
252
+ linear-gradient(color-mix(in srgb, var(--spagbol-foreground) 8%, transparent), color-mix(in srgb, var(--spagbol-foreground) 8%, transparent)),
253
+ color-mix(in srgb, var(--spagbol-bg) 42%, transparent);
236
254
  pointer-events: none;
237
255
  }
238
256
  .spagbol-canvas-area-selected {
239
- border-color: var(--spagbol-accent);
257
+ outline: 1px dashed var(--spagbol-foreground);
258
+ outline-offset: 3px;
259
+ }
260
+ .spagbol-canvas-area-collapsed {
261
+ overflow: hidden;
240
262
  }
241
263
  .spagbol-canvas-area-header {
242
264
  position: absolute;
@@ -244,29 +266,84 @@
244
266
  left: 0;
245
267
  right: 0;
246
268
  height: 32px;
247
- padding: 5px 10px;
248
- background: var(--spagbol-surface-raised);
249
- color: var(--spagbol-muted);
250
269
  display: flex;
251
- justify-content: space-between;
252
- cursor: move;
270
+ align-items: center;
271
+ padding: 0 8px;
272
+ border-bottom: 1px solid color-mix(in srgb, var(--spagbol-foreground) 28%, transparent);
273
+ color: var(--spagbol-foreground);
274
+ cursor: grab;
253
275
  pointer-events: auto;
254
276
  user-select: none;
255
277
  touch-action: none;
256
278
  }
257
- .spagbol-canvas-area-header > span {
279
+ .spagbol-canvas-area-header:active { cursor: grabbing; }
280
+ .spagbol-canvas-area-title,
281
+ .spagbol-canvas-area-title-input {
258
282
  flex: 1;
259
- font-size: calc(11px * var(--spagbol-title-scale, 1));
283
+ min-width: 0;
284
+ max-width: 100%;
285
+ border: 0;
286
+ padding: 0;
287
+ outline: 0;
288
+ background: transparent;
289
+ color: var(--spagbol-foreground);
290
+ font: inherit;
291
+ font-size: calc(13px * var(--spagbol-title-scale, 1));
292
+ font-weight: 700;
293
+ }
294
+ .spagbol-canvas-area-title {
295
+ overflow: hidden;
296
+ text-overflow: ellipsis;
297
+ white-space: nowrap;
298
+ cursor: text;
260
299
  }
300
+ .spagbol-canvas-area-title-input { width: auto; min-width: 4ch; }
261
301
  .spagbol-canvas-area-actions {
262
302
  display: flex;
263
- gap: 8px;
303
+ flex: 0 0 auto;
304
+ align-items: center;
305
+ gap: 6px;
306
+ margin-left: auto;
264
307
  }
265
- .spagbol-canvas-area-actions button {
266
- border: none;
308
+ .spagbol-canvas .spagbol-canvas-area-actions button {
309
+ display: inline-flex;
310
+ flex: 0 0 auto;
311
+ width: 20px;
312
+ height: 20px;
313
+ align-items: center;
314
+ justify-content: center;
315
+ border: 0;
316
+ padding: 0;
267
317
  background: transparent;
268
- padding: 0 2px;
318
+ color: currentColor;
319
+ cursor: pointer;
320
+ }
321
+ .spagbol-canvas .spagbol-canvas-area-actions button:hover,
322
+ .spagbol-canvas .spagbol-canvas-area-actions button:focus-visible {
323
+ background: var(--spagbol-bg);
324
+ color: var(--spagbol-foreground);
325
+ outline: 0;
269
326
  }
327
+ .spagbol-canvas-area-lock-icon {
328
+ position: relative;
329
+ display: block;
330
+ width: 10px;
331
+ height: 8px;
332
+ border: 1px solid currentColor;
333
+ }
334
+ .spagbol-canvas-area-lock-icon::before {
335
+ position: absolute;
336
+ bottom: 7px;
337
+ left: 1px;
338
+ width: 6px;
339
+ height: 6px;
340
+ border: 1px solid currentColor;
341
+ border-bottom: 0;
342
+ border-radius: 5px 5px 0 0;
343
+ content: '';
344
+ }
345
+ .spagbol-canvas-area-lock:not([aria-pressed="true"]) .spagbol-canvas-area-lock-icon::before { left: 6px; }
346
+ .spagbol-canvas-area-lock[aria-pressed="true"] .spagbol-canvas-area-lock-icon { background: currentColor; }
270
347
  .spagbol-canvas-area-resize {
271
348
  position: absolute;
272
349
  bottom: 0;
@@ -426,10 +503,10 @@
426
503
  gap: 8px;
427
504
  text-align: left;
428
505
  }
429
- .spagbol-canvas-menu-check {
430
- display: inline-block;
431
- width: 12px;
432
- flex-shrink: 0;
506
+ .spagbol-canvas-menu-items > button[aria-checked="true"],
507
+ .spagbol-canvas-actions > button[aria-pressed="true"] {
508
+ background: var(--spagbol-header);
509
+ color: var(--spagbol-header-text);
433
510
  }
434
511
  .spagbol-canvas-menu-separator {
435
512
  height: 1px;
@@ -472,19 +549,7 @@
472
549
  box-shadow: 0 10px 40px #0007;
473
550
  }
474
551
  .spagbol-canvas-picker-heading {
475
- padding: 9px 10px;
476
- display: flex;
477
- align-items: center;
478
- justify-content: space-between;
479
- background: var(--spagbol-header);
480
- color: var(--spagbol-header-text);
481
- }
482
- .spagbol-canvas-picker-heading button {
483
- border: 0;
484
- background: none;
485
- color: inherit;
486
- font-size: 18px;
487
- line-height: 16px;
552
+ cursor: default;
488
553
  }
489
554
  .spagbol-canvas-picker input {
490
555
  margin: 8px;
@@ -509,9 +574,12 @@
509
574
  border: 0;
510
575
  background: transparent;
511
576
  }
512
- .spagbol-canvas-picker-list button:hover {
513
- background: var(--spagbol-surface-raised);
514
- color: var(--spagbol-accent);
577
+ .spagbol-canvas-picker-list button[aria-selected="true"] {
578
+ background: var(--spagbol-header);
579
+ color: var(--spagbol-header-text);
580
+ }
581
+ .spagbol-canvas-picker-list button[aria-selected="true"] small {
582
+ color: inherit;
515
583
  }
516
584
  .spagbol-canvas-picker-list small {
517
585
  color: var(--spagbol-muted);
@@ -739,10 +807,13 @@
739
807
  }
740
808
 
741
809
 
742
- .spagbol-canvas-area-title, .spagbol-canvas-area-title-input { flex: 1; min-width: 0; }
743
- .spagbol-canvas-area-title-input { border: 0; padding: 0; color: inherit; background: transparent; font: inherit; outline: none; }
744
810
 
745
811
  .spagbol-node-frame-projected > .spagbol-node-frame-header { background: var(--spagbol-bg); color: var(--spagbol-foreground); cursor: pointer; }
746
812
  .spagbol-node-panel { border-bottom: 1px dashed var(--spagbol-foreground); background: var(--spagbol-bg); }
747
813
 
748
814
  .spagbol-node-frame-auto-compact { min-width: 0; }
815
+
816
+ .spagbol-canvas .spagbol-node-frame-enter { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 30px; width: 30px; height: 30px; padding: 0; cursor: pointer; }
817
+ .spagbol-node-frame-enter svg { display: block; flex: none; }
818
+ .spagbol-node-frame-enter + .spagbol-node-frame-enabled { margin-left: 0; }
819
+ .spagbol-node-frame-enter:hover { background: var(--spagbol-bg); color: var(--spagbol-foreground); }
@@ -0,0 +1,18 @@
1
+ /** Teia's user-facing 100% corresponds to a half-scale graph viewport. */
2
+ export const SPAGBOL_ZOOM_BASELINE = 0.5;
3
+
4
+ function normalizedZoom(zoom: number): number {
5
+ return Number.isFinite(zoom) && zoom > 0 ? zoom : SPAGBOL_ZOOM_BASELINE;
6
+ }
7
+
8
+ export function spagbolZoomPercentage(zoom: number): string {
9
+ return `${Math.round((normalizedZoom(zoom) / SPAGBOL_ZOOM_BASELINE) * 100)}%`;
10
+ }
11
+
12
+ export function spagbolHeaderTitleScale(zoom: number): number {
13
+ const userZoom = normalizedZoom(zoom) / SPAGBOL_ZOOM_BASELINE;
14
+ if (userZoom <= 0.3) return 2.2;
15
+ if (userZoom <= 0.5) return 1.75;
16
+ if (userZoom < 0.7) return 1.3;
17
+ return 1;
18
+ }