@dfosco/storyboard 0.10.0 → 0.10.2

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.
@@ -1 +1 @@
1
- :root{--tc-border-radius: var(--radius, 12px);--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .12));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .15));--tc-border-color: var(--color-border-muted, rgba(0, 0, 0, .15));--tc-bg-muted: var(--color-background-muted, #f6f8fa);--tc-dot-color: var(--color-overlay-backdrop, rgba(0, 0, 0, .08));--tc-grid-size: var(--golden-number, 36px);--tc-grid-offset: calc(var(--tc-grid-size) / -2);--tc-dot-radius: 2px;--tc-font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif}.tc-canvas{min-width:max(100vw,10000px);min-height:max(100vh,10000px);width:100%;height:100%;margin:0;font-family:var(--tc-font-stack);position:relative;background-color:var(--tc-bg-muted);color-scheme:light dark}.tc-canvas[data-color-mode=light]{color-scheme:light}.tc-canvas[data-color-mode=dark]{color-scheme:dark}.tc-canvas[data-dotted]{background-image:radial-gradient(var(--tc-dot-color) var(--tc-dot-radius),transparent var(--tc-dot-radius));background-size:var(--tc-grid-size) var(--tc-grid-size);background-position:var(--tc-grid-offset) var(--tc-grid-offset)}@media(prefers-color-scheme:dark){.tc-canvas:not([data-color-mode=light]){--tc-bg-muted: var(--color-background-muted, #161b22);--tc-dot-color: var(--color-overlay-backdrop, rgba(255, 255, 255, .1));--tc-border-color: var(--color-border-muted, rgba(255, 255, 255, .15));--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .3));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .4))}}.tc-canvas[data-color-mode=dark]{--tc-bg-muted: var(--color-background-muted, #161b22);--tc-dot-color: var(--color-overlay-backdrop, rgba(255, 255, 255, .1));--tc-border-color: var(--color-border-muted, rgba(255, 255, 255, .15));--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .3));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .4))}body .tc-draggable-inner:not(:active){touch-action:none}.tc-on-translation{transition:transform calc(var(--tc-translation-ms, .15s) + var(--tc-translation-delay-ms, 50ms)) cubic-bezier(.78,.1,.51,.78) var(--tc-translation-delay-ms, 50ms)}.tc-drag{position:absolute;width:fit-content}.tc-draggable-inner{position:relative;width:fit-content;height:min-content}.tc-draggable-inner:after{content:"";position:absolute;z-index:-1;width:100%;height:100%;top:0;border-radius:var(--tc-border-radius);box-shadow:var(--tc-shadow-float);opacity:0;transition:opacity .3s ease-in-out}.tc-on-translation .tc-draggable-inner:after,.tc-draggable-inner:active:after,.tc-draggable-inner:hover:after{opacity:0}.tc-on-translation .tc-draggable-inner,.tc-draggable-inner:active,.tc-draggable-inner:hover{outline:none}.tc-canvas[data-locked] *{cursor:default!important}
1
+ :root{--tc-border-radius: var(--radius, 12px);--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .12));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .15));--tc-border-color: var(--color-border-muted, rgba(0, 0, 0, .15));--tc-bg-muted: var(--color-background-muted, #f6f8fa);--tc-dot-color: var(--color-overlay-backdrop, rgba(0, 0, 0, .08));--tc-grid-size: var(--golden-number, 36px);--tc-grid-offset: calc(var(--tc-grid-size) / -2);--tc-dot-radius: 2px;--tc-font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif}.tc-canvas{min-width:max(100vw,10000px);min-height:max(100vh,10000px);width:100%;height:100%;margin:0;font-family:var(--tc-font-stack);position:relative;background-color:var(--tc-bg-muted);color-scheme:light dark}html[data-canvas-surface-width=viewport] .tc-canvas{min-width:100vw;max-width:100vw;overflow-x:hidden}html[data-canvas-surface-height=viewport] .tc-canvas{min-height:100vh;max-height:100vh;overflow-y:hidden}.tc-canvas[data-color-mode=light]{color-scheme:light}.tc-canvas[data-color-mode=dark]{color-scheme:dark}.tc-canvas[data-dotted]{background-image:radial-gradient(var(--tc-dot-color) var(--tc-dot-radius),transparent var(--tc-dot-radius));background-size:var(--tc-grid-size) var(--tc-grid-size);background-position:var(--tc-grid-offset) var(--tc-grid-offset)}@media(prefers-color-scheme:dark){.tc-canvas:not([data-color-mode=light]){--tc-bg-muted: var(--color-background-muted, #161b22);--tc-dot-color: var(--color-overlay-backdrop, rgba(255, 255, 255, .1));--tc-border-color: var(--color-border-muted, rgba(255, 255, 255, .15));--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .3));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .4))}}.tc-canvas[data-color-mode=dark]{--tc-bg-muted: var(--color-background-muted, #161b22);--tc-dot-color: var(--color-overlay-backdrop, rgba(255, 255, 255, .1));--tc-border-color: var(--color-border-muted, rgba(255, 255, 255, .15));--tc-shadow-rest: var(--shadow-resting, 0 1px 3px rgba(0, 0, 0, .3));--tc-shadow-float: var(--shadow-floating, 0 4px 12px rgba(0, 0, 0, .4))}body .tc-draggable-inner:not(:active){touch-action:none}.tc-on-translation{transition:transform calc(var(--tc-translation-ms, .15s) + var(--tc-translation-delay-ms, 50ms)) cubic-bezier(.78,.1,.51,.78) var(--tc-translation-delay-ms, 50ms)}.tc-drag{position:absolute;width:fit-content}.tc-draggable-inner{position:relative;width:fit-content;height:min-content}.tc-draggable-inner:after{content:"";position:absolute;z-index:-1;width:100%;height:100%;top:0;border-radius:var(--tc-border-radius);box-shadow:var(--tc-shadow-float);opacity:0;transition:opacity .3s ease-in-out}.tc-on-translation .tc-draggable-inner:after,.tc-draggable-inner:active:after,.tc-draggable-inner:hover:after{opacity:0}.tc-on-translation .tc-draggable-inner,.tc-draggable-inner:active,.tc-draggable-inner:hover{outline:none}.tc-canvas[data-locked] *{cursor:default!important}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -28,6 +28,30 @@
28
28
  color-scheme: light dark;
29
29
  }
30
30
 
31
+ /* Canvas surface sizing — consumer-controlled via `canvas.surface.*` in
32
+ * storyboard.config.json (see canvasInteractionStore.js). The store
33
+ * toggles these `<html>` data attrs; CSS clamps each axis independently
34
+ * so e.g. width can be viewport-locked while height stays "auto" with
35
+ * the 10000px pannable floor.
36
+ *
37
+ * `overflow-{x,y}: hidden` is required on the constrained axis so that
38
+ * widgets positioned past the surface bounds (absolute-positioned
39
+ * Draggables) don't contribute to the scroll container's scrollWidth /
40
+ * scrollHeight at 100% zoom. CSS transforms (the zoom scaling on
41
+ * `.canvasZoom`) ink-overflow past `overflow: hidden`, so zoom-in still
42
+ * produces real scroll on the scroller — only the in-bounds layout is
43
+ * clipped. */
44
+ html[data-canvas-surface-width="viewport"] .tc-canvas {
45
+ min-width: 100vw;
46
+ max-width: 100vw;
47
+ overflow-x: hidden;
48
+ }
49
+ html[data-canvas-surface-height="viewport"] .tc-canvas {
50
+ min-height: 100vh;
51
+ max-height: 100vh;
52
+ overflow-y: hidden;
53
+ }
54
+
31
55
  .tc-canvas[data-color-mode="light"] {
32
56
  color-scheme: light;
33
57
  }
@@ -485,13 +485,15 @@ export async function mountStoryboardCore(config = {}, options = {}) {
485
485
  }
486
486
 
487
487
  // Seed canvas-interaction store from the same canvas config block. Reads
488
- // `canvas.scroll.axis` and `canvas.zoom.gestures`. Runs after
489
- // initCanvasConfig so the unified-store path is preferred when present.
488
+ // `canvas.scroll.axis`, `canvas.zoom.{gestures,origin}`, and
489
+ // `canvas.surface.{width,height}`. Runs after initCanvasConfig so the
490
+ // unified-store path is preferred when present.
490
491
  const canvasCfg = (uc.canvas && Object.keys(uc.canvas).length > 0) ? uc.canvas : (config.canvas || {})
491
492
  initCanvasInteraction({
492
493
  scrollAxis: canvasCfg.scroll?.axis,
493
494
  zoomGestures: canvasCfg.zoom?.gestures,
494
495
  zoomOrigin: canvasCfg.zoom?.origin,
496
+ surface: canvasCfg.surface,
495
497
  })
496
498
 
497
499
  // Load and merge command palette config.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Canvas Interaction Store — runtime gates for canvas scroll + zoom behavior.
3
3
  *
4
- * Three knobs that go *beyond* presentation (which is purely visual):
4
+ * Four knobs that go *beyond* presentation (which is purely visual):
5
5
  *
6
6
  * - `scrollAxis` — restricts the native scroll axes of the canvas
7
7
  * scroll container. Useful when a canvas is rendered as a "webpage"
@@ -25,9 +25,20 @@
25
25
  * Pointer-driven zoom (wheel/pinch) is unaffected — it always
26
26
  * anchors on the cursor.
27
27
  *
28
+ * - `surface` — sizes the underlying canvas surface (`.tc-canvas`)
29
+ * per axis. `{ width, height }` each accept `"auto"` (default,
30
+ * keeps the `max(100vw, 10000px)` floor so users can pan into a
31
+ * large workspace) or `"viewport"` (sizes to exactly `100vw` /
32
+ * `100vh`, so the unscaled surface matches the visible window
33
+ * exactly and zooming >100% surfaces real scroll). The store
34
+ * toggles `data-canvas-surface-width="viewport"` and
35
+ * `data-canvas-surface-height="viewport"` on `<html>` so CSS can
36
+ * pick up the override without React having to re-render.
37
+ *
28
38
  * Defaults are seeded from `canvas.scroll.axis`, `canvas.zoom.gestures`,
29
- * and `canvas.zoom.origin` in `storyboard.config.json`. Runtime overrides
30
- * can be applied at any time via `setCanvasInteraction(partial)`.
39
+ * `canvas.zoom.origin`, and `canvas.surface.{width,height}` in
40
+ * `storyboard.config.json`. Runtime overrides can be applied at any
41
+ * time via `setCanvasInteraction(partial)`.
31
42
  *
32
43
  * This is an **advanced, undocumented** extension surface — see
33
44
  * `core/stores/presentationStore.js` for the sibling system.
@@ -37,30 +48,72 @@
37
48
 
38
49
  const VALID_AXES = new Set(['both', 'vertical', 'horizontal', 'none'])
39
50
  const VALID_ZOOM_ORIGINS = new Set(['center', 'top-left'])
51
+ const VALID_SURFACE_SIZES = new Set(['auto', 'viewport'])
40
52
 
41
53
  const _defaults = {
42
54
  scrollAxis: 'both',
43
55
  zoomGestures: true,
44
56
  zoomOrigin: 'center',
57
+ surface: { width: 'auto', height: 'auto' },
45
58
  }
46
59
 
47
- let _state = { ..._defaults }
60
+ let _state = {
61
+ ..._defaults,
62
+ surface: { ..._defaults.surface },
63
+ }
48
64
 
49
65
  /** @type {Set<Function>} */
50
66
  const _listeners = new Set()
51
67
 
52
68
  let _snapshotVersion = 0
53
69
 
70
+ function _applySurfaceAttrs() {
71
+ if (typeof document === 'undefined') return
72
+ const root = document.documentElement
73
+ if (_state.surface.width === 'viewport') {
74
+ root.dataset.canvasSurfaceWidth = 'viewport'
75
+ } else {
76
+ delete root.dataset.canvasSurfaceWidth
77
+ }
78
+ if (_state.surface.height === 'viewport') {
79
+ root.dataset.canvasSurfaceHeight = 'viewport'
80
+ } else {
81
+ delete root.dataset.canvasSurfaceHeight
82
+ }
83
+ }
84
+
85
+ function _readSurfaceFromConfig(input) {
86
+ // Accept the nested shape ({ width, height }) directly, and ignore
87
+ // anything malformed. Each axis is validated independently.
88
+ if (!input || typeof input !== 'object') return null
89
+ const next = {}
90
+ if (typeof input.width === 'string' && VALID_SURFACE_SIZES.has(input.width)) {
91
+ next.width = input.width
92
+ }
93
+ if (typeof input.height === 'string' && VALID_SURFACE_SIZES.has(input.height)) {
94
+ next.height = input.height
95
+ }
96
+ return Object.keys(next).length ? next : null
97
+ }
98
+
54
99
  /**
55
100
  * Seed the store from a partial config (typically from
56
101
  * `storyboard.config.json` → `canvas.scroll.axis`, `canvas.zoom.gestures`,
57
- * and `canvas.zoom.origin`). Replaces any previous overrides. Called once
58
- * at app startup.
102
+ * `canvas.zoom.origin`, and `canvas.surface`). Replaces any previous
103
+ * overrides. Called once at app startup.
59
104
  *
60
- * @param {{ scrollAxis?: string, zoomGestures?: boolean, zoomOrigin?: string }} [config]
105
+ * @param {{
106
+ * scrollAxis?: string,
107
+ * zoomGestures?: boolean,
108
+ * zoomOrigin?: string,
109
+ * surface?: { width?: string, height?: string },
110
+ * }} [config]
61
111
  */
62
112
  export function initCanvasInteraction(config) {
63
- _state = { ..._defaults }
113
+ _state = {
114
+ ..._defaults,
115
+ surface: { ..._defaults.surface },
116
+ }
64
117
  if (config && typeof config === 'object') {
65
118
  if (typeof config.scrollAxis === 'string' && VALID_AXES.has(config.scrollAxis)) {
66
119
  _state.scrollAxis = config.scrollAxis
@@ -71,13 +124,23 @@ export function initCanvasInteraction(config) {
71
124
  if (typeof config.zoomOrigin === 'string' && VALID_ZOOM_ORIGINS.has(config.zoomOrigin)) {
72
125
  _state.zoomOrigin = config.zoomOrigin
73
126
  }
127
+ const surfaceUpdate = _readSurfaceFromConfig(config.surface)
128
+ if (surfaceUpdate) {
129
+ _state.surface = { ..._state.surface, ...surfaceUpdate }
130
+ }
74
131
  }
132
+ _applySurfaceAttrs()
75
133
  _notify()
76
134
  }
77
135
 
78
136
  /**
79
137
  * Apply a partial runtime override. Unspecified keys keep their current value.
80
- * @param {{ scrollAxis?: string, zoomGestures?: boolean, zoomOrigin?: string }} partial
138
+ * @param {{
139
+ * scrollAxis?: string,
140
+ * zoomGestures?: boolean,
141
+ * zoomOrigin?: string,
142
+ * surface?: { width?: string, height?: string },
143
+ * }} partial
81
144
  */
82
145
  export function setCanvasInteraction(partial) {
83
146
  if (!partial || typeof partial !== 'object') return
@@ -94,11 +157,25 @@ export function setCanvasInteraction(partial) {
94
157
  _state.zoomOrigin = partial.zoomOrigin
95
158
  changed = true
96
159
  }
160
+ const surfaceUpdate = _readSurfaceFromConfig(partial.surface)
161
+ if (surfaceUpdate) {
162
+ const merged = { ..._state.surface, ...surfaceUpdate }
163
+ if (merged.width !== _state.surface.width || merged.height !== _state.surface.height) {
164
+ _state.surface = merged
165
+ _applySurfaceAttrs()
166
+ changed = true
167
+ }
168
+ }
97
169
  if (changed) _notify()
98
170
  }
99
171
 
100
172
  /**
101
- * @returns {{ scrollAxis: 'both'|'vertical'|'horizontal'|'none', zoomGestures: boolean, zoomOrigin: 'center'|'top-left' }}
173
+ * @returns {{
174
+ * scrollAxis: 'both'|'vertical'|'horizontal'|'none',
175
+ * zoomGestures: boolean,
176
+ * zoomOrigin: 'center'|'top-left',
177
+ * surface: { width: 'auto'|'viewport', height: 'auto'|'viewport' },
178
+ * }}
102
179
  */
103
180
  export function getCanvasInteraction() {
104
181
  return _state
@@ -134,7 +211,11 @@ function _notify() {
134
211
 
135
212
  /** Reset all state. Only for tests. */
136
213
  export function _resetCanvasInteraction() {
137
- _state = { ..._defaults }
214
+ _state = {
215
+ ..._defaults,
216
+ surface: { ..._defaults.surface },
217
+ }
218
+ _applySurfaceAttrs()
138
219
  _listeners.clear()
139
220
  _snapshotVersion = 0
140
221
  }
@@ -14,23 +14,58 @@ describe('canvasInteractionStore', () => {
14
14
  _resetCanvasInteraction()
15
15
  })
16
16
 
17
- it('starts with defaults: scrollAxis=both, zoomGestures=true, zoomOrigin=center', () => {
18
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
17
+ it('starts with defaults: scrollAxis=both, zoomGestures=true, zoomOrigin=center, surface=auto/auto', () => {
18
+ expect(getCanvasInteraction()).toEqual({
19
+ scrollAxis: 'both',
20
+ zoomGestures: true,
21
+ zoomOrigin: 'center',
22
+ surface: { width: 'auto', height: 'auto' },
23
+ })
19
24
  })
20
25
 
21
26
  it('initCanvasInteraction seeds from config', () => {
22
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
23
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
27
+ initCanvasInteraction({
28
+ scrollAxis: 'vertical',
29
+ zoomGestures: false,
30
+ zoomOrigin: 'top-left',
31
+ surface: { width: 'viewport', height: 'viewport' },
32
+ })
33
+ expect(getCanvasInteraction()).toEqual({
34
+ scrollAxis: 'vertical',
35
+ zoomGestures: false,
36
+ zoomOrigin: 'top-left',
37
+ surface: { width: 'viewport', height: 'viewport' },
38
+ })
24
39
  })
25
40
 
26
41
  it('initCanvasInteraction with no args / empty / invalid resets to defaults', () => {
27
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
42
+ initCanvasInteraction({
43
+ scrollAxis: 'vertical',
44
+ zoomGestures: false,
45
+ zoomOrigin: 'top-left',
46
+ surface: { width: 'viewport', height: 'viewport' },
47
+ })
28
48
  initCanvasInteraction()
29
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
30
-
31
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
49
+ expect(getCanvasInteraction()).toEqual({
50
+ scrollAxis: 'both',
51
+ zoomGestures: true,
52
+ zoomOrigin: 'center',
53
+ surface: { width: 'auto', height: 'auto' },
54
+ })
55
+
56
+ initCanvasInteraction({
57
+ scrollAxis: 'vertical',
58
+ zoomGestures: false,
59
+ zoomOrigin: 'top-left',
60
+ surface: { width: 'viewport', height: 'viewport' },
61
+ })
32
62
  initCanvasInteraction({})
33
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
63
+ expect(getCanvasInteraction()).toEqual({
64
+ scrollAxis: 'both',
65
+ zoomGestures: true,
66
+ zoomOrigin: 'center',
67
+ surface: { width: 'auto', height: 'auto' },
68
+ })
34
69
  })
35
70
 
36
71
  it('initCanvasInteraction ignores invalid scrollAxis values', () => {
@@ -77,23 +112,75 @@ describe('canvasInteractionStore', () => {
77
112
  }
78
113
  })
79
114
 
115
+ it('initCanvasInteraction accepts surface.{width,height} independently', () => {
116
+ initCanvasInteraction({ surface: { width: 'viewport' } })
117
+ expect(getCanvasInteraction().surface).toEqual({ width: 'viewport', height: 'auto' })
118
+
119
+ initCanvasInteraction({ surface: { height: 'viewport' } })
120
+ expect(getCanvasInteraction().surface).toEqual({ width: 'auto', height: 'viewport' })
121
+
122
+ initCanvasInteraction({ surface: { width: 'viewport', height: 'viewport' } })
123
+ expect(getCanvasInteraction().surface).toEqual({ width: 'viewport', height: 'viewport' })
124
+ })
125
+
126
+ it('initCanvasInteraction ignores invalid surface values', () => {
127
+ initCanvasInteraction({ surface: { width: 'fluid' } })
128
+ expect(getCanvasInteraction().surface).toEqual({ width: 'auto', height: 'auto' })
129
+
130
+ initCanvasInteraction({ surface: { width: 42 } })
131
+ expect(getCanvasInteraction().surface).toEqual({ width: 'auto', height: 'auto' })
132
+
133
+ initCanvasInteraction({ surface: null })
134
+ expect(getCanvasInteraction().surface).toEqual({ width: 'auto', height: 'auto' })
135
+
136
+ initCanvasInteraction({ surface: 'viewport' })
137
+ expect(getCanvasInteraction().surface).toEqual({ width: 'auto', height: 'auto' })
138
+ })
139
+
140
+ it('initCanvasInteraction writes data-canvas-surface-* attrs on <html>', () => {
141
+ const root = document.documentElement
142
+ initCanvasInteraction({ surface: { width: 'viewport' } })
143
+ expect(root.dataset.canvasSurfaceWidth).toBe('viewport')
144
+ expect(root.dataset.canvasSurfaceHeight).toBeUndefined()
145
+
146
+ initCanvasInteraction({ surface: { width: 'viewport', height: 'viewport' } })
147
+ expect(root.dataset.canvasSurfaceWidth).toBe('viewport')
148
+ expect(root.dataset.canvasSurfaceHeight).toBe('viewport')
149
+
150
+ initCanvasInteraction() // resets to default
151
+ expect(root.dataset.canvasSurfaceWidth).toBeUndefined()
152
+ expect(root.dataset.canvasSurfaceHeight).toBeUndefined()
153
+ })
154
+
80
155
  it('setCanvasInteraction patches partial state without resetting other keys', () => {
81
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
156
+ initCanvasInteraction({
157
+ scrollAxis: 'vertical',
158
+ zoomGestures: false,
159
+ zoomOrigin: 'top-left',
160
+ surface: { width: 'viewport' },
161
+ })
82
162
 
83
163
  setCanvasInteraction({ zoomGestures: true })
84
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: true, zoomOrigin: 'top-left' })
164
+ expect(getCanvasInteraction()).toEqual({
165
+ scrollAxis: 'vertical',
166
+ zoomGestures: true,
167
+ zoomOrigin: 'top-left',
168
+ surface: { width: 'viewport', height: 'auto' },
169
+ })
85
170
 
86
171
  setCanvasInteraction({ scrollAxis: 'horizontal' })
87
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true, zoomOrigin: 'top-left' })
172
+ expect(getCanvasInteraction().scrollAxis).toBe('horizontal')
173
+
174
+ setCanvasInteraction({ surface: { height: 'viewport' } })
175
+ expect(getCanvasInteraction().surface).toEqual({ width: 'viewport', height: 'viewport' })
88
176
 
89
177
  setCanvasInteraction({ zoomOrigin: 'center' })
90
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true, zoomOrigin: 'center' })
178
+ expect(getCanvasInteraction().zoomOrigin).toBe('center')
91
179
  })
92
180
 
93
181
  it('setCanvasInteraction with no-op patch does not notify subscribers', () => {
94
182
  const cb = vi.fn()
95
183
  subscribeToCanvasInteraction(cb)
96
- // Reset count: subscribers fire on initial reset_+ subscribe etc.
97
184
  cb.mockClear()
98
185
 
99
186
  setCanvasInteraction({}) // no keys
@@ -102,6 +189,8 @@ describe('canvasInteractionStore', () => {
102
189
  setCanvasInteraction({ zoomGestures: true }) // same as default
103
190
  setCanvasInteraction({ zoomOrigin: 'center' }) // same as default
104
191
  setCanvasInteraction({ zoomOrigin: 'invalid' }) // ignored
192
+ setCanvasInteraction({ surface: { width: 'auto' } }) // same as default
193
+ setCanvasInteraction({ surface: { width: 'fluid' } }) // ignored
105
194
  expect(cb).not.toHaveBeenCalled()
106
195
 
107
196
  setCanvasInteraction({ zoomGestures: false }) // real change
@@ -109,6 +198,9 @@ describe('canvasInteractionStore', () => {
109
198
 
110
199
  setCanvasInteraction({ zoomOrigin: 'top-left' }) // real change
111
200
  expect(cb).toHaveBeenCalledTimes(2)
201
+
202
+ setCanvasInteraction({ surface: { width: 'viewport' } }) // real change
203
+ expect(cb).toHaveBeenCalledTimes(3)
112
204
  })
113
205
 
114
206
  it('subscribers are notified on init and meaningful set; unsubscribe stops further notifications', () => {
@@ -257,6 +257,17 @@ export const configDefaults = {
257
257
  scroll: {
258
258
  axis: 'both', // "both" | "vertical" | "horizontal" | "none"
259
259
  },
260
+ surface: {
261
+ // Sizing of the underlying canvas surface (`.tc-canvas`). Independent
262
+ // per axis. "auto" preserves the default `max(100vw, 10000px)` floor
263
+ // so users can pan into a large workspace. "viewport" sizes the
264
+ // surface to exactly `100vw` / `100vh`, which is what landing-style
265
+ // canvases want: the unscaled canvas matches the visible window
266
+ // exactly, while zooming above 100% legitimately overflows and
267
+ // surfaces horizontal/vertical scroll.
268
+ width: 'auto', // "auto" | "viewport"
269
+ height: 'auto', // "auto" | "viewport"
270
+ },
260
271
  terminal: {
261
272
  resizable: false,
262
273
  defaultWidth: 800,
@@ -27,6 +27,7 @@ describe('configSchema', () => {
27
27
  expect(c.canvas.zoom.gestures).toBe(true)
28
28
  expect(c.canvas.zoom.origin).toBe('center')
29
29
  expect(c.canvas.scroll.axis).toBe('both')
30
+ expect(c.canvas.surface).toEqual({ width: 'auto', height: 'auto' })
30
31
  expect(c.canvas.production).toEqual({
31
32
  move: false,
32
33
  resize: false,
@@ -41,6 +41,23 @@
41
41
  will-change: transform;
42
42
  }
43
43
 
44
+ /* When the consumer sets `canvas.surface.width/height` to "viewport"
45
+ (see canvasInteractionStore.js), the canvas surface clamps to 100vw/100vh
46
+ — but the .canvasZoom wrapper still has its inline `max(10000, 100/scale)vw`
47
+ sizing, which would leave a huge stretch of empty wrapper to the right /
48
+ below the canvas and let the user scroll into it. Clamp the wrapper to
49
+ the viewport too. !important is required to beat the inline style set
50
+ by applyZoom(). Each axis is independent — only the configured axis is
51
+ constrained. */
52
+ :global(html[data-canvas-surface-width="viewport"]) .canvasZoom {
53
+ width: 100vw !important;
54
+ min-width: 100vw !important;
55
+ }
56
+ :global(html[data-canvas-surface-height="viewport"]) .canvasZoom {
57
+ height: 100vh !important;
58
+ min-height: 100vh !important;
59
+ }
60
+
44
61
  /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
45
62
 
46
63
  .canvasLogo {
@@ -165,7 +165,7 @@
165
165
  "ariaLabel": "Zoom to objects",
166
166
  "label": "Zoom to objects",
167
167
  "icon": "iconoir/square-3d-three-points",
168
- "meta": { "strokeWeight": 1.5, "scale": 0.5 },
168
+ "meta": { "strokeWeight": 2, "scale": 1 },
169
169
  "render": "canvas-zoom-to-fit",
170
170
  "surface": "canvas-toolbar",
171
171
  "handler": "core:canvas-toolbar",
@@ -179,7 +179,7 @@
179
179
  "labelOn": "Snap to grid (on)",
180
180
  "labelOff": "Snap to grid (off)",
181
181
  "icon": "primer/apps",
182
- "meta": { "strokeWeight": 1.5, "scale": 1.2 },
182
+ "meta": { "strokeWeight": 0.9, "scale": 1.1 },
183
183
  "render": "canvas-snap",
184
184
  "surface": "canvas-toolbar",
185
185
  "handler": "core:canvas-toolbar",