@dfosco/storyboard 0.9.4 → 0.9.6

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.
@@ -14,23 +14,23 @@ describe('canvasInteractionStore', () => {
14
14
  _resetCanvasInteraction()
15
15
  })
16
16
 
17
- it('starts with defaults: scrollAxis=both, zoomGestures=true', () => {
18
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
17
+ it('starts with defaults: scrollAxis=both, zoomGestures=true, zoomOrigin=center', () => {
18
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
19
19
  })
20
20
 
21
21
  it('initCanvasInteraction seeds from config', () => {
22
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
23
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: false })
22
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
23
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
24
24
  })
25
25
 
26
26
  it('initCanvasInteraction with no args / empty / invalid resets to defaults', () => {
27
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
27
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
28
28
  initCanvasInteraction()
29
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
29
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
30
30
 
31
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
31
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
32
32
  initCanvasInteraction({})
33
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
33
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true, zoomOrigin: 'center' })
34
34
  })
35
35
 
36
36
  it('initCanvasInteraction ignores invalid scrollAxis values', () => {
@@ -52,6 +52,24 @@ describe('canvasInteractionStore', () => {
52
52
  expect(getCanvasInteraction().zoomGestures).toBe(true)
53
53
  })
54
54
 
55
+ it('initCanvasInteraction ignores invalid zoomOrigin values', () => {
56
+ initCanvasInteraction({ zoomOrigin: 'bottom-right' })
57
+ expect(getCanvasInteraction().zoomOrigin).toBe('center')
58
+
59
+ initCanvasInteraction({ zoomOrigin: null })
60
+ expect(getCanvasInteraction().zoomOrigin).toBe('center')
61
+
62
+ initCanvasInteraction({ zoomOrigin: 42 })
63
+ expect(getCanvasInteraction().zoomOrigin).toBe('center')
64
+ })
65
+
66
+ it('accepts both valid zoomOrigin values', () => {
67
+ for (const origin of ['center', 'top-left']) {
68
+ initCanvasInteraction({ zoomOrigin: origin })
69
+ expect(getCanvasInteraction().zoomOrigin).toBe(origin)
70
+ }
71
+ })
72
+
55
73
  it('accepts all four valid axis values', () => {
56
74
  for (const axis of ['both', 'vertical', 'horizontal', 'none']) {
57
75
  initCanvasInteraction({ scrollAxis: axis })
@@ -60,13 +78,16 @@ describe('canvasInteractionStore', () => {
60
78
  })
61
79
 
62
80
  it('setCanvasInteraction patches partial state without resetting other keys', () => {
63
- initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
81
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false, zoomOrigin: 'top-left' })
64
82
 
65
83
  setCanvasInteraction({ zoomGestures: true })
66
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: true })
84
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: true, zoomOrigin: 'top-left' })
67
85
 
68
86
  setCanvasInteraction({ scrollAxis: 'horizontal' })
69
- expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true })
87
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true, zoomOrigin: 'top-left' })
88
+
89
+ setCanvasInteraction({ zoomOrigin: 'center' })
90
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true, zoomOrigin: 'center' })
70
91
  })
71
92
 
72
93
  it('setCanvasInteraction with no-op patch does not notify subscribers', () => {
@@ -79,10 +100,15 @@ describe('canvasInteractionStore', () => {
79
100
  setCanvasInteraction({ scrollAxis: 'invalid' }) // ignored
80
101
  setCanvasInteraction({ scrollAxis: 'both' }) // same as default
81
102
  setCanvasInteraction({ zoomGestures: true }) // same as default
103
+ setCanvasInteraction({ zoomOrigin: 'center' }) // same as default
104
+ setCanvasInteraction({ zoomOrigin: 'invalid' }) // ignored
82
105
  expect(cb).not.toHaveBeenCalled()
83
106
 
84
107
  setCanvasInteraction({ zoomGestures: false }) // real change
85
108
  expect(cb).toHaveBeenCalledTimes(1)
109
+
110
+ setCanvasInteraction({ zoomOrigin: 'top-left' }) // real change
111
+ expect(cb).toHaveBeenCalledTimes(2)
86
112
  })
87
113
 
88
114
  it('subscribers are notified on init and meaningful set; unsubscribe stops further notifications', () => {
@@ -217,6 +217,7 @@ export const configDefaults = {
217
217
  max: 250,
218
218
  step: 10,
219
219
  gestures: true, // when false, disables cmd+wheel and pinch-to-zoom; +/- buttons still work
220
+ origin: 'center', // "center" | "top-left" — anchor point for all zoom (toolbar, keyboard, cmd+wheel, pinch). Hold Alt while cmd+scrolling to opt into cursor-anchor zoom.
220
221
  },
221
222
  scroll: {
222
223
  axis: 'both', // "both" | "vertical" | "horizontal" | "none"
@@ -25,6 +25,7 @@ describe('configSchema', () => {
25
25
  expect(c.canvas.github.embedBehavior).toBe('link-preview')
26
26
  expect(c.canvas.github.ghGuard).toBe('copy')
27
27
  expect(c.canvas.zoom.gestures).toBe(true)
28
+ expect(c.canvas.zoom.origin).toBe('center')
28
29
  expect(c.canvas.scroll.axis).toBe('both')
29
30
  expect(c.commandPalette.providers).toEqual(['prototypes', 'flows', 'canvases', 'pages'])
30
31
  expect(c.commandPalette.ranking).toBe('frecency')
@@ -66,6 +66,7 @@
66
66
 
67
67
  --color-foreground: #1f2328;
68
68
  --color-foreground-muted: #59636e;
69
+ --color-foreground-hover: #000000;
69
70
  --color-foreground-accent: #0969da;
70
71
  --color-foreground-danger: #d1242f;
71
72
  --color-foreground-success: #1a7f37;
@@ -143,6 +144,7 @@
143
144
 
144
145
  --color-foreground: #f0f6fc;
145
146
  --color-foreground-muted: #9198a1;
147
+ --color-foreground-hover: #ffffff;
146
148
  --color-foreground-accent: #4493f8;
147
149
  --color-foreground-danger: #f85149;
148
150
  --color-foreground-success: #3fb950;
@@ -26,7 +26,8 @@ import { mkdirSync, existsSync } from 'node:fs'
26
26
  import { dirname, resolve } from 'node:path'
27
27
  import { detectWorktreeName, repoRoot, worktreeDir, getPort, slugify } from '../worktree/port.js'
28
28
  import { findByWorktree } from '../worktree/serverRegistry.js'
29
- import { detectWorkleafContext, workleafDir, workleafBranchName, WORKLEAF_PREFIX } from './paths.js'
29
+ import { detectWorkleafContext, workleafDir, workleafBranchName, listWorkleafs, WORKLEAF_PREFIX } from './paths.js'
30
+ import { generateWorkleafName } from './nameGenerator.js'
30
31
  import { dim, bold, green } from '../cli/intro.js'
31
32
  import { parseFlags } from '../cli/flags.js'
32
33
 
@@ -65,6 +66,42 @@ function resolveParentDir(parent, root) {
65
66
  return dir
66
67
  }
67
68
 
69
+ /**
70
+ * Build a Set of names already taken in this repo so we don't collide
71
+ * with an existing workleaf directory OR an existing git branch (a
72
+ * stale branch left over after a delete --force, for example).
73
+ *
74
+ * Slug-only: callers test bare workleaf slugs, not full branch names.
75
+ */
76
+ function collectTakenSlugs(root, parent) {
77
+ const taken = new Set()
78
+
79
+ // 1. Live workleafs on disk (any parent — we want global uniqueness
80
+ // of the slug so `cd` and `delete <slug>` stay unambiguous).
81
+ for (const wl of listWorkleafs(root)) {
82
+ taken.add(wl.slug)
83
+ }
84
+
85
+ // 2. Existing git branches that match `<parent>--workleaf--<slug>`
86
+ // — covers branches whose worktree was already cleaned up.
87
+ try {
88
+ const out = execFileSync(
89
+ 'git',
90
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
91
+ { cwd: root, encoding: 'utf8' }
92
+ )
93
+ const prefix = `${parent}--workleaf--`
94
+ for (const line of out.split('\n')) {
95
+ const ref = line.trim()
96
+ if (ref.startsWith(prefix)) taken.add(ref.slice(prefix.length))
97
+ }
98
+ } catch {
99
+ // Best-effort — if git fails we just rely on the disk check.
100
+ }
101
+
102
+ return taken
103
+ }
104
+
68
105
  /**
69
106
  * Public entry — runnable as a subcommand or imported.
70
107
  */
@@ -72,16 +109,8 @@ export async function runCreate(argv = process.argv.slice(4)) {
72
109
  const { flags, positional } = parseFlags(argv, flagSchema)
73
110
  const rawName = positional[0]
74
111
 
75
- const nameError = validateName(rawName)
76
- if (nameError) {
77
- p.log.error(nameError)
78
- p.log.info(`Usage: ${green('npx storyboard workleaf create <name>')}`)
79
- process.exit(1)
80
- }
81
-
82
- const slug = slugify(rawName.trim())
83
-
84
- // 1. Nesting check — refuse if cwd is already a workleaf
112
+ // Parent + root are needed before name resolution so the auto-namer
113
+ // can check collisions against the right branch prefix.
85
114
  const ctx = detectWorkleafContext()
86
115
  if (ctx.inWorkleaf) {
87
116
  p.log.error('Workleaves cannot be nested.')
@@ -89,9 +118,29 @@ export async function runCreate(argv = process.argv.slice(4)) {
89
118
  process.exit(1)
90
119
  }
91
120
 
92
- // 2. Derive parent from cwd (or explicit --parent)
93
121
  const parent = flags.parent || detectWorktreeName()
94
122
  const root = repoRoot()
123
+
124
+ let slug
125
+ if (rawName) {
126
+ const nameError = validateName(rawName)
127
+ if (nameError) {
128
+ p.log.error(nameError)
129
+ p.log.info(`Usage: ${green('npx storyboard workleaf create <name>')}`)
130
+ process.exit(1)
131
+ }
132
+ slug = slugify(rawName.trim())
133
+ } else {
134
+ const taken = collectTakenSlugs(root, parent)
135
+ const generated = generateWorkleafName((name) => taken.has(name))
136
+ if (!generated) {
137
+ p.log.error('Could not auto-generate a workleaf name — the whole pool is in use.')
138
+ p.log.info(`Pass an explicit name: ${green('npx storyboard workleaf create <name>')}`)
139
+ process.exit(1)
140
+ }
141
+ slug = generated
142
+ }
143
+
95
144
  const parentDir = resolveParentDir(parent, root)
96
145
  const parentBranch = currentBranch(parentDir)
97
146
 
@@ -106,6 +155,7 @@ export async function runCreate(argv = process.argv.slice(4)) {
106
155
  }
107
156
 
108
157
  p.intro('storyboard workleaf create')
158
+ if (!rawName) p.log.info(`${dim('name:')} ${bold(slug)} ${dim('(auto-generated)')}`)
109
159
  p.log.info(`${dim('parent:')} ${bold(parent)} ${dim('(branch:')} ${parentBranch}${dim(')')}`)
110
160
  p.log.info(`${dim('branch:')} ${bold(branch)}`)
111
161
  p.log.info(`${dim('dir:')} ${dim(dir.replace(root + '/', ''))}`)
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Auto-name generator for workleaves.
3
+ *
4
+ * Produces names in the form `<adjective>-<tree>` (e.g. "sunny-baobab")
5
+ * where both words are short, single-word slugs that are globally
6
+ * recognizable. The tree list is intentionally balanced across
7
+ * continents so the namespace doesn't lean toward any one region.
8
+ *
9
+ * Picks are randomized, then checked for collisions against a caller-
10
+ * supplied "in use" predicate (typically: existing workleaf slugs +
11
+ * existing git branches). If we exhaust the random search, we fall
12
+ * back to a deterministic sweep so we never give up on a free name
13
+ * unless the entire 20×20 namespace is taken.
14
+ */
15
+
16
+ export const ADJECTIVES = Object.freeze([
17
+ 'bright',
18
+ 'calm',
19
+ 'cheerful',
20
+ 'cozy',
21
+ 'gentle',
22
+ 'golden',
23
+ 'happy',
24
+ 'kind',
25
+ 'lively',
26
+ 'lucky',
27
+ 'merry',
28
+ 'mighty',
29
+ 'peaceful',
30
+ 'quiet',
31
+ 'snug',
32
+ 'steady',
33
+ 'sunny',
34
+ 'sweet',
35
+ 'warm',
36
+ 'wise',
37
+ ])
38
+
39
+ // 20 trees, ~2-3 per continent — globally recognizable, single-word
40
+ // (hyphenated only if unavoidable). No region dominates the namespace.
41
+ export const TREES = Object.freeze([
42
+ // Africa
43
+ 'baobab',
44
+ 'acacia',
45
+ 'marula',
46
+ // South America
47
+ 'jacaranda',
48
+ 'ceiba',
49
+ 'mahogany',
50
+ // Asia
51
+ 'bamboo',
52
+ 'ginkgo',
53
+ 'banyan',
54
+ 'sakura',
55
+ // Oceania / Pacific
56
+ 'eucalyptus',
57
+ 'kauri',
58
+ // Mediterranean / West Asia / North Africa
59
+ 'olive',
60
+ 'cedar',
61
+ 'cypress',
62
+ // North America
63
+ 'redwood',
64
+ 'maple',
65
+ // Europe
66
+ 'oak',
67
+ 'birch',
68
+ // Pantropical
69
+ 'palm',
70
+ ])
71
+
72
+ /**
73
+ * Total size of the name namespace (adjectives × trees).
74
+ */
75
+ export const NAMESPACE_SIZE = ADJECTIVES.length * TREES.length
76
+
77
+ /**
78
+ * Pick a random integer in [0, max).
79
+ */
80
+ function randInt(max, random = Math.random) {
81
+ return Math.floor(random() * max)
82
+ }
83
+
84
+ /**
85
+ * Compose a name from indices.
86
+ */
87
+ function compose(adjIdx, treeIdx) {
88
+ return `${ADJECTIVES[adjIdx]}-${TREES[treeIdx]}`
89
+ }
90
+
91
+ /**
92
+ * Generate a unique workleaf name not already taken.
93
+ *
94
+ * @param {(name: string) => boolean} isTaken
95
+ * Returns true if a name is already in use. Called for each
96
+ * candidate the generator considers.
97
+ * @param {object} [opts]
98
+ * @param {() => number} [opts.random=Math.random]
99
+ * Override the random source (for tests).
100
+ * @param {number} [opts.maxRandomAttempts=50]
101
+ * How many random picks to try before falling back to a sweep.
102
+ * @returns {string|null} A free name, or null if the entire namespace
103
+ * is exhausted.
104
+ */
105
+ export function generateWorkleafName(isTaken, opts = {}) {
106
+ const random = opts.random || Math.random
107
+ const maxRandomAttempts = opts.maxRandomAttempts ?? 50
108
+ const check = typeof isTaken === 'function' ? isTaken : () => false
109
+
110
+ for (let i = 0; i < maxRandomAttempts; i++) {
111
+ const candidate = compose(
112
+ randInt(ADJECTIVES.length, random),
113
+ randInt(TREES.length, random),
114
+ )
115
+ if (!check(candidate)) return candidate
116
+ }
117
+
118
+ // Random search exhausted — deterministic sweep so we never miss a
119
+ // free name unless the whole namespace is genuinely full.
120
+ const adjStart = randInt(ADJECTIVES.length, random)
121
+ const treeStart = randInt(TREES.length, random)
122
+ for (let a = 0; a < ADJECTIVES.length; a++) {
123
+ for (let t = 0; t < TREES.length; t++) {
124
+ const candidate = compose(
125
+ (adjStart + a) % ADJECTIVES.length,
126
+ (treeStart + t) % TREES.length,
127
+ )
128
+ if (!check(candidate)) return candidate
129
+ }
130
+ }
131
+
132
+ return null
133
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ ADJECTIVES,
4
+ TREES,
5
+ NAMESPACE_SIZE,
6
+ generateWorkleafName,
7
+ } from './nameGenerator.js'
8
+
9
+ describe('ADJECTIVES + TREES word lists', () => {
10
+ it('contains exactly 20 adjectives and 20 trees', () => {
11
+ expect(ADJECTIVES.length).toBe(20)
12
+ expect(TREES.length).toBe(20)
13
+ })
14
+
15
+ it('uses only lowercase single-word tokens (hyphens allowed)', () => {
16
+ for (const list of [ADJECTIVES, TREES]) {
17
+ for (const word of list) {
18
+ expect(word).toMatch(/^[a-z]+(-[a-z]+)?$/)
19
+ }
20
+ }
21
+ })
22
+
23
+ it('has no duplicates within each list', () => {
24
+ expect(new Set(ADJECTIVES).size).toBe(ADJECTIVES.length)
25
+ expect(new Set(TREES).size).toBe(TREES.length)
26
+ })
27
+
28
+ it('NAMESPACE_SIZE matches the product', () => {
29
+ expect(NAMESPACE_SIZE).toBe(ADJECTIVES.length * TREES.length)
30
+ })
31
+ })
32
+
33
+ describe('generateWorkleafName', () => {
34
+ it('produces an <adjective>-<tree> name from the word lists', () => {
35
+ const name = generateWorkleafName(() => false)
36
+ expect(name).toMatch(/^[a-z]+(-[a-z]+)?-[a-z]+(-[a-z]+)?$/)
37
+ const [adj, ...rest] = name.split('-')
38
+ // Either the adjective is a single token or the tree contains a
39
+ // hyphen — we have no multi-token adjectives currently, so check by
40
+ // walking the suffix until the rest matches a tree.
41
+ let tree = rest.join('-')
42
+ let prefix = adj
43
+ // Try every split point (e.g. "foo-bar-baz" could be adj="foo",
44
+ // tree="bar-baz" or adj="foo-bar", tree="baz").
45
+ const parts = name.split('-')
46
+ let matched = false
47
+ for (let i = 1; i < parts.length; i++) {
48
+ const a = parts.slice(0, i).join('-')
49
+ const t = parts.slice(i).join('-')
50
+ if (ADJECTIVES.includes(a) && TREES.includes(t)) {
51
+ matched = true
52
+ prefix = a
53
+ tree = t
54
+ break
55
+ }
56
+ }
57
+ expect(matched).toBe(true)
58
+ expect(ADJECTIVES).toContain(prefix)
59
+ expect(TREES).toContain(tree)
60
+ })
61
+
62
+ it('skips names reported as taken', () => {
63
+ // Mark a specific candidate as taken; with a deterministic random
64
+ // source that would have returned it first, we should get a
65
+ // different name back.
66
+ const sequence = [
67
+ // First pick: ADJECTIVES[0] + TREES[0] — "bright-baobab" — taken
68
+ 0, 0,
69
+ // Second pick: ADJECTIVES[1] + TREES[1] — free
70
+ 1 / ADJECTIVES.length, 1 / TREES.length,
71
+ ]
72
+ let i = 0
73
+ const random = () => sequence[i++] ?? 0
74
+
75
+ const first = `${ADJECTIVES[0]}-${TREES[0]}`
76
+ const name = generateWorkleafName((n) => n === first, { random })
77
+ expect(name).not.toBe(first)
78
+ })
79
+
80
+ it('returns null when the entire namespace is exhausted', () => {
81
+ const name = generateWorkleafName(() => true, { maxRandomAttempts: 5 })
82
+ expect(name).toBeNull()
83
+ })
84
+
85
+ it('falls back to a sweep when random attempts are exhausted but a slot remains', () => {
86
+ // Block every name except a single deterministic survivor.
87
+ const survivor = `${ADJECTIVES[7]}-${TREES[13]}`
88
+ const name = generateWorkleafName((n) => n !== survivor, { maxRandomAttempts: 3 })
89
+ expect(name).toBe(survivor)
90
+ })
91
+
92
+ it('returns the random pick when no names are taken', () => {
93
+ // Force the random source to return a specific pair on the first
94
+ // attempt; the predicate accepts everything, so we should get it.
95
+ const random = () => 0
96
+ const name = generateWorkleafName(() => false, { random })
97
+ expect(name).toBe(`${ADJECTIVES[0]}-${TREES[0]}`)
98
+ })
99
+ })
@@ -351,8 +351,8 @@
351
351
  }
352
352
 
353
353
  .buttonVariant_primary:not(:disabled):hover {
354
- background: #000;
355
- border-color: #000;
354
+ background: var(--color-foreground-hover, #000);
355
+ border-color: var(--color-foreground-hover, #000);
356
356
  }
357
357
 
358
358
  .buttonVariant_secondary {
@@ -74,6 +74,7 @@ vi.mock('./widgets/widgetConfig.js', async () => {
74
74
  isSplitScreenCapable: () => false,
75
75
  getChromeOptions: () => ({ enabled: true }),
76
76
  getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
77
+ isMovable: () => false,
77
78
  schemas: {},
78
79
  getMenuWidgetTypes: () => [],
79
80
  getConnectorDefaults: actual.getConnectorDefaults,
@@ -59,6 +59,7 @@ vi.mock('./widgets/widgetConfig.js', async () => {
59
59
  isSplitScreenCapable: () => false,
60
60
  getChromeOptions: () => ({ enabled: true }),
61
61
  getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
62
+ isMovable: () => false,
62
63
  schemas: {},
63
64
  getMenuWidgetTypes: () => [],
64
65
  getConnectorDefaults: actual.getConnectorDefaults,
@@ -6,7 +6,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
6
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
7
  import { getWidgetComponent } from './widgets/index.js'
8
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
- import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable, getChromeOptions, getInteractionOptions } from './widgets/widgetConfig.js'
9
+ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable, getChromeOptions, getInteractionOptions, isMovable } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '../../core/index.js'
12
12
  import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
@@ -566,7 +566,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
566
566
  onRoleChange={onRoleChange ? (roleId) => onRoleChange(widget.id, roleId) : undefined}
567
567
  readOnly={readOnly}
568
568
  chromeEnabled={chromeOpts.enabled}
569
- movable={interactionOpts.movable}
569
+ movable={isMovable(widget.type)}
570
570
  selectable={interactionOpts.selectable}
571
571
  >
572
572
  <WidgetRenderer
@@ -2199,11 +2199,30 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2199
2199
  const oldScale = zoomRef.current / 100
2200
2200
  const newScale = clampedZoom / 100
2201
2201
 
2202
- // Anchor point in scroll-container space
2202
+ // Anchor point in scroll-container space.
2203
+ //
2204
+ // When a cursor position is supplied (wheel/pinch zoom), anchor there.
2205
+ // Otherwise (toolbar +/− and keyboard shortcuts) honor the configured
2206
+ // `canvas.zoom.origin`:
2207
+ // - "center" → viewport center (default; matches native canvas)
2208
+ // - "top-left" → the scroll container's top-left, which keeps the
2209
+ // canvas's (0, 0) pinned to the viewport top-left
2210
+ // through any zoom — useful for landing canvases
2211
+ // with top-left-anchored content.
2203
2212
  const rect = el.getBoundingClientRect()
2204
- const useViewportCenter = clientX == null || clientY == null
2205
- const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
2206
- const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
2213
+ const cursorProvided = clientX != null && clientY != null
2214
+ let anchorX
2215
+ let anchorY
2216
+ if (cursorProvided) {
2217
+ anchorX = clientX - rect.left
2218
+ anchorY = clientY - rect.top
2219
+ } else if (interactionRef.current?.zoomOrigin === 'top-left') {
2220
+ anchorX = 0
2221
+ anchorY = 0
2222
+ } else {
2223
+ anchorX = el.clientWidth / 2
2224
+ anchorY = el.clientHeight / 2
2225
+ }
2207
2226
 
2208
2227
  // Anchor → canvas coordinate
2209
2228
  const canvasX = (el.scrollLeft + anchorX) / oldScale
@@ -3308,10 +3327,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3308
3327
  }))
3309
3328
  }, [undoRedo.canUndo, undoRedo.canRedo])
3310
3329
 
3311
- // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
3330
+ // Cmd+scroll / trackpad pinch to smooth-zoom the canvas.
3312
3331
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
3313
3332
  // fractional deltaY values. We accumulate the delta to handle sub-pixel changes.
3314
3333
  // Skipped entirely when the consumer disables zoomGestures (e.g. landing page).
3334
+ //
3335
+ // Anchor policy: by default, zoom honors `canvas.zoom.origin` (center or
3336
+ // top-left) — same anchor as toolbar/keyboard zoom, so a user zooming
3337
+ // out from the corner of the screen doesn't get teleported off-canvas.
3338
+ // Hold Alt while cmd+scrolling to opt into cursor-anchor zoom (power
3339
+ // user gesture: zoom centered on whatever you're hovering).
3315
3340
  const zoomAccum = useRef(0)
3316
3341
  useEffect(() => {
3317
3342
  function handleWheel(e) {
@@ -3322,7 +3347,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3322
3347
  const step = Math.trunc(zoomAccum.current)
3323
3348
  if (step === 0) return
3324
3349
  zoomAccum.current -= step
3325
- applyZoom(zoomRef.current + step, e.clientX, e.clientY)
3350
+ if (e.altKey) {
3351
+ applyZoom(zoomRef.current + step, e.clientX, e.clientY)
3352
+ } else {
3353
+ applyZoom(zoomRef.current + step)
3354
+ }
3326
3355
  }
3327
3356
  document.addEventListener('wheel', handleWheel, { passive: false })
3328
3357
  return () => document.removeEventListener('wheel', handleWheel)
@@ -3375,7 +3404,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3375
3404
  const dist = getTouchDist(e.touches[0], e.touches[1])
3376
3405
  const ratio = dist / pinchState.current.startDist
3377
3406
  const newZoom = Math.round(pinchState.current.startZoom * ratio)
3378
- applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
3407
+ // Touch pinch has no modifier-key equivalent; always honor the
3408
+ // configured zoomOrigin (centerpoint of the pinch is intuitive
3409
+ // visually but tends to drift the canvas across the screen).
3410
+ applyZoom(newZoom)
3379
3411
  }
3380
3412
 
3381
3413
  function handleTouchEnd() {
@@ -3491,6 +3523,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3491
3523
  )
3492
3524
  }
3493
3525
 
3526
+ // Drag is allowed in dev for every widget, and in production only when a
3527
+ // widget opts in via `interaction.movable: { enabled: true, prod: true }`.
3528
+ // The canvas-wide `locked` switch is what tiny-canvas reads to disable
3529
+ // neodrag; we keep it locked in prod unless at least one widget on this
3530
+ // canvas (or any component widget — they share the 'component' type config)
3531
+ // is movable-in-prod.
3532
+ const hasMovableInEnv = isLocalDev
3533
+ || (localWidgets ?? []).some((w) => isMovable(w.type))
3534
+ || (componentEntries.length > 0 && isMovable('component'))
3535
+
3494
3536
  const canvasProps = {
3495
3537
  centered: canvas.centered ?? false,
3496
3538
  dotted: canvas.dotted ?? false,
@@ -3500,7 +3542,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3500
3542
  colorMode: canvas.colorMode === 'auto'
3501
3543
  ? getToolbarColorMode(canvasTheme)
3502
3544
  : (canvas.colorMode ?? 'auto'),
3503
- locked: !isLocalDev,
3545
+ locked: !hasMovableInEnv,
3504
3546
  }
3505
3547
 
3506
3548
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
@@ -3521,7 +3563,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3521
3563
  data-tc-x={sourcePosition.x}
3522
3564
  data-tc-y={sourcePosition.y}
3523
3565
  data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
3524
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
3566
+ {...((isLocalDev || isMovable('component')) ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
3525
3567
  {...canvasPrimerAttrs}
3526
3568
  style={canvasThemeVars}
3527
3569
  onClick={isLocalDev ? (e) => {
@@ -3574,7 +3616,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3574
3616
  data-tc-x={effectiveWidget?.position?.x ?? 0}
3575
3617
  data-tc-y={effectiveWidget?.position?.y ?? 0}
3576
3618
  data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
3577
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
3619
+ {...((isLocalDev || isMovable(effectiveWidget.type)) ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
3578
3620
  {...canvasPrimerAttrs}
3579
3621
  style={canvasThemeVars}
3580
3622
  onClick={isLocalDev ? (e) => {
@@ -102,6 +102,7 @@ vi.mock('./widgets/widgetConfig.js', async () => {
102
102
  getInteractGate: () => ({ enabled: false, label: 'Click to interact' }),
103
103
  getChromeOptions: () => ({ enabled: true }),
104
104
  getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
105
+ isMovable: () => false,
105
106
  getWidgetMeta: () => null,
106
107
  getConnectorConfig: actual.getConnectorConfig,
107
108
  getAnchorState: actual.getAnchorState,
@@ -44,15 +44,20 @@ function resolveStorySetUrl(storyId, layout, selected, density, theme) {
44
44
  // components rendering in light mode regardless of the canvas theme.
45
45
  params.set('_sb_theme_target', 'prototype')
46
46
  if (layout) params.set('layout', layout)
47
- if (selected) params.set('selected', selected)
48
47
  if (density) params.set('density', density)
49
48
  if (theme) params.set('theme', theme)
50
49
 
50
+ // `selected` lives in the URL hash, not the query string, so that
51
+ // selecting a different variant is a same-document fragment navigation
52
+ // when React reassigns iframe.src — no iframe reload. See
53
+ // ComponentSetPage.handleSelect for the matching producer.
54
+ const hash = selected ? `#selected=${encodeURIComponent(selected)}` : ''
55
+
51
56
  const route = story._route || `/components/${storyId}`
52
57
  if (import.meta.env.DEV) {
53
- return `${base}/stories.html${route}?${params}`
58
+ return `${base}/stories.html${route}?${params}${hash}`
54
59
  }
55
- return `${base}${route}?${params}`
60
+ return `${base}${route}?${params}${hash}`
56
61
  }
57
62
 
58
63
  export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {