konpeki 0.1.0

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 (121) hide show
  1. package/.agents/skills/authoring-visuals/SKILL.md +82 -0
  2. package/AGENTS.md +33 -0
  3. package/AUTHORING.md +233 -0
  4. package/LICENSE +201 -0
  5. package/README.md +70 -0
  6. package/SETUP.md +86 -0
  7. package/composition/README.md +166 -0
  8. package/composition/compile.ts +227 -0
  9. package/composition/document.ts +709 -0
  10. package/composition/schema.json +2992 -0
  11. package/composition/schema.ts +435 -0
  12. package/composition/theme-tokens.ts +16 -0
  13. package/composition/types.ts +286 -0
  14. package/composition/validate.ts +684 -0
  15. package/composition/vector.ts +143 -0
  16. package/composition/visualizations.ts +335 -0
  17. package/design/README.md +17 -0
  18. package/design/palettes/README.md +14 -0
  19. package/design/palettes/base.ts +14 -0
  20. package/design/palettes/candidates.ts +19 -0
  21. package/design/palettes/index.ts +78 -0
  22. package/design/review/color-theme.md +44 -0
  23. package/design/review/layout.md +16 -0
  24. package/design/review/text.md +18 -0
  25. package/design/review/typography.md +15 -0
  26. package/design/review/visuals.md +31 -0
  27. package/design/semantic-patterns.md +43 -0
  28. package/design/themes/README.md +22 -0
  29. package/design/themes/index.ts +13 -0
  30. package/design/visual-languages/technical-product.md +17 -0
  31. package/design/visual-review.md +88 -0
  32. package/docs/development.md +140 -0
  33. package/docs/workflow.md +61 -0
  34. package/index.html +17 -0
  35. package/lib/assets.d.ts +8 -0
  36. package/lib/charts.ts +18 -0
  37. package/lib/contrast.ts +16 -0
  38. package/lib/layouts.ts +50 -0
  39. package/lib/slide.tsx +42 -0
  40. package/lib/taste.ts +17 -0
  41. package/lib/text.tsx +89 -0
  42. package/lib/typeface.ts +44 -0
  43. package/package.json +85 -0
  44. package/runtime/konpeki.mjs +1685 -0
  45. package/scripts/migrate-react-page.ts +120 -0
  46. package/slides/README.md +153 -0
  47. package/slides/architecture/PROMPT.md +31 -0
  48. package/slides/architecture/index.tsx +102 -0
  49. package/slides/article-brief/PROMPT.md +35 -0
  50. package/slides/article-brief/index.tsx +71 -0
  51. package/slides/bar-chart/PROMPT.md +39 -0
  52. package/slides/bar-chart/index.tsx +97 -0
  53. package/slides/comparison/PROMPT.md +29 -0
  54. package/slides/comparison/index.tsx +95 -0
  55. package/slides/decision-memo/PROMPT.md +34 -0
  56. package/slides/decision-memo/index.tsx +85 -0
  57. package/slides/delivery-plan/PROMPT.md +45 -0
  58. package/slides/delivery-plan/index.tsx +105 -0
  59. package/slides/experiment/PROMPT.md +44 -0
  60. package/slides/experiment/index.tsx +127 -0
  61. package/slides/incident-workflow/PROMPT.md +57 -0
  62. package/slides/incident-workflow/index.tsx +78 -0
  63. package/slides/introducing-konpeki/PROMPT.md +19 -0
  64. package/slides/introducing-konpeki/README.md +54 -0
  65. package/slides/introducing-konpeki/SOURCE.md +20 -0
  66. package/slides/introducing-konpeki/author.ts +163 -0
  67. package/slides/introducing-konpeki/composition.json +3265 -0
  68. package/slides/line-chart/PROMPT.md +40 -0
  69. package/slides/line-chart/index.tsx +72 -0
  70. package/slides/migration/PROMPT.md +38 -0
  71. package/slides/migration/index.tsx +89 -0
  72. package/slides/og-images/PROMPT.md +21 -0
  73. package/slides/og-images/index.tsx +76 -0
  74. package/slides/product-introduction/PROMPT.md +24 -0
  75. package/slides/product-introduction/index.tsx +105 -0
  76. package/slides/research-brief/PROMPT.md +40 -0
  77. package/slides/research-brief/index.tsx +104 -0
  78. package/slides/results-explanation/PROMPT.md +32 -0
  79. package/slides/results-explanation/index.tsx +96 -0
  80. package/slides/retrospective/PROMPT.md +43 -0
  81. package/slides/retrospective/index.tsx +105 -0
  82. package/slides/sankey/PROMPT.md +11 -0
  83. package/slides/sankey/index.tsx +93 -0
  84. package/slides/teaching/PROMPT.md +45 -0
  85. package/slides/teaching/index.tsx +124 -0
  86. package/slides/vertical-bar-charts/PROMPT.md +13 -0
  87. package/slides/vertical-bar-charts/index.tsx +97 -0
  88. package/src/app/App.tsx +694 -0
  89. package/src/assets/konpeki-mark.png +0 -0
  90. package/src/components/Canvas.tsx +1168 -0
  91. package/src/components/DiagramTypeIcon.tsx +78 -0
  92. package/src/components/InspectorPanel.tsx +687 -0
  93. package/src/components/LeftPanel.tsx +107 -0
  94. package/src/components/PageSizePicker.tsx +30 -0
  95. package/src/components/Presentation.tsx +105 -0
  96. package/src/components/RightPanel.tsx +201 -0
  97. package/src/components/VectorOverflowWarning.tsx +46 -0
  98. package/src/components/WorkspaceChrome.tsx +199 -0
  99. package/src/components/ui.tsx +53 -0
  100. package/src/lib/examples/react-page-migration.json +1295 -0
  101. package/src/lib/examples.ts +42 -0
  102. package/src/lib/export-png.ts +101 -0
  103. package/src/lib/file-session.ts +74 -0
  104. package/src/lib/history.ts +53 -0
  105. package/src/lib/model.ts +188 -0
  106. package/src/lib/page-size.ts +24 -0
  107. package/src/lib/presentation.ts +17 -0
  108. package/src/lib/storage.ts +43 -0
  109. package/src/lib/theme.ts +25 -0
  110. package/src/lib/use-file-session.ts +162 -0
  111. package/src/main.tsx +26 -0
  112. package/src/styles/base.css +105 -0
  113. package/src/styles/canvas.css +268 -0
  114. package/src/styles/chrome.css +214 -0
  115. package/src/styles/component-previews.css +386 -0
  116. package/src/styles/feedback.css +71 -0
  117. package/src/styles/left-panel.css +125 -0
  118. package/src/styles/presentation.css +72 -0
  119. package/src/styles/right-panel.css +1172 -0
  120. package/src/styles/shell.css +247 -0
  121. package/vite.config.ts +6 -0
@@ -0,0 +1,709 @@
1
+ import {
2
+ canvasPadding,
3
+ canvasSize,
4
+ compositionSchema,
5
+ type CanvasSize,
6
+ type CompositionComponent,
7
+ type CompositionDocument,
8
+ type CompositionSlide,
9
+ type ContentSlot,
10
+ type Rect,
11
+ type SemanticRelationship,
12
+ type ThemeId,
13
+ type AuthoringMode,
14
+ } from "./types.ts";
15
+ import { validateComposition } from "./validate.ts";
16
+ import { appearanceOptions } from "./schema.ts";
17
+ import { diagramDefinition } from "./visualizations.ts";
18
+
19
+ export type Draft = CompositionDocument;
20
+ type LegacyDraft = {
21
+ title: string;
22
+ audience: string;
23
+ question: string;
24
+ themeId: ThemeId;
25
+ themeMode: "paper" | "night";
26
+ authoringMode?: AuthoringMode;
27
+ components: CompositionComponent[];
28
+ readingOrder: string[];
29
+ paintOrder: string[];
30
+ relationships: SemanticRelationship[];
31
+ };
32
+ export type StoredDraftResult =
33
+ { ok: true; draft: Draft; migrated: boolean } | { ok: false };
34
+ export type ParsedComposition =
35
+ | { ok: true; document: CompositionDocument }
36
+ | { ok: false; message: string };
37
+ export const componentLabels = {
38
+ "text-block": "Text block",
39
+ chart: "Chart",
40
+ diagram: "Diagram",
41
+ image: "Image",
42
+ table: "Table",
43
+ };
44
+ export function defaultAppearance(kind: CompositionComponent["kind"]) {
45
+ const appearance = Object.fromEntries(
46
+ Object.entries(appearanceOptions[kind]).map(([key, options]) => [
47
+ key,
48
+ options[0],
49
+ ]),
50
+ );
51
+ if (kind === "text-block") {
52
+ appearance.role = "body";
53
+ appearance.titleStyle = "plain";
54
+ appearance.purpose = "narrative";
55
+ appearance.treatment = "plain";
56
+ }
57
+ if (kind === "chart") {
58
+ appearance.orientation = "vertical";
59
+ appearance.colorScheme = "accent-with-muted-context";
60
+ appearance.emphasis = "primary";
61
+ appearance.legend = "top";
62
+ }
63
+ if (kind === "diagram") {
64
+ appearance.type = "process";
65
+ }
66
+ if (kind === "table") delete appearance.colorScheme;
67
+ return appearance;
68
+ }
69
+ const defaultRects: Record<CompositionComponent["kind"], Rect> = {
70
+ "text-block": { x: canvasPadding.left, y: 280, width: 560, height: 420 },
71
+ chart: { x: canvasPadding.left, y: 280, width: 1080, height: 530 },
72
+ diagram: {
73
+ x: canvasPadding.left,
74
+ y: 320,
75
+ width: canvasSize.width - canvasPadding.left - canvasPadding.right,
76
+ height: 420,
77
+ },
78
+ image: { x: canvasPadding.left, y: 260, width: 880, height: 540 },
79
+ table: { x: canvasPadding.left, y: 280, width: 1080, height: 530 },
80
+ };
81
+ const instructions = {
82
+ "text-block": "Explain the supporting idea in concise language.",
83
+ chart: "Show the data evidence that supports the takeaway.",
84
+ diagram: "Show the entities or sequence and their relationships.",
85
+ image: "Describe the image to use and its purpose.",
86
+ table: "Describe the comparison, exact cell values, and reading order for the table.",
87
+ };
88
+ function isFootnoteText(component: CompositionComponent) {
89
+ return component.kind === "text-block" && component.appearance.role === "footnote";
90
+ }
91
+ function clamp(value: number, minimum: number, maximum: number) {
92
+ return Math.min(maximum, Math.max(minimum, value));
93
+ }
94
+ export function createComponent(
95
+ kind: CompositionComponent["kind"],
96
+ index: number,
97
+ at?: { x: number; y: number },
98
+ size: CanvasSize = canvasSize,
99
+ ): CompositionComponent {
100
+ const id = `${kind}-${index}`;
101
+ const original = defaultRects[kind];
102
+ const scale = Math.min(1, size.width / canvasSize.width, size.height / canvasSize.height);
103
+ const rect = { x: original.x * scale, y: original.y * scale, width: original.width * scale, height: original.height * scale };
104
+ const preferredRect = at
105
+ ? {
106
+ ...rect,
107
+ x: clamp(at.x - rect.width / 2, 0, size.width - rect.width),
108
+ y: clamp(at.y - 60, 0, size.height - rect.height),
109
+ }
110
+ : { ...rect };
111
+ const appearance = defaultAppearance(kind);
112
+ return {
113
+ id,
114
+ kind,
115
+ preferredRect,
116
+ slotIds: [`${id}-content`],
117
+ intent: instructions[kind],
118
+ ...(kind === "text-block" ? { content: "Text", textStyle: { size: 36, weight: 400, lineHeight: 1.4, color: "ink", font: "body" } } : {}),
119
+ appearance,
120
+ } as CompositionComponent;
121
+ }
122
+ function createStarterSlide(index = 1): CompositionSlide {
123
+ const components = (["text-block", "chart", "text-block", "text-block"] as const)
124
+ .map((kind, index) => createComponent(kind, index + 1));
125
+ const title = components[0];
126
+ if (title.kind === "text-block") {
127
+ title.preferredRect = {
128
+ x: canvasPadding.left,
129
+ y: canvasPadding.top,
130
+ width: canvasSize.width - canvasPadding.left - canvasPadding.right,
131
+ height: 88,
132
+ };
133
+ title.intent = "State the decision or takeaway.";
134
+ title.appearance = {
135
+ ...title.appearance,
136
+ role: "title",
137
+ titleStyle: "prominent",
138
+ rule: "bottom",
139
+ };
140
+ }
141
+ const emphasis = components[2];
142
+ if (emphasis.kind === "text-block") {
143
+ emphasis.preferredRect.x = 1248;
144
+ emphasis.intent = "Explain the implication or recommended action.";
145
+ emphasis.appearance = {
146
+ ...emphasis.appearance,
147
+ purpose: "emphasis",
148
+ treatment: "strong",
149
+ };
150
+ }
151
+ const footnote = components[3];
152
+ if (footnote.kind === "text-block") {
153
+ footnote.preferredRect = {
154
+ x: canvasPadding.left,
155
+ y: canvasSize.height - canvasPadding.bottom - 88,
156
+ width: canvasSize.width - canvasPadding.left - canvasPadding.right,
157
+ height: 88,
158
+ };
159
+ footnote.intent = "Add the source, scope, and any important caveat.";
160
+ footnote.appearance = {
161
+ ...footnote.appearance,
162
+ role: "footnote",
163
+ rule: "top",
164
+ };
165
+ }
166
+ const contentSlots = components.flatMap(createContentSlots).map((slot) =>
167
+ slot.role === "source"
168
+ ? {
169
+ ...slot,
170
+ targets: components
171
+ .filter((component) => !isFootnoteText(component))
172
+ .map((component) => component.id),
173
+ }
174
+ : slot,
175
+ );
176
+ return {
177
+ id: `slide-${index}`,
178
+ name: `Slide ${String(index).padStart(2, "0")}`,
179
+ canvas: canvasSize,
180
+ innerPadding: canvasPadding,
181
+ pageNumber: { style: "none", color: "muted" },
182
+ audience: "Decision makers",
183
+ question: "What should the audience understand or decide?",
184
+ intendedViewingSize: "presentation",
185
+ contentSlots,
186
+ components,
187
+ groups: [],
188
+ readingOrder: components.map((component) => ({
189
+ kind: "component",
190
+ id: component.id,
191
+ })),
192
+ paintOrder: components.map((component) => component.id),
193
+ relationships: [],
194
+ };
195
+ }
196
+ export function createBlankSlide(index = 1, size: CanvasSize = canvasSize): CompositionSlide {
197
+ return {
198
+ id: `slide-${index}`,
199
+ name: `Page ${String(index).padStart(2, "0")}`,
200
+ canvas: { width: size.width, height: size.height },
201
+ innerPadding: canvasPadding,
202
+ pageNumber: { style: "none", color: "muted" },
203
+ audience: "Decision makers",
204
+ question: "What should the audience understand or decide?",
205
+ intendedViewingSize: "presentation",
206
+ contentSlots: [],
207
+ components: [],
208
+ groups: [],
209
+ readingOrder: [],
210
+ paintOrder: [],
211
+ relationships: [],
212
+ };
213
+ }
214
+ export function initialDraft(empty = false): Draft {
215
+ return {
216
+ schema: compositionSchema,
217
+ title: "Untitled composition",
218
+ theme: { id: "plex", mode: "paper" },
219
+ slides: [empty ? createBlankSlide() : createStarterSlide()],
220
+ };
221
+ }
222
+ export function getSlide(draft: Draft, id = draft.slides[0]?.id) {
223
+ const slide = draft.slides.find((item) => item.id === id);
224
+ if (!slide) throw new Error(`Unknown slide: ${id}`);
225
+ return slide;
226
+ }
227
+ export function addSlide(draft: Draft): { draft: Draft; slideId: string } {
228
+ const used = new Set(draft.slides.map((slide) => slide.id));
229
+ let index = draft.slides.length + 1;
230
+ while (used.has(`slide-${index}`)) index += 1;
231
+ const slide = createBlankSlide(index);
232
+ const next = { ...draft, slides: [...draft.slides, slide] };
233
+ const validated = validMutation(draft, next);
234
+ if (validated === draft) return { draft, slideId: "" };
235
+ return {
236
+ draft: validated,
237
+ slideId: slide.id,
238
+ };
239
+ }
240
+ export function removeSlide(draft: Draft, slideId: string): Draft {
241
+ if (draft.slides.length === 1) return draft;
242
+ const next = {
243
+ ...draft,
244
+ slides: draft.slides.filter((slide) => slide.id !== slideId),
245
+ };
246
+ if (next.slides.length === draft.slides.length) return draft;
247
+ return validMutation(draft, next);
248
+ }
249
+ export function createContentSlots(
250
+ component: CompositionComponent,
251
+ ): ContentSlot[] {
252
+ return component.slotIds.map((id, index): ContentSlot => {
253
+ const base = {
254
+ id,
255
+ label: componentLabels[component.kind] + (index ? ` ${index + 1}` : ""),
256
+ required: true,
257
+ instruction: component.intent?.trim() || instructions[component.kind],
258
+ };
259
+ if (isFootnoteText(component))
260
+ return { ...base, role: "source", targets: [] };
261
+ const roles = {
262
+ chart: "evidence",
263
+ diagram:
264
+ component.kind === "diagram"
265
+ ? diagramDefinition(component.appearance.type).slotRole
266
+ : "entity",
267
+ "text-block": component.kind === "text-block" && component.appearance.role === "title"
268
+ ? "takeaway"
269
+ : "body",
270
+ image: "image",
271
+ table: "table",
272
+ } as const;
273
+ return { ...base, role: roles[component.kind] };
274
+ });
275
+ }
276
+ function uniqueId(base: string, used: Set<string>) {
277
+ if (!used.has(base)) return base;
278
+ let index = 2;
279
+ while (used.has(`${base}-${index}`)) index += 1;
280
+ return `${base}-${index}`;
281
+ }
282
+ function offsetRect(rect: Rect, size: CanvasSize) {
283
+ const maximumX = size.width - rect.width;
284
+ const maximumY = size.height - rect.height;
285
+ const x = rect.x + 48 <= maximumX ? rect.x + 48 : Math.max(0, rect.x - 48);
286
+ const y = rect.y + 48 <= maximumY ? rect.y + 48 : Math.max(0, rect.y - 48);
287
+ return { ...rect, x, y };
288
+ }
289
+ export function transformComponentRect(
290
+ component: CompositionComponent,
291
+ previousRect: Rect,
292
+ nextRect: Rect,
293
+ ): CompositionComponent {
294
+ const next = structuredClone(component);
295
+ next.preferredRect = { ...nextRect };
296
+ if ((next.kind !== "diagram" && next.kind !== "chart") || !next.topology) return next;
297
+ const scaleX = nextRect.width / previousRect.width;
298
+ const scaleY = nextRect.height / previousRect.height;
299
+ next.topology.nodes = next.topology.nodes.map((node) => {
300
+ if (!node.preferredRect) return node;
301
+ return {
302
+ ...node,
303
+ preferredRect: {
304
+ x: nextRect.x + (node.preferredRect.x - previousRect.x) * scaleX,
305
+ y: nextRect.y + (node.preferredRect.y - previousRect.y) * scaleY,
306
+ width: node.preferredRect.width * scaleX,
307
+ height: node.preferredRect.height * scaleY,
308
+ },
309
+ };
310
+ });
311
+ return next;
312
+ }
313
+ function validMutation(draft: Draft, next: Draft) {
314
+ const candidate = next.title.trim()
315
+ ? next
316
+ : { ...next, title: "Untitled composition" };
317
+ return validateComposition(candidate).ok ? next : draft;
318
+ }
319
+ export function addComponent(
320
+ draft: Draft,
321
+ kind: CompositionComponent["kind"],
322
+ at?: { x: number; y: number },
323
+ slideId?: string,
324
+ ): Draft {
325
+ const slide = getSlide(draft, slideId);
326
+ let index =
327
+ Math.max(
328
+ 0,
329
+ ...slide.components.map((c) =>
330
+ Number(c.id.match(/-(\d+)$/)?.[1] ?? 0),
331
+ ),
332
+ ) + 1;
333
+ const usedIds = new Set([
334
+ ...slide.components.map((component) => component.id),
335
+ ...slide.contentSlots.map((slot) => slot.id),
336
+ ...slide.groups.map((group) => group.id),
337
+ ]);
338
+ while (
339
+ usedIds.has(`${kind}-${index}`) ||
340
+ usedIds.has(`${kind}-${index}-content`)
341
+ )
342
+ index += 1;
343
+ const component = createComponent(kind, index, at, slide.canvas);
344
+ const previous = [...slide.components]
345
+ .reverse()
346
+ .find((item) =>
347
+ item.kind === kind &&
348
+ (item.kind !== "text-block" ||
349
+ component.kind !== "text-block" ||
350
+ item.appearance.role === component.appearance.role),
351
+ );
352
+ if (!at && previous)
353
+ component.preferredRect = offsetRect(previous.preferredRect, slide.canvas);
354
+ const readingOrder = [...slide.readingOrder];
355
+ readingOrder.push({ kind: "component", id: component.id });
356
+ const contentSlots = [
357
+ ...slide.contentSlots,
358
+ ...createContentSlots(component).map((slot) =>
359
+ slot.role === "source"
360
+ ? {
361
+ ...slot,
362
+ targets: slide.components
363
+ .filter((item) => !isFootnoteText(item))
364
+ .map((item) => item.id),
365
+ }
366
+ : slot,
367
+ ),
368
+ ];
369
+ const next = {
370
+ ...draft,
371
+ slides: draft.slides.map((item) =>
372
+ item.id === slide.id
373
+ ? {
374
+ ...item,
375
+ contentSlots,
376
+ components: [...item.components, component],
377
+ readingOrder,
378
+ paintOrder: [...item.paintOrder, component.id],
379
+ }
380
+ : item,
381
+ ),
382
+ };
383
+ return validMutation(draft, next);
384
+ }
385
+ export function componentRemovalIssue(draft: Draft, id: string, slideId?: string) {
386
+ const slide = getSlide(draft, slideId);
387
+ const removed = slide.components.find((component) => component.id === id);
388
+ if (!removed) return "Component not found.";
389
+ const survivingOwners = new Set(
390
+ slide.components
391
+ .filter((component) => component.id !== id)
392
+ .flatMap((component) => component.slotIds),
393
+ );
394
+ const orphanedSlots = new Set(
395
+ removed.slotIds.filter((slotId) => !survivingOwners.has(slotId)),
396
+ );
397
+ const blockedReference = slide.contentSlots.find(
398
+ (slot) =>
399
+ "targets" in slot &&
400
+ !orphanedSlots.has(slot.id) &&
401
+ slot.targets.filter(
402
+ (target) => target !== id && !orphanedSlots.has(target),
403
+ ).length === 0,
404
+ );
405
+ return blockedReference
406
+ ? `Cannot delete while “${blockedReference.label}” only targets this component.`
407
+ : undefined;
408
+ }
409
+ export function removeComponent(draft: Draft, id: string, slideId?: string): Draft {
410
+ const slide = getSlide(draft, slideId);
411
+ const removed = slide.components.find((component) => component.id === id);
412
+ if (!removed || componentRemovalIssue(draft, id, slide.id)) return draft;
413
+ const survivingSlots = new Set(
414
+ slide.components
415
+ .filter((component) => component.id !== id)
416
+ .flatMap((component) => component.slotIds),
417
+ );
418
+ const removedSlots = new Set(
419
+ removed.slotIds.filter((slotId) => !survivingSlots.has(slotId)),
420
+ );
421
+ const groups = slide.groups
422
+ .map((group) => ({
423
+ ...group,
424
+ childIds: group.childIds.filter((childId) => childId !== id),
425
+ }))
426
+ .filter((group) => group.childIds.length);
427
+ const groupIds = new Set(groups.map((group) => group.id));
428
+ const next = {
429
+ ...draft,
430
+ slides: draft.slides.map((item) =>
431
+ item.id === slide.id
432
+ ? {
433
+ ...item,
434
+ contentSlots: item.contentSlots
435
+ .filter((slot) => !removedSlots.has(slot.id))
436
+ .map((slot) =>
437
+ "targets" in slot
438
+ ? {
439
+ ...slot,
440
+ targets: slot.targets.filter(
441
+ (target) =>
442
+ target !== id && !removedSlots.has(target),
443
+ ),
444
+ }
445
+ : slot,
446
+ ),
447
+ components: item.components.filter((c) => c.id !== id),
448
+ groups,
449
+ readingOrder: item.readingOrder.filter((entry) =>
450
+ entry.kind === "component"
451
+ ? entry.id !== id
452
+ : groupIds.has(entry.id),
453
+ ),
454
+ paintOrder: item.paintOrder.filter((item) => item !== id),
455
+ relationships: item.relationships.filter(
456
+ (link) => link.from.nodeId !== id && link.to.nodeId !== id,
457
+ ),
458
+ }
459
+ : item,
460
+ ),
461
+ };
462
+ return validMutation(draft, next);
463
+ }
464
+ export function reorderPaintOrder(
465
+ draft: Draft,
466
+ paintOrder: string[],
467
+ slideId?: string,
468
+ ): Draft {
469
+ const slide = getSlide(draft, slideId);
470
+ if (
471
+ paintOrder.length !== slide.components.length ||
472
+ new Set(paintOrder).size !== paintOrder.length ||
473
+ paintOrder.some((id) => !slide.components.some((component) => component.id === id))
474
+ )
475
+ return draft;
476
+ return validMutation(draft, {
477
+ ...draft,
478
+ slides: draft.slides.map((item) =>
479
+ item.id === slide.id ? { ...item, paintOrder: [...paintOrder] } : item,
480
+ ),
481
+ });
482
+ }
483
+ export function duplicateComponent(draft: Draft, id: string, slideId?: string): Draft {
484
+ const slide = getSlide(draft, slideId);
485
+ const source = slide.components.find((component) => component.id === id);
486
+ if (!source) return draft;
487
+ const used = new Set([
488
+ ...slide.components.map((component) => component.id),
489
+ ...slide.contentSlots.map((slot) => slot.id),
490
+ ...slide.groups.map((group) => group.id),
491
+ ]);
492
+ const componentId = uniqueId(`${source.id}-copy`, used);
493
+ used.add(componentId);
494
+ const slotIds = source.slotIds.map((_, index) => {
495
+ const slotId = uniqueId(`${componentId}-content${index ? `-${index + 1}` : ""}`, used);
496
+ used.add(slotId);
497
+ return slotId;
498
+ });
499
+ const slotMap = new Map(source.slotIds.map((slotId, index) => [slotId, slotIds[index]]));
500
+ const preferredRect = offsetRect(source.preferredRect, slide.canvas);
501
+ const component = transformComponentRect(
502
+ source,
503
+ source.preferredRect,
504
+ preferredRect,
505
+ );
506
+ component.id = componentId;
507
+ component.slotIds = slotIds;
508
+ if ((component.kind === "diagram" || component.kind === "chart") && component.topology)
509
+ component.topology.nodes = component.topology.nodes.map((node) => ({
510
+ ...node,
511
+ slotId: slotMap.get(node.slotId) ?? node.slotId,
512
+ }));
513
+ const sourceSlots = new Map(
514
+ slide.contentSlots.map((slot) => [slot.id, slot]),
515
+ );
516
+ const contentSlots = source.slotIds.map((slotId, index) => {
517
+ const slot = structuredClone(sourceSlots.get(slotId)!);
518
+ slot.id = slotIds[index];
519
+ if ("targets" in slot)
520
+ slot.targets = slot.targets.map((target) =>
521
+ target === source.id ? componentId : slotMap.get(target) ?? target,
522
+ );
523
+ return slot;
524
+ });
525
+ const allSlots = slide.contentSlots.map((slot) =>
526
+ slot.role === "source" && slot.targets.includes(source.id)
527
+ ? {
528
+ ...slot,
529
+ targets: slot.targets.flatMap((target) =>
530
+ target === source.id ? [target, componentId] : [target],
531
+ ),
532
+ }
533
+ : slot,
534
+ );
535
+ const slotInsert = Math.max(
536
+ ...source.slotIds.map((slotId) => allSlots.findIndex((slot) => slot.id === slotId)),
537
+ );
538
+ allSlots.splice(slotInsert + 1, 0, ...contentSlots);
539
+ const components = [...slide.components];
540
+ components.splice(components.indexOf(source) + 1, 0, component);
541
+ const groups = slide.groups.map((group) => {
542
+ const sourceIndex = group.childIds.indexOf(id);
543
+ if (sourceIndex < 0) return group;
544
+ const childIds = [...group.childIds];
545
+ childIds.splice(sourceIndex + 1, 0, componentId);
546
+ return { ...group, childIds };
547
+ });
548
+ const readingOrder = [...slide.readingOrder];
549
+ const sourceIndex = readingOrder.findIndex(
550
+ (entry) => entry.kind === "component" && entry.id === id,
551
+ );
552
+ if (sourceIndex >= 0) {
553
+ readingOrder.splice(sourceIndex + 1, 0, {
554
+ kind: "component",
555
+ id: componentId,
556
+ });
557
+ }
558
+ const paintOrder = [...slide.paintOrder];
559
+ paintOrder.splice(paintOrder.indexOf(id) + 1, 0, componentId);
560
+ const next = {
561
+ ...draft,
562
+ slides: draft.slides.map((item) =>
563
+ item.id === slide.id
564
+ ? {
565
+ ...item,
566
+ contentSlots: allSlots,
567
+ components,
568
+ groups,
569
+ readingOrder,
570
+ paintOrder,
571
+ }
572
+ : item,
573
+ ),
574
+ };
575
+ return validMutation(draft, next);
576
+ }
577
+ function legacyToComposition(draft: LegacyDraft): unknown {
578
+ const components = draft.components;
579
+ const rawComponents = components as unknown as Array<
580
+ {
581
+ id: string;
582
+ kind: string;
583
+ slotIds: string[];
584
+ appearance?: { role?: string };
585
+ intent?: string;
586
+ }
587
+ >;
588
+ const contentSlots = rawComponents.flatMap((component) =>
589
+ component.slotIds.map((id) => {
590
+ const kind = component.kind;
591
+ const source =
592
+ kind === "footnote" ||
593
+ (kind === "text-block" && component.appearance?.role === "footnote");
594
+ const role = source
595
+ ? "source"
596
+ : kind === "headline" ||
597
+ (kind === "text-block" && component.appearance?.role === "title")
598
+ ? "takeaway"
599
+ : ["visual", "evidence", "chart"].includes(kind)
600
+ ? "evidence"
601
+ : kind === "image"
602
+ ? "image"
603
+ : kind === "table"
604
+ ? "table"
605
+ : ["diagram", "process"].includes(kind)
606
+ ? "process-step"
607
+ : kind === "text-block"
608
+ ? "body"
609
+ : "entity";
610
+ const base = {
611
+ id,
612
+ label: componentLabels[component.kind as CompositionComponent["kind"]] ??
613
+ kind.replaceAll("-", " "),
614
+ required: true,
615
+ instruction: component.intent?.trim() || `Describe ${kind.replaceAll("-", " ")} content.`,
616
+ };
617
+ return role === "source"
618
+ ? {
619
+ ...base,
620
+ role,
621
+ targets: rawComponents
622
+ .filter((candidate) => candidate.id !== component.id)
623
+ .map((candidate) => candidate.id),
624
+ }
625
+ : { ...base, role };
626
+ }),
627
+ );
628
+ return {
629
+ schema: "konpeki-composition/v3",
630
+ title: draft.title,
631
+ ...(draft.authoringMode ? { authoringMode: draft.authoringMode } : {}),
632
+ theme: { id: draft.themeId, mode: draft.themeMode },
633
+ slide: {
634
+ id: "slide-1",
635
+ canvas: canvasSize,
636
+ innerPadding: canvasPadding,
637
+ audience: draft.audience,
638
+ question: draft.question,
639
+ intendedViewingSize: "presentation",
640
+ contentSlots,
641
+ components,
642
+ groups: [],
643
+ readingOrder: draft.readingOrder.map((id) => ({ kind: "component", id })),
644
+ paintOrder: draft.paintOrder,
645
+ relationships: draft.relationships,
646
+ },
647
+ };
648
+ }
649
+ export function serializeDraft(draft: Draft) {
650
+ return JSON.stringify({ version: 2, document: draft });
651
+ }
652
+ export function parseStoredDraft(raw: string): StoredDraftResult {
653
+ try {
654
+ const parsed = JSON.parse(raw);
655
+ if (parsed?.version === 2) {
656
+ const document = parsed.document as CompositionDocument;
657
+ if (typeof document?.title !== "string") return { ok: false };
658
+ const candidate = {
659
+ ...document,
660
+ title: document.title.trim() || "Untitled composition",
661
+ };
662
+ const result = validateComposition(candidate);
663
+ if (!result.ok) return { ok: false };
664
+ return {
665
+ ok: true,
666
+ draft: { ...result.document, title: document.title },
667
+ migrated: result.document.schema !== document.schema,
668
+ };
669
+ }
670
+ const legacy = (parsed?.version === 1 ? parsed.draft : parsed) as LegacyDraft;
671
+ if (
672
+ !legacy ||
673
+ typeof legacy.title !== "string" ||
674
+ typeof legacy.audience !== "string" ||
675
+ typeof legacy.question !== "string" ||
676
+ !Array.isArray(legacy.readingOrder) ||
677
+ !legacy.readingOrder.every((id: unknown) => typeof id === "string")
678
+ )
679
+ return { ok: false };
680
+ const document = legacyToComposition(legacy) as { title: string };
681
+ const result = validateComposition({
682
+ ...document,
683
+ title: document.title.trim() || "Untitled composition",
684
+ });
685
+ if (!result.ok)
686
+ return { ok: false };
687
+ return {
688
+ ok: true,
689
+ draft: { ...result.document, title: document.title },
690
+ migrated: true,
691
+ };
692
+ } catch {
693
+ return { ok: false };
694
+ }
695
+ }
696
+ export function parseCompositionJSON(raw: string): ParsedComposition {
697
+ try {
698
+ const input: unknown = JSON.parse(raw);
699
+ const result = validateComposition(input);
700
+ if (result.ok) return { ok: true, document: result.document };
701
+ const issue = result.issues[0];
702
+ return {
703
+ ok: false,
704
+ message: `${issue?.path || "Document"}: ${issue?.message || "Invalid composition"}`,
705
+ };
706
+ } catch {
707
+ return { ok: false, message: "The selected file is not valid JSON." };
708
+ }
709
+ }