@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -1
- package/scaffold/AGENTS.md +0 -79
- package/scaffold/src/_prototype.jsx +9 -1
- package/src/core/cli/workleaf.js +3 -3
- package/src/core/mountStoryboardCore.js +1 -0
- package/src/core/stores/canvasInteractionStore.js +29 -9
- package/src/core/stores/canvasInteractionStore.test.js +37 -11
- package/src/core/stores/configSchema.js +1 -0
- package/src/core/stores/configSchema.test.js +1 -0
- package/src/core/styles/tailwind.css +2 -0
- package/src/core/workleaf/create.js +62 -12
- package/src/core/workleaf/nameGenerator.js +133 -0
- package/src/core/workleaf/nameGenerator.test.js +99 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +2 -2
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +1 -0
- package/src/internals/canvas/CanvasPage.jsx +54 -12
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +1 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +8 -3
- package/src/internals/canvas/widgets/expandUtils.js +6 -3
- package/src/internals/canvas/widgets/widgetConfig.js +33 -1
- package/src/internals/canvas/widgets/widgetConfig.merged.test.js +27 -0
- package/src/internals/story/ComponentSetPage.jsx +21 -9
|
@@ -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
|
-
|
|
76
|
-
|
|
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={
|
|
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
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: !
|
|
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) {
|