@lupinum/board-core 1.0.0-beta.4 → 1.0.0-beta.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.
Files changed (26) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +46 -25
  3. package/dist/agent/AGENTS.md +53 -57
  4. package/dist/agent/pages/docs/build-features/connections.md +333 -2
  5. package/dist/agent/pages/docs/build-features/custom-node-renderers.md +366 -2
  6. package/dist/agent/pages/docs/build-features/read-only-and-command-guards.md +278 -2
  7. package/dist/agent/pages/docs/build-features/save-and-load.md +264 -2
  8. package/dist/agent/pages/docs/build-features/selection-and-keyboard.md +257 -2
  9. package/dist/agent/pages/docs/build-features/theming.md +285 -2
  10. package/dist/agent/pages/docs/evaluate/how-nuxt-board-works.md +451 -2
  11. package/dist/agent/pages/docs/project/contributing.md +4 -4
  12. package/dist/agent/pages/docs/project/support-and-security.md +1 -1
  13. package/dist/agent/pages/docs/reference/events-and-errors.md +279 -2
  14. package/dist/agent/pages/docs/reference/package-overview.md +2 -2
  15. package/dist/agent/pages/docs/solutions/mind-map.md +448 -2
  16. package/dist/agent/pages/docs/solutions/nuxt-application.md +214 -2
  17. package/dist/agent/pages/docs/solutions/planning-board.md +281 -2
  18. package/dist/agent/pages/docs/solutions/read-only-viewer.md +278 -2
  19. package/dist/agent/pages/docs/solutions/workflow-builder.md +435 -2
  20. package/dist/agent/pages/docs/start-building/customize-your-first-node.md +381 -2
  21. package/dist/agent/pages/docs/start-building/installation.md +6 -14
  22. package/dist/agent/pages/docs/understand-the-system/commands-and-transactions.md +456 -2
  23. package/dist/agent/pages/docs/understand-the-system/document-and-session-state.md +348 -2
  24. package/dist/agent/pages/docs/understand-the-system/persistence-and-json-canvas.md +445 -2
  25. package/package.json +3 -2
  26. package/dist/agent/manifest.json +0 -320
@@ -13,8 +13,454 @@ source: "ginko-content"
13
13
 
14
14
  > Spatial topic hierarchy with connections, custom renderers, and branch-from-selection.
15
15
 
16
- > Component omitted: `mind-map-demo`.
17
- > This page contains an interactive or site-specific block that has no agent markdown serializer yet.
16
+ ## Live example source
17
+
18
+ Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
19
+
20
+ ### app/components/demos/MindMapDemo.vue
21
+
22
+ ````vue
23
+ <script setup lang="ts">
24
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
25
+ import { asNodeId, createBoardEngine, type NodeId } from '@lupinum/board-core'
26
+ import { createDemoDocument } from '../../utils/demoDocument'
27
+ import { connectionsPlugin } from '@lupinum/board-connections'
28
+ import { BoardConnectionLayer } from '@lupinum/board-connections/vue'
29
+ import { historyPlugin } from '@lupinum/board-history'
30
+ import MindMapTopicNode from './MindMapTopicNode.vue'
31
+
32
+ const engine = createBoardEngine({
33
+ grid: { size: 20, majorEvery: 5, snap: true, pattern: 'dot' },
34
+ plugins: [historyPlugin(), connectionsPlugin({ routing: 'bezier' })],
35
+ })
36
+
37
+ const selection = ref<NodeId[]>([])
38
+ const edgesVersion = ref(0)
39
+ const historyState = ref(engine.plugins.history.getState())
40
+ const ROOT_ID = asNodeId('root')
41
+ const ENG_ID = asNodeId('eng')
42
+ const DESIGN_ID = asNodeId('design')
43
+ const GROWTH_ID = asNodeId('growth')
44
+ const API_ID = asNodeId('api')
45
+
46
+ function syncHistoryState() {
47
+ historyState.value = engine.plugins.history.getState()
48
+ }
49
+
50
+ function refreshEdges() {
51
+ edgesVersion.value++
52
+ }
53
+
54
+ const unsubscribeEngine = [
55
+ engine.on('selection:change', (next) => {
56
+ selection.value = next
57
+ }),
58
+ engine.on('edge:created', refreshEdges),
59
+ engine.on('edge:updated', refreshEdges),
60
+ engine.on('edge:deleted', refreshEdges),
61
+ engine.on('history:push', syncHistoryState),
62
+ engine.on('history:undo', syncHistoryState),
63
+ engine.on('history:redo', syncHistoryState),
64
+ engine.on('history:clear', syncHistoryState),
65
+ ]
66
+
67
+ const topicDepthsById = computed(() => {
68
+ void edgesVersion.value
69
+ const depths = new Map<NodeId, number>([[ROOT_ID, 0]])
70
+ const queue = [ROOT_ID]
71
+
72
+ while (queue.length > 0) {
73
+ const current = queue.shift()!
74
+ const depth = depths.get(current) ?? 0
75
+ for (const edge of engine.plugins.connections.getEdgesFrom(current)) {
76
+ if (depths.has(edge.to)) {
77
+ continue
78
+ }
79
+ depths.set(edge.to, depth + 1)
80
+ queue.push(edge.to)
81
+ }
82
+ }
83
+
84
+ return depths
85
+ })
86
+
87
+ function seed() {
88
+ engine.loadDocument(
89
+ createDemoDocument({
90
+ camera: { x: -60, y: -40, z: 1 },
91
+ grid: engine.getGridSettings(),
92
+ selection: [],
93
+ nextZIndex: 6,
94
+ nodes: [
95
+ {
96
+ id: ROOT_ID,
97
+ type: 'text',
98
+ x: 300,
99
+ y: 160,
100
+ width: 220,
101
+ height: 110,
102
+ text: 'Product roadmap\nQ3 priorities',
103
+ zIndex: 1,
104
+ locked: false,
105
+ visible: true,
106
+ },
107
+ {
108
+ id: ENG_ID,
109
+ type: 'text',
110
+ x: 40,
111
+ y: 40,
112
+ width: 200,
113
+ height: 100,
114
+ text: 'Engineering\nAPI, canvas, exports',
115
+ zIndex: 2,
116
+ locked: false,
117
+ visible: true,
118
+ },
119
+ {
120
+ id: DESIGN_ID,
121
+ type: 'text',
122
+ x: 40,
123
+ y: 290,
124
+ width: 200,
125
+ height: 100,
126
+ text: 'Design\nInteraction polish',
127
+ zIndex: 3,
128
+ locked: false,
129
+ visible: true,
130
+ },
131
+ {
132
+ id: GROWTH_ID,
133
+ type: 'text',
134
+ x: 610,
135
+ y: 40,
136
+ width: 200,
137
+ height: 100,
138
+ text: 'Growth\nActivation paths',
139
+ zIndex: 4,
140
+ locked: false,
141
+ visible: true,
142
+ },
143
+ {
144
+ id: API_ID,
145
+ type: 'text',
146
+ x: 610,
147
+ y: 290,
148
+ width: 200,
149
+ height: 90,
150
+ text: 'Public API\nKeep it boring',
151
+ zIndex: 5,
152
+ locked: false,
153
+ visible: true,
154
+ },
155
+ ],
156
+ }),
157
+ { mode: 'replace' },
158
+ )
159
+
160
+ const conn = engine.plugins.connections
161
+ for (const edge of conn.getEdges()) conn.deleteEdge(edge.id)
162
+ conn.createEdge({ from: ROOT_ID, to: ENG_ID, data: {} })
163
+ conn.createEdge({ from: ROOT_ID, to: DESIGN_ID, data: {} })
164
+ conn.createEdge({ from: ROOT_ID, to: GROWTH_ID, data: {} })
165
+ conn.createEdge({ from: ENG_ID, to: API_ID, data: {} })
166
+ engine.plugins.history.clear()
167
+ refreshEdges()
168
+ syncHistoryState()
169
+ }
170
+
171
+ let branchCount = 0
172
+ function addBranch() {
173
+ branchCount++
174
+ const selected = selection.value[0]
175
+ const parent = selected ?? ROOT_ID
176
+ const parentNode = engine.getNode(parent)
177
+ if (!parentNode) return
178
+
179
+ const newNode = engine.createNode({
180
+ type: 'text',
181
+ x: parentNode.x + 260 + (branchCount % 2) * 32,
182
+ y: parentNode.y + ((branchCount % 3) - 1) * 60,
183
+ width: 200,
184
+ height: 90,
185
+ text: `New topic ${branchCount}`,
186
+ select: true,
187
+ })
188
+ engine.plugins.connections.createEdge({
189
+ from: parent,
190
+ to: newNode.id,
191
+ data: {},
192
+ })
193
+ }
194
+
195
+ onMounted(async () => {
196
+ seed()
197
+ await engine.zoomToFit(72, false)
198
+ })
199
+
200
+ onBeforeUnmount(() => {
201
+ for (const unsubscribe of unsubscribeEngine) {
202
+ unsubscribe()
203
+ }
204
+ })
205
+ </script>
206
+
207
+ <template>
208
+ <div class="demo-frame">
209
+ <div class="demo-toolbar">
210
+ <button class="demo-danger" @click="seed">Reset</button>
211
+ <button class="demo-primary" @click="addBranch">Add branch</button>
212
+ <button @click="engine.zoomToFit(72, false)">Zoom to fit</button>
213
+ <button
214
+ :disabled="historyState.undoDepth === 0"
215
+ @click="engine.plugins.history.undo()"
216
+ >
217
+ Undo
218
+ </button>
219
+ <button
220
+ :disabled="historyState.redoDepth === 0"
221
+ @click="engine.plugins.history.redo()"
222
+ >
223
+ Redo
224
+ </button>
225
+ <span class="demo-toolbar-note ml-auto"
226
+ >Select a topic, then add a branch from it.</span
227
+ >
228
+ </div>
229
+
230
+ <BoardRoot :engine="engine" style="height: 420px">
231
+ <template #node:text="{ node, selected }">
232
+ <MindMapTopicNode
233
+ :node="node"
234
+ :selected="selected"
235
+ :depth="topicDepthsById.get(node.id) ?? 2"
236
+ />
237
+ </template>
238
+ <BoardConnectionLayer routing="bezier" />
239
+ </BoardRoot>
240
+ </div>
241
+ </template>
242
+ ````
243
+
244
+ ### app/utils/demoDocument.ts
245
+
246
+ ````ts
247
+ import type {
248
+ BoardNode,
249
+ BoardNodeMetadata,
250
+ Camera,
251
+ GridSettings,
252
+ JsonCanvasDocument,
253
+ JsonCanvasNode,
254
+ NodeId,
255
+ } from '@lupinum/board-core'
256
+
257
+ type DemoNode = Omit<
258
+ Pick<
259
+ BoardNode,
260
+ | 'id'
261
+ | 'type'
262
+ | 'x'
263
+ | 'y'
264
+ | 'width'
265
+ | 'height'
266
+ | 'color'
267
+ | 'text'
268
+ | 'file'
269
+ | 'subpath'
270
+ | 'url'
271
+ | 'label'
272
+ | 'background'
273
+ | 'backgroundStyle'
274
+ | 'zIndex'
275
+ | 'locked'
276
+ | 'visible'
277
+ | 'parentId'
278
+ >,
279
+ 'id' | 'parentId'
280
+ > & {
281
+ id: NodeId | string
282
+ parentId?: NodeId | string
283
+ }
284
+
285
+ export interface DemoDocumentInput {
286
+ nodes: readonly DemoNode[]
287
+ camera?: Camera
288
+ grid?: GridSettings
289
+ selection?: readonly (NodeId | string)[]
290
+ nextZIndex?: number
291
+ }
292
+
293
+ function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
294
+ const base = {
295
+ id: node.id as NodeId,
296
+ type: node.type,
297
+ x: node.x,
298
+ y: node.y,
299
+ width: node.width,
300
+ height: node.height,
301
+ ...(node.color ? { color: node.color } : {}),
302
+ }
303
+
304
+ switch (node.type) {
305
+ case 'file':
306
+ return {
307
+ ...base,
308
+ type: 'file',
309
+ file: node.file ?? '',
310
+ ...(node.subpath ? { subpath: node.subpath } : {}),
311
+ }
312
+ case 'link':
313
+ return {
314
+ ...base,
315
+ type: 'link',
316
+ url: node.url ?? '',
317
+ }
318
+ case 'group':
319
+ return {
320
+ ...base,
321
+ type: 'group',
322
+ ...((node.label ?? node.text)
323
+ ? { label: node.label ?? node.text }
324
+ : {}),
325
+ ...(node.background ? { background: node.background } : {}),
326
+ ...(node.backgroundStyle
327
+ ? { backgroundStyle: node.backgroundStyle }
328
+ : {}),
329
+ }
330
+ case 'text':
331
+ default:
332
+ return {
333
+ ...base,
334
+ type: 'text',
335
+ text: node.text ?? '',
336
+ }
337
+ }
338
+ }
339
+
340
+ function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
341
+ return {
342
+ zIndex: node.zIndex,
343
+ locked: node.locked,
344
+ visible: node.visible,
345
+ ...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
346
+ }
347
+ }
348
+
349
+ export function createDemoDocument(
350
+ input: DemoDocumentInput,
351
+ ): JsonCanvasDocument {
352
+ return {
353
+ nodes: input.nodes.map(toJsonCanvasNode),
354
+ 'x-lupinum-board': {
355
+ ...(input.camera ? { camera: input.camera } : {}),
356
+ ...(input.grid ? { grid: input.grid } : {}),
357
+ ...(input.selection
358
+ ? { selection: input.selection.map((id) => id as NodeId) }
359
+ : {}),
360
+ nextZIndex:
361
+ input.nextZIndex ??
362
+ input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
363
+ nodes: Object.fromEntries(
364
+ input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
365
+ ),
366
+ },
367
+ }
368
+ }
369
+ ````
370
+
371
+ ### app/components/demos/MindMapTopicNode.vue
372
+
373
+ ````vue
374
+ <script setup lang="ts">
375
+ import { computed } from 'vue'
376
+ import type { BoardNode } from '@lupinum/board-core'
377
+
378
+ const props = defineProps<{
379
+ node: BoardNode
380
+ selected: boolean
381
+ depth: number
382
+ }>()
383
+
384
+ const data = computed(() => {
385
+ const [title = 'Topic', detail = ''] = props.node.text?.split('\n') ?? []
386
+ return { title, detail }
387
+ })
388
+ const depth = computed(() => props.depth)
389
+
390
+ const style = computed(() => {
391
+ if (depth.value === 0) {
392
+ return {
393
+ surface: `linear-gradient(135deg,
394
+ color-mix(in srgb, #14b8a6 16%, white),
395
+ color-mix(in srgb, #14b8a6 4%, white) 55%,
396
+ color-mix(in srgb, #14b8a6 12%, white))`,
397
+ glow: 'inset 0 1px 0 0 rgba(20,184,166,0.20), 0 14px 48px rgba(15,23,42,0.08), 0 1px 3px rgba(20,184,166,0.08)',
398
+ border: '#14b8a6',
399
+ text: 'text-teal-950',
400
+ label: 'ROOT',
401
+ labelColor: 'text-teal-600',
402
+ }
403
+ }
404
+ if (depth.value === 1) {
405
+ return {
406
+ surface: `linear-gradient(135deg,
407
+ color-mix(in srgb, #0ea5e9 14%, white),
408
+ color-mix(in srgb, #0ea5e9 3%, white) 55%,
409
+ color-mix(in srgb, #0ea5e9 10%, white))`,
410
+ glow: 'inset 0 1px 0 0 rgba(14,165,233,0.18), 0 14px 48px rgba(15,23,42,0.07), 0 1px 3px rgba(14,165,233,0.08)',
411
+ border: '#0ea5e9',
412
+ text: 'text-sky-950',
413
+ label: 'BRANCH',
414
+ labelColor: 'text-sky-500',
415
+ }
416
+ }
417
+ return {
418
+ surface: `linear-gradient(135deg,
419
+ color-mix(in srgb, #94a3b8 10%, white),
420
+ white 55%,
421
+ color-mix(in srgb, #94a3b8 6%, white))`,
422
+ glow: 'inset 0 1px 0 0 rgba(148,163,184,0.14), 0 10px 36px rgba(15,23,42,0.05)',
423
+ border: '#94a3b8',
424
+ text: 'text-default',
425
+ label: 'LEAF',
426
+ labelColor: 'text-dimmed',
427
+ }
428
+ })
429
+ </script>
430
+
431
+ <template>
432
+ <div
433
+ class="h-full rounded-md border p-4"
434
+ :class="[selected ? 'ring-2 ring-primary' : 'border-default']"
435
+ :style="{
436
+ borderLeft: `4px solid ${style.border}`,
437
+ background: style.surface,
438
+ boxShadow: style.glow,
439
+ }"
440
+ >
441
+ <div class="flex h-full flex-col justify-between gap-2">
442
+ <span
443
+ class="flex items-center gap-1.5 text-[0.6rem] font-semibold uppercase tracking-[0.28em]"
444
+ :class="style.labelColor"
445
+ >
446
+ <span
447
+ class="inline-block size-1.5 rounded-full"
448
+ :style="{ background: style.border }"
449
+ ></span>
450
+ {{ style.label }}
451
+ </span>
452
+ <div>
453
+ <h3 class="text-base font-semibold tracking-tight" :class="style.text">
454
+ {{ data.title }}
455
+ </h3>
456
+ <p v-if="data.detail" class="mt-1.5 text-sm leading-5 text-dimmed">
457
+ {{ data.detail }}
458
+ </p>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ </template>
463
+ ````
18
464
 
19
465
  ## What happens
20
466
 
@@ -13,8 +13,220 @@ source: "ginko-content"
13
13
 
14
14
  > Use board components and composables without manual imports via the Nuxt module.
15
15
 
16
- > Component omitted: `nuxt-auto-imports-demo`.
17
- > This page contains an interactive or site-specific block that has no agent markdown serializer yet.
16
+ ## Live example source
17
+
18
+ Use the installation guide to register @lupinum/nuxt-board. These are the actual documentation demo files. Keep their paths under app/. The module auto-imports Board components; install the optional packages named in imports. For the local content components, use components: [{ path: "~/components/content", pathPrefix: false }, "~/components"] in nuxt.config.ts, as this site does, or import them explicitly. createDemoDocument is a local helper, not a package export. DocsLab and the inspector components provide presentation only.
19
+
20
+ ### app/components/demos/NuxtAutoImportsDemo.vue
21
+
22
+ ````vue
23
+ <script setup lang="ts">
24
+ import { onMounted } from 'vue'
25
+ import { createBoardEngine, createDemoDocument } from '#imports'
26
+
27
+ // The generated #imports entry verifies the helpers are registered by @lupinum/nuxt-board.
28
+ const engine = createBoardEngine({
29
+ grid: { size: 20, majorEvery: 5, snap: true, pattern: 'dot' },
30
+ })
31
+
32
+ function seed() {
33
+ engine.loadDocument(
34
+ createDemoDocument({
35
+ camera: { x: 0, y: 0, z: 1 },
36
+ grid: engine.getGridSettings(),
37
+ nodes: [
38
+ {
39
+ id: 'nuxt',
40
+ type: 'text',
41
+ x: 100,
42
+ y: 100,
43
+ width: 240,
44
+ height: 100,
45
+ text: 'Node',
46
+ zIndex: 1,
47
+ locked: false,
48
+ visible: true,
49
+ },
50
+ {
51
+ id: 'board',
52
+ type: 'text',
53
+ x: 420,
54
+ y: 180,
55
+ width: 240,
56
+ height: 100,
57
+ text: 'Node',
58
+ zIndex: 2,
59
+ locked: false,
60
+ visible: true,
61
+ },
62
+ ],
63
+ selection: [],
64
+ nextZIndex: 3,
65
+ }),
66
+ { mode: 'replace' },
67
+ )
68
+ }
69
+
70
+ seed()
71
+
72
+ onMounted(async () => {
73
+ await engine.zoomToFit(84, false)
74
+ })
75
+ </script>
76
+
77
+ <template>
78
+ <div class="demo-frame">
79
+ <div class="demo-toolbar">
80
+ <button class="demo-danger" @click="seed">Reset</button>
81
+ <button
82
+ class="demo-primary"
83
+ @click="
84
+ engine.createNode({
85
+ type: 'text',
86
+ x: 260,
87
+ y: 80,
88
+ width: 220,
89
+ height: 100,
90
+ text: 'Node',
91
+ })
92
+ "
93
+ >
94
+ Add node
95
+ </button>
96
+ <button @click="engine.zoomToFit(84, false)">Zoom to fit</button>
97
+ </div>
98
+
99
+ <BoardRoot :engine="engine" style="height: 320px" />
100
+ </div>
101
+ </template>
102
+ ````
103
+
104
+ ### app/utils/demoDocument.ts
105
+
106
+ ````ts
107
+ import type {
108
+ BoardNode,
109
+ BoardNodeMetadata,
110
+ Camera,
111
+ GridSettings,
112
+ JsonCanvasDocument,
113
+ JsonCanvasNode,
114
+ NodeId,
115
+ } from '@lupinum/board-core'
116
+
117
+ type DemoNode = Omit<
118
+ Pick<
119
+ BoardNode,
120
+ | 'id'
121
+ | 'type'
122
+ | 'x'
123
+ | 'y'
124
+ | 'width'
125
+ | 'height'
126
+ | 'color'
127
+ | 'text'
128
+ | 'file'
129
+ | 'subpath'
130
+ | 'url'
131
+ | 'label'
132
+ | 'background'
133
+ | 'backgroundStyle'
134
+ | 'zIndex'
135
+ | 'locked'
136
+ | 'visible'
137
+ | 'parentId'
138
+ >,
139
+ 'id' | 'parentId'
140
+ > & {
141
+ id: NodeId | string
142
+ parentId?: NodeId | string
143
+ }
144
+
145
+ export interface DemoDocumentInput {
146
+ nodes: readonly DemoNode[]
147
+ camera?: Camera
148
+ grid?: GridSettings
149
+ selection?: readonly (NodeId | string)[]
150
+ nextZIndex?: number
151
+ }
152
+
153
+ function toJsonCanvasNode(node: DemoNode): JsonCanvasNode {
154
+ const base = {
155
+ id: node.id as NodeId,
156
+ type: node.type,
157
+ x: node.x,
158
+ y: node.y,
159
+ width: node.width,
160
+ height: node.height,
161
+ ...(node.color ? { color: node.color } : {}),
162
+ }
163
+
164
+ switch (node.type) {
165
+ case 'file':
166
+ return {
167
+ ...base,
168
+ type: 'file',
169
+ file: node.file ?? '',
170
+ ...(node.subpath ? { subpath: node.subpath } : {}),
171
+ }
172
+ case 'link':
173
+ return {
174
+ ...base,
175
+ type: 'link',
176
+ url: node.url ?? '',
177
+ }
178
+ case 'group':
179
+ return {
180
+ ...base,
181
+ type: 'group',
182
+ ...((node.label ?? node.text)
183
+ ? { label: node.label ?? node.text }
184
+ : {}),
185
+ ...(node.background ? { background: node.background } : {}),
186
+ ...(node.backgroundStyle
187
+ ? { backgroundStyle: node.backgroundStyle }
188
+ : {}),
189
+ }
190
+ case 'text':
191
+ default:
192
+ return {
193
+ ...base,
194
+ type: 'text',
195
+ text: node.text ?? '',
196
+ }
197
+ }
198
+ }
199
+
200
+ function toNodeMetadata(node: DemoNode): BoardNodeMetadata {
201
+ return {
202
+ zIndex: node.zIndex,
203
+ locked: node.locked,
204
+ visible: node.visible,
205
+ ...(node.parentId ? { parentId: node.parentId as NodeId } : {}),
206
+ }
207
+ }
208
+
209
+ export function createDemoDocument(
210
+ input: DemoDocumentInput,
211
+ ): JsonCanvasDocument {
212
+ return {
213
+ nodes: input.nodes.map(toJsonCanvasNode),
214
+ 'x-lupinum-board': {
215
+ ...(input.camera ? { camera: input.camera } : {}),
216
+ ...(input.grid ? { grid: input.grid } : {}),
217
+ ...(input.selection
218
+ ? { selection: input.selection.map((id) => id as NodeId) }
219
+ : {}),
220
+ nextZIndex:
221
+ input.nextZIndex ??
222
+ input.nodes.reduce((max, node) => Math.max(max, node.zIndex), 0) + 1,
223
+ nodes: Object.fromEntries(
224
+ input.nodes.map((node) => [node.id, toNodeMetadata(node)]),
225
+ ),
226
+ },
227
+ }
228
+ }
229
+ ````
18
230
 
19
231
  ## What happens
20
232