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,694 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { CompositionComponent } from "../../composition/types.ts";
3
+ import { validateComposition } from "../../composition/validate.ts";
4
+ import { removeVectorElement } from "../../composition/vector.ts";
5
+ import {
6
+ addComponent,
7
+ addSlide as appendSlide,
8
+ componentRemovalIssue,
9
+ componentInstanceLabel,
10
+ componentLabels,
11
+ duplicateComponent,
12
+ getSlide,
13
+ initialDraft,
14
+ parseCompositionJSON,
15
+ removeComponent,
16
+ removeSlide as deleteSlide,
17
+ reorderPaintOrder,
18
+ transformComponentRect,
19
+ type Draft,
20
+ } from "../lib/model.ts";
21
+ import {
22
+ commitHistory,
23
+ createHistory,
24
+ finishHistoryEdit,
25
+ redoHistory,
26
+ undoHistory,
27
+ } from "../lib/history.ts";
28
+ import { clearStoredDraft, loadDraft, persistDraft } from "../lib/storage.ts";
29
+ import { diagramDefinition } from "../../composition/visualizations.ts";
30
+ import { Canvas } from "../components/Canvas.tsx";
31
+ import { LeftPanel } from "../components/LeftPanel.tsx";
32
+ import { Presentation } from "../components/Presentation.tsx";
33
+ import {
34
+ RightPanel,
35
+ type RightPanelView,
36
+ } from "../components/RightPanel.tsx";
37
+ import { WorkspaceChrome } from "../components/WorkspaceChrome.tsx";
38
+ import { exampleDraft } from "../lib/examples.ts";
39
+ import { exportPagePNG } from "../lib/export-png.ts";
40
+ import { fileSessionToken } from "../lib/file-session.ts";
41
+ import { useFileSession } from "../lib/use-file-session.ts";
42
+
43
+ const sessionToken = fileSessionToken();
44
+
45
+ function loadInitialDraft() {
46
+ if (sessionToken)
47
+ return {
48
+ draft: initialDraft(true),
49
+ storageBlocked: false,
50
+ example: false,
51
+ fileSession: true,
52
+ };
53
+ const example = exampleDraft(
54
+ new URLSearchParams(window.location.search).get("example"),
55
+ );
56
+ return example
57
+ ? { draft: example, storageBlocked: false, example: true, fileSession: false }
58
+ : { ...loadDraft(), example: false, fileSession: false };
59
+ }
60
+
61
+ export function App() {
62
+ const [loaded] = useState(loadInitialDraft);
63
+ const [history, setHistory] = useState(() =>
64
+ createHistory<{ draft: Draft; selected?: string; activeSlideId: string }>({
65
+ draft: loaded.draft,
66
+ activeSlideId: loaded.draft.slides[0].id,
67
+ }),
68
+ );
69
+ const { draft, selected, activeSlideId } = history.present;
70
+ const slide = getSlide(draft, activeSlideId);
71
+ const [blocked, setBlocked] = useState(loaded.storageBlocked);
72
+ const [error, setError] = useState(loaded.error ?? "");
73
+ const [notice, setNotice] = useState<{
74
+ message: string;
75
+ undoableFor?: Draft;
76
+ }>();
77
+ const [toastClosing, setToastClosing] = useState(false);
78
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
79
+ const [rightCollapsed, setRightCollapsed] = useState(false);
80
+ const [rightView, setRightView] = useState<RightPanelView>("settings");
81
+ const [vectorEditRequest, setVectorEditRequest] = useState(0);
82
+ const [presenting, setPresenting] = useState(false);
83
+ const [buildBusy, setBuildBusy] = useState(false);
84
+ const [exportBusy, setExportBusy] = useState(false);
85
+ const [vectorSelection, setVectorSelection] = useState<{
86
+ componentId: string;
87
+ elementId?: string;
88
+ }>();
89
+ const [titleError, setTitleError] = useState(false);
90
+ const title = useRef<HTMLInputElement>(null);
91
+ const focusAfterHistory = useRef(false);
92
+ const fileSession = useFileSession(loaded.fileSession, draft, {
93
+ onOpen: (document, name) => {
94
+ setHistory(createHistory({
95
+ draft: document,
96
+ activeSlideId: document.slides[0].id,
97
+ }));
98
+ showNotice(`${name} opened from the local service.`);
99
+ },
100
+ onExternalChange: (document) => {
101
+ setHistory((current) => commitHistory(current, {
102
+ draft: document,
103
+ activeSlideId: document.slides.some(
104
+ (candidate) => candidate.id === current.present.activeSlideId,
105
+ ) ? current.present.activeSlideId : document.slides[0].id,
106
+ selected: undefined,
107
+ }));
108
+ showNotice("Agent changes loaded. Undo restores the previous composition.");
109
+ },
110
+ onNotice: showNotice,
111
+ onError: setError,
112
+ });
113
+ useEffect(() => {
114
+ if (!focusAfterHistory.current) return;
115
+ focusAfterHistory.current = false;
116
+ const frame = requestAnimationFrame(() => {
117
+ const target = history.present.selected
118
+ ? [...document.querySelectorAll<HTMLElement>("[data-component]")]
119
+ .find(
120
+ (element) =>
121
+ element.dataset.component === history.present.selected,
122
+ )
123
+ ?.querySelector<HTMLElement>(".component-surface")
124
+ : document.querySelector<HTMLElement>("#canvas-stage");
125
+ target?.focus();
126
+ });
127
+ return () => cancelAnimationFrame(frame);
128
+ }, [history.present]);
129
+ useEffect(() => {
130
+ if (loaded.fileSession) return;
131
+ if (blocked || loaded.example) return;
132
+ if (!validateComposition(draft).ok) return;
133
+ const timer = setTimeout(() => {
134
+ try {
135
+ persistDraft(draft);
136
+ } catch {
137
+ setError(
138
+ "Unable to save this draft. Export your work or reset saved draft to retry.",
139
+ );
140
+ setBlocked(true);
141
+ }
142
+ }, 250);
143
+ return () => clearTimeout(timer);
144
+ }, [draft, blocked, loaded.example, loaded.fileSession]);
145
+ useEffect(() => {
146
+ if (!notice) return;
147
+ const timer = window.setTimeout(() => setToastClosing(true), 5000);
148
+ return () => window.clearTimeout(timer);
149
+ }, [notice]);
150
+ useEffect(() => {
151
+ if (!toastClosing) return;
152
+ const timer = window.setTimeout(() => {
153
+ setNotice(undefined);
154
+ setToastClosing(false);
155
+ }, 280);
156
+ return () => window.clearTimeout(timer);
157
+ }, [toastClosing]);
158
+ useEffect(() => {
159
+ function keydown(event: KeyboardEvent) {
160
+ if (presenting) return;
161
+ const modifier = event.metaKey || event.ctrlKey;
162
+ const target = event.target;
163
+ const editable =
164
+ target instanceof HTMLElement &&
165
+ (target.closest("input,textarea,select") || target.isContentEditable);
166
+ const slideRename =
167
+ target instanceof HTMLInputElement &&
168
+ target.getAttribute("aria-label") === "Page name";
169
+ if (event.key === "Escape" && !editable && vectorSelection) {
170
+ setVectorSelection(undefined);
171
+ return;
172
+ }
173
+ if (modifier && !event.altKey && event.key.toLowerCase() === "z") {
174
+ if (slideRename) return;
175
+ event.preventDefault();
176
+ setHistory((current) =>
177
+ event.shiftKey ? redoHistory(current) : undoHistory(current),
178
+ );
179
+ return;
180
+ }
181
+ if (
182
+ modifier &&
183
+ !event.altKey &&
184
+ event.key.toLowerCase() === "y"
185
+ ) {
186
+ if (slideRename) return;
187
+ event.preventDefault();
188
+ setHistory(redoHistory);
189
+ return;
190
+ }
191
+ if (
192
+ modifier &&
193
+ !event.altKey &&
194
+ event.key.toLowerCase() === "d" &&
195
+ selected &&
196
+ !editable
197
+ ) {
198
+ event.preventDefault();
199
+ duplicateSelected();
200
+ return;
201
+ }
202
+ if (
203
+ !["Delete", "Backspace"].includes(event.key) ||
204
+ event.ctrlKey ||
205
+ event.metaKey ||
206
+ event.altKey ||
207
+ !selected
208
+ )
209
+ return;
210
+ if (editable) return;
211
+ event.preventDefault();
212
+ deleteSelected();
213
+ }
214
+ window.addEventListener("keydown", keydown);
215
+ return () => window.removeEventListener("keydown", keydown);
216
+ }, [draft, presenting, selected, vectorSelection]);
217
+ function showNotice(message: string, undoableFor?: Draft) {
218
+ setToastClosing(false);
219
+ setNotice({ message, undoableFor });
220
+ }
221
+ function updateDraft(
222
+ next: Draft,
223
+ options: { mergeKey?: string; selected?: string; activeSlideId?: string } = {},
224
+ ) {
225
+ setHistory((current) =>
226
+ commitHistory(
227
+ current,
228
+ {
229
+ draft: next,
230
+ selected: Object.hasOwn(options, "selected")
231
+ ? options.selected
232
+ : current.present.selected,
233
+ activeSlideId: options.activeSlideId ?? current.present.activeSlideId,
234
+ },
235
+ options.mergeKey,
236
+ ),
237
+ );
238
+ }
239
+ function selectComponent(id?: string) {
240
+ if (!id || vectorSelection?.componentId !== id) setVectorSelection(undefined);
241
+ setHistory((current) => {
242
+ const next = finishHistoryEdit(current);
243
+ return { ...next, present: { ...next.present, selected: id } };
244
+ });
245
+ }
246
+ function selectSlide(id: string) {
247
+ setVectorSelection(undefined);
248
+ setHistory((current) => {
249
+ const next = finishHistoryEdit(current);
250
+ return {
251
+ ...next,
252
+ present: { ...next.present, activeSlideId: id, selected: undefined },
253
+ };
254
+ });
255
+ }
256
+ function addNewSlide() {
257
+ const result = appendSlide(draft);
258
+ if (!result.slideId) {
259
+ showNotice("Could not add a page until the composition is valid.");
260
+ return;
261
+ }
262
+ focusAfterHistory.current = true;
263
+ updateDraft(result.draft, {
264
+ activeSlideId: result.slideId,
265
+ selected: undefined,
266
+ });
267
+ showNotice("Page added.");
268
+ }
269
+ function removeExistingSlide(id: string) {
270
+ if (draft.slides.length === 1) {
271
+ showNotice("A document needs at least one page.");
272
+ return;
273
+ }
274
+ const index = draft.slides.findIndex((item) => item.id === id);
275
+ const next = deleteSlide(draft, id);
276
+ if (next === draft) return;
277
+ const nextActiveSlideId =
278
+ id === slide.id
279
+ ? next.slides[Math.min(index, next.slides.length - 1)].id
280
+ : slide.id;
281
+ focusAfterHistory.current = true;
282
+ updateDraft(next, {
283
+ activeSlideId: nextActiveSlideId,
284
+ selected: id === slide.id ? undefined : selected,
285
+ });
286
+ showNotice("Page deleted.", next);
287
+ }
288
+ function finishEdit() {
289
+ setHistory(finishHistoryEdit);
290
+ }
291
+ function performUndo() {
292
+ focusAfterHistory.current = true;
293
+ setHistory(undoHistory);
294
+ }
295
+ function performRedo() {
296
+ focusAfterHistory.current = true;
297
+ setHistory(redoHistory);
298
+ }
299
+ function updateComponent(
300
+ component: CompositionComponent,
301
+ mergeKey?: string,
302
+ ) {
303
+ const previous = slide.components.find(
304
+ (candidate) => candidate.id === component.id,
305
+ );
306
+ const nextComponent = previous && (
307
+ previous.preferredRect.x !== component.preferredRect.x ||
308
+ previous.preferredRect.y !== component.preferredRect.y ||
309
+ previous.preferredRect.width !== component.preferredRect.width ||
310
+ previous.preferredRect.height !== component.preferredRect.height
311
+ )
312
+ ? transformComponentRect(
313
+ component,
314
+ previous.preferredRect,
315
+ component.preferredRect,
316
+ )
317
+ : component;
318
+ const diagramTypeChanged =
319
+ previous?.kind === "diagram" &&
320
+ nextComponent.kind === "diagram" &&
321
+ previous.appearance.type !== nextComponent.appearance.type;
322
+ updateDraft(
323
+ {
324
+ ...draft,
325
+ slides: draft.slides.map((item) =>
326
+ item.id === slide.id
327
+ ? {
328
+ ...item,
329
+ components: item.components.map((candidate) =>
330
+ candidate.id === nextComponent.id ? nextComponent : candidate,
331
+ ),
332
+ contentSlots: item.contentSlots.map((slot) => {
333
+ if (
334
+ !nextComponent.slotIds.includes(slot.id) ||
335
+ "targets" in slot
336
+ )
337
+ return slot;
338
+ if (
339
+ diagramTypeChanged &&
340
+ nextComponent.kind === "diagram" &&
341
+ ["process-step", "entity"].includes(slot.role)
342
+ )
343
+ return {
344
+ ...slot,
345
+ role: diagramDefinition(nextComponent.appearance.type).slotRole,
346
+ };
347
+ return slot;
348
+ }),
349
+ }
350
+ : item,
351
+ ),
352
+ },
353
+ { mergeKey },
354
+ );
355
+ }
356
+ function add(
357
+ kind: CompositionComponent["kind"],
358
+ at?: { x: number; y: number },
359
+ ) {
360
+ const next = addComponent(draft, kind, at, slide.id);
361
+ if (next === draft) return;
362
+ const nextSlide = getSlide(next, slide.id);
363
+ const id = nextSlide.components.find(
364
+ (component) =>
365
+ !slide.components.some((item) => item.id === component.id),
366
+ )!.id;
367
+ focusAfterHistory.current = true;
368
+ updateDraft(next, { selected: id });
369
+ showNotice(`${componentLabels[kind]} added.`);
370
+ }
371
+ function duplicateSelected() {
372
+ if (!selected) return;
373
+ const next = duplicateComponent(draft, selected, slide.id);
374
+ if (next === draft) return;
375
+ const nextSlide = getSlide(next, slide.id);
376
+ const id = nextSlide.components.find(
377
+ (component) =>
378
+ !slide.components.some((item) => item.id === component.id),
379
+ )!.id;
380
+ focusAfterHistory.current = true;
381
+ updateDraft(next, { selected: id });
382
+ showNotice("Component duplicated.");
383
+ }
384
+ function updatePaintOrder(ids: string[]) {
385
+ const next = reorderPaintOrder(draft, ids, slide.id);
386
+ if (next === draft) return;
387
+ updateDraft(next);
388
+ }
389
+ function deleteSelected() {
390
+ if (!selected) return;
391
+ if (vectorSelection?.componentId === selected) {
392
+ const component = slide.components.find((item) => item.id === selected);
393
+ if (component?.customVisual?.format === "vector" && vectorSelection.elementId) {
394
+ updateComponent({
395
+ ...component,
396
+ customVisual: {
397
+ ...component.customVisual,
398
+ elements: removeVectorElement(component.customVisual.elements, vectorSelection.elementId),
399
+ },
400
+ });
401
+ setVectorSelection({ componentId: selected });
402
+ }
403
+ return;
404
+ }
405
+ remove(selected);
406
+ }
407
+ function remove(id: string) {
408
+ const issue = componentRemovalIssue(draft, id, slide.id);
409
+ if (issue) {
410
+ showNotice(issue);
411
+ return;
412
+ }
413
+ const index = slide.components.findIndex((component) => component.id === id);
414
+ const next = removeComponent(draft, id, slide.id);
415
+ if (next === draft) return;
416
+ const nextSelected =
417
+ selected === id
418
+ ? slide.components[index - 1]?.id ?? slide.components[index + 1]?.id
419
+ : selected;
420
+ focusAfterHistory.current = true;
421
+ updateDraft(next, { selected: nextSelected });
422
+ showNotice("Component deleted.", next);
423
+ }
424
+ async function requestBuild() {
425
+ setBuildBusy(true);
426
+ try {
427
+ const instruction = selected
428
+ ? "Review the selected component in context and build it from the current saved composition."
429
+ : "Review the current slides and build them from the current saved composition.";
430
+ if (!await fileSession.build(instruction, slide.id, selected)) return;
431
+ showNotice("Build request sent. The agent will reread this saved revision.");
432
+ } catch (requestError) {
433
+ showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.");
434
+ } finally {
435
+ setBuildBusy(false);
436
+ }
437
+ }
438
+ async function openComposition(file: File) {
439
+ let raw = "";
440
+ try {
441
+ raw = await file.text();
442
+ } catch {
443
+ showNotice("Could not read that composition file.");
444
+ return;
445
+ }
446
+ const parsed = parseCompositionJSON(raw);
447
+ if (!parsed.ok) {
448
+ showNotice(`Could not open: ${parsed.message}`);
449
+ return;
450
+ }
451
+ focusAfterHistory.current = true;
452
+ updateDraft(parsed.document, {
453
+ activeSlideId: parsed.document.slides[0].id,
454
+ selected: undefined,
455
+ });
456
+ setTitleError(false);
457
+ showNotice("Editable composition opened. Undo restores the previous document.", parsed.document);
458
+ }
459
+ function present() {
460
+ const validation = validateComposition(draft);
461
+ if (!validation.ok) {
462
+ const issue = validation.issues[0];
463
+ showNotice(
464
+ `Cannot present: ${issue?.path || "document"} ${issue?.message || "is invalid"}.`,
465
+ );
466
+ return;
467
+ }
468
+ selectComponent();
469
+ setPresenting(true);
470
+ }
471
+ async function downloadPNG() {
472
+ const source = document.querySelector<HTMLElement>(".workspace .canvas");
473
+ if (!source) return;
474
+ setExportBusy(true);
475
+ showNotice("Exporting page…");
476
+ try {
477
+ const blob = await exportPagePNG(source, slide.canvas);
478
+ const url = URL.createObjectURL(blob);
479
+ const link = document.createElement("a");
480
+ link.href = url;
481
+ link.download = `${slide.name.replace(/[^a-z0-9_-]+/gi, "-") || "konpeki-page"}.png`;
482
+ link.click();
483
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
484
+ showNotice(
485
+ `PNG exported at ${slide.canvas.width} × ${slide.canvas.height}.`,
486
+ );
487
+ } catch (exportError) {
488
+ showNotice(
489
+ `Could not export PNG: ${exportError instanceof Error ? exportError.message : "Unknown error"}`,
490
+ );
491
+ } finally {
492
+ setExportBusy(false);
493
+ }
494
+ }
495
+ function reset() {
496
+ try {
497
+ clearStoredDraft();
498
+ const next = initialDraft(true);
499
+ setHistory(
500
+ createHistory({ draft: next, activeSlideId: next.slides[0].id }),
501
+ );
502
+ setBlocked(false);
503
+ setError("");
504
+ showNotice("Saved draft reset.");
505
+ } catch {
506
+ setError("Storage remains unavailable. Export your work to keep it.");
507
+ }
508
+ }
509
+ const selectedComponent = slide.components.find((c) => c.id === selected);
510
+ function useComponentTool(kind: CompositionComponent["kind"]) {
511
+ add(kind);
512
+ }
513
+ return (
514
+ <>
515
+ <a className="skip-link" href="#canvas-stage">
516
+ Skip to canvas
517
+ </a>
518
+ {error && (
519
+ <div className="recovery" role="alert">
520
+ <span>{error}</span>
521
+ <button type="button" onClick={reset}>
522
+ Reset saved draft
523
+ </button>
524
+ </div>
525
+ )}
526
+ <div
527
+ className={`workspace ${leftCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""}`}
528
+ aria-hidden={presenting || undefined}
529
+ inert={presenting || undefined}
530
+ onDragOver={(event) => {
531
+ if (!event.dataTransfer.types.includes("Files")) return;
532
+ event.preventDefault();
533
+ event.dataTransfer.dropEffect = "copy";
534
+ }}
535
+ onDrop={(event) => {
536
+ const file = event.dataTransfer.files[0];
537
+ if (!file) return;
538
+ event.preventDefault();
539
+ void openComposition(file);
540
+ }}
541
+ >
542
+ <WorkspaceChrome
543
+ leftCollapsed={leftCollapsed}
544
+ onToggleLeftPanel={() => setLeftCollapsed(!leftCollapsed)}
545
+ title={draft.title}
546
+ titleRef={title}
547
+ titleError={titleError}
548
+ selectedKind={selectedComponent?.kind}
549
+ onTitle={(value, mergeKey) => {
550
+ updateDraft({ ...draft, title: value }, { mergeKey });
551
+ setTitleError(false);
552
+ }}
553
+ onEditEnd={finishEdit}
554
+ onExportPNG={() => { void downloadPNG(); }}
555
+ exporting={exportBusy}
556
+ onPresent={present}
557
+ fileStatus={loaded.fileSession ? fileSession.status : undefined}
558
+ building={buildBusy}
559
+ onBuild={loaded.fileSession ? () => { void requestBuild(); } : undefined}
560
+ onSelectTool={() => selectComponent()}
561
+ onComponentTool={useComponentTool}
562
+ />
563
+ <LeftPanel
564
+ draft={draft}
565
+ activeSlideId={slide.id}
566
+ collapsed={leftCollapsed}
567
+ onSelectSlide={selectSlide}
568
+ onAddSlide={addNewSlide}
569
+ onRemoveSlide={removeExistingSlide}
570
+ />
571
+ <Canvas
572
+ key={slide.id}
573
+ draft={draft}
574
+ activeSlideId={slide.id}
575
+ selected={selected}
576
+ vectorSelection={vectorSelection}
577
+ onSelect={selectComponent}
578
+ onVectorSelect={(selection, edit) => {
579
+ if (selection) {
580
+ selectComponent(selection.componentId);
581
+ setVectorSelection(selection);
582
+ setRightView("settings");
583
+ if (edit) {
584
+ setRightCollapsed(false);
585
+ setVectorEditRequest((request) => request + 1);
586
+ }
587
+ } else setVectorSelection(undefined);
588
+ }}
589
+ onSlideName={(name) =>
590
+ updateDraft({
591
+ ...draft,
592
+ slides: draft.slides.map((item) =>
593
+ item.id === slide.id ? { ...item, name } : item,
594
+ ),
595
+ })
596
+ }
597
+ onComponent={updateComponent}
598
+ onDuplicate={(sourceId, rect) => {
599
+ const next = duplicateComponent(draft, sourceId, slide.id);
600
+ if (next === draft) return;
601
+ const nextSlide = getSlide(next, slide.id);
602
+ const copy = nextSlide.components.find(candidate => !slide.components.some(existing => existing.id === candidate.id));
603
+ if (!copy) return;
604
+ const moved = transformComponentRect(copy, copy.preferredRect, rect);
605
+ nextSlide.components = nextSlide.components.map(candidate => candidate.id === copy.id ? moved : candidate);
606
+ setVectorSelection(undefined);
607
+ updateDraft(next, { selected: moved.id, mergeKey: `geometry:${moved.id}` });
608
+ return moved;
609
+ }}
610
+ onEditEnd={finishEdit}
611
+ onAdd={add}
612
+ onNotice={showNotice}
613
+ />
614
+ <RightPanel
615
+ view={rightView}
616
+ collapsed={rightCollapsed}
617
+ vectorEditRequest={vectorEditRequest}
618
+ component={selectedComponent}
619
+ selectedComponentId={selected}
620
+ selectedVectorElementId={
621
+ vectorSelection && vectorSelection.componentId === selected
622
+ ? vectorSelection.elementId
623
+ : undefined
624
+ }
625
+ componentLabel={
626
+ selected
627
+ ? componentInstanceLabel(slide.components, selected)
628
+ : undefined
629
+ }
630
+ slide={slide}
631
+ draft={draft}
632
+ onView={setRightView}
633
+ onCollapsedChange={setRightCollapsed}
634
+ onSelectSlide={() => selectComponent()}
635
+ onSelectComponent={selectComponent}
636
+ onSelectVectorElement={(elementId) =>
637
+ selected && setVectorSelection({ componentId: selected, elementId })
638
+ }
639
+ onReorderPaintOrder={updatePaintOrder}
640
+ onComponent={updateComponent}
641
+ onSlide={(nextSlide, mergeKey) =>
642
+ updateDraft(
643
+ {
644
+ ...draft,
645
+ slides: draft.slides.map((item) =>
646
+ item.id === nextSlide.id ? nextSlide : item,
647
+ ),
648
+ },
649
+ { mergeKey },
650
+ )
651
+ }
652
+ onDraft={(patch, mergeKey) =>
653
+ updateDraft({ ...draft, ...patch }, { mergeKey })
654
+ }
655
+ onEditEnd={finishEdit}
656
+ />
657
+ </div>
658
+ {presenting && (
659
+ <Presentation
660
+ draft={draft}
661
+ initialSlideId={slide.id}
662
+ onExit={() => setPresenting(false)}
663
+ />
664
+ )}
665
+ <div
666
+ className={`toast ${notice ? "visible" : ""} ${toastClosing ? "closing" : ""}`}
667
+ >
668
+ <span role="status" aria-live="polite">
669
+ {notice?.message}
670
+ </span>
671
+ {notice?.undoableFor === draft && (
672
+ <button
673
+ type="button"
674
+ onClick={() => {
675
+ performUndo();
676
+ showNotice("Change undone.");
677
+ }}
678
+ >
679
+ Undo
680
+ </button>
681
+ )}
682
+ {notice && (
683
+ <button
684
+ type="button"
685
+ aria-label="Dismiss notification"
686
+ onClick={() => setToastClosing(true)}
687
+ >
688
+ ×
689
+ </button>
690
+ )}
691
+ </div>
692
+ </>
693
+ );
694
+ }
Binary file