konpeki 0.2.0 → 0.3.1

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 (52) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.md +81 -28
  4. package/SECURITY.md +22 -0
  5. package/SETUP.md +128 -51
  6. package/composition/README.md +16 -3
  7. package/composition/compile.ts +1 -1
  8. package/composition/schema.json +9 -0
  9. package/composition/schema.ts +3 -1
  10. package/composition/types.ts +3 -1
  11. package/design/themes/README.md +25 -7
  12. package/design/themes/index.ts +15 -4
  13. package/docs/development.md +79 -11
  14. package/docs/workflow.md +11 -4
  15. package/index.html +17 -1
  16. package/package.json +10 -2
  17. package/plugin.json +22 -0
  18. package/public/og.png +0 -0
  19. package/runtime/konpeki.mjs +41 -13
  20. package/skills/konpeki/SKILL.md +186 -0
  21. package/skills/konpeki/assets/blank.json +23 -0
  22. package/skills/konpeki/scripts/ensure-runtime.mjs +69 -0
  23. package/skills/konpeki/scripts/prepare-document.mjs +40 -0
  24. package/slides/README.md +35 -132
  25. package/slides/drawing-references.md +32 -0
  26. package/slides/github-cover/PROMPT.md +21 -0
  27. package/slides/github-cover/README.md +21 -0
  28. package/slides/github-cover/author.ts +67 -0
  29. package/slides/github-cover/composition.json +595 -0
  30. package/slides/introducing-konpeki/PROMPT.md +25 -14
  31. package/slides/introducing-konpeki/README.md +32 -42
  32. package/slides/introducing-konpeki/SOURCE.md +13 -14
  33. package/slides/introducing-konpeki/author.ts +44 -42
  34. package/slides/introducing-konpeki/composition.json +135 -135
  35. package/src/app/App.tsx +149 -56
  36. package/src/components/Canvas.tsx +9 -3
  37. package/src/components/InspectorPanel.tsx +104 -67
  38. package/src/components/RevisionNotes.tsx +6 -7
  39. package/src/components/RightPanel.tsx +42 -10
  40. package/src/components/VectorOverflowWarning.tsx +35 -8
  41. package/src/components/WorkspaceChrome.tsx +125 -10
  42. package/src/components/ui.tsx +95 -1
  43. package/src/lib/storage.ts +38 -10
  44. package/src/lib/use-file-session.ts +60 -38
  45. package/src/main.tsx +3 -0
  46. package/src/styles/base.css +7 -0
  47. package/src/styles/chrome.css +188 -25
  48. package/src/styles/feedback.css +101 -49
  49. package/src/styles/left-panel.css +27 -6
  50. package/src/styles/right-panel.css +114 -30
  51. package/src/styles/shell.css +96 -33
  52. package/.agents/skills/authoring-visuals/SKILL.md +0 -93
package/src/app/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { CompositionComponent } from "../../composition/types.ts";
3
3
  import { validateComposition } from "../../composition/validate.ts";
4
4
  import { removeVectorElement } from "../../composition/vector.ts";
@@ -25,7 +25,15 @@ import {
25
25
  redoHistory,
26
26
  undoHistory,
27
27
  } from "../lib/history.ts";
28
- import { clearStoredDraft, loadDraft, persistDraft } from "../lib/storage.ts";
28
+ import {
29
+ clearStoredDraft,
30
+ clearStoredExampleDraft,
31
+ loadDraft,
32
+ loadExampleDraft,
33
+ persistDraft,
34
+ persistExampleDraft,
35
+ } from "../lib/storage.ts";
36
+ import { canonicalJSON } from "../../composition/compile.ts";
29
37
  import { diagramDefinition } from "../../composition/visualizations.ts";
30
38
  import { Canvas } from "../components/Canvas.tsx";
31
39
  import { BuildOrb } from "../components/BuildOrb.tsx";
@@ -36,6 +44,7 @@ import {
36
44
  type RightPanelView,
37
45
  } from "../components/RightPanel.tsx";
38
46
  import { WorkspaceChrome } from "../components/WorkspaceChrome.tsx";
47
+ import { FeedbackNotice } from "../components/ui.tsx";
39
48
  import { exampleDraft } from "../lib/examples.ts";
40
49
  import { exportPagePNG } from "../lib/export-png.ts";
41
50
  import { fileSessionToken } from "../lib/file-session.ts";
@@ -50,15 +59,18 @@ function loadInitialDraft() {
50
59
  return {
51
60
  draft: initialDraft(true),
52
61
  storageBlocked: false,
53
- example: false,
62
+ exampleName: undefined,
54
63
  fileSession: true,
55
64
  };
56
- const example = exampleDraft(
57
- new URLSearchParams(window.location.search).get("example"),
58
- );
65
+ const exampleName = new URLSearchParams(window.location.search).get("example");
66
+ const example = exampleDraft(exampleName);
59
67
  return example
60
- ? { draft: example, storageBlocked: false, example: true, fileSession: false }
61
- : { ...loadDraft(), example: false, fileSession: false };
68
+ ? {
69
+ ...loadExampleDraft(exampleName!, example),
70
+ exampleName: exampleName!,
71
+ fileSession: false,
72
+ }
73
+ : { ...loadDraft(), exampleName: undefined, fileSession: false };
62
74
  }
63
75
 
64
76
  export function App() {
@@ -71,13 +83,16 @@ export function App() {
71
83
  );
72
84
  const { draft, selected, activeSlideId } = history.present;
73
85
  const slide = getSlide(draft, activeSlideId);
86
+ const validation = useMemo(() => validateComposition(draft), [draft]);
87
+ const [savedDraft, setSavedDraft] = useState<Draft>();
74
88
  const [blocked, setBlocked] = useState(loaded.storageBlocked);
89
+ const [requiresReset, setRequiresReset] = useState(loaded.storageBlocked);
75
90
  const [error, setError] = useState(loaded.error ?? "");
76
91
  const [notice, setNotice] = useState<{
77
92
  message: string;
78
93
  tone: "neutral" | "error";
79
94
  }>();
80
- const [toastClosing, setToastClosing] = useState(false);
95
+ const [toastPaused, setToastPaused] = useState(false);
81
96
  const [leftCollapsed, setLeftCollapsed] = useState(false);
82
97
  const [leftView, setLeftView] = useState<LeftPanelView>("pages");
83
98
  const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -112,6 +127,7 @@ export function App() {
112
127
  onNotice: showNotice,
113
128
  onError: setError,
114
129
  });
130
+ const fileLocked = loaded.fileSession && (!fileSession.ready || fileSession.opening);
115
131
  useEffect(() => {
116
132
  if (!focusAfterHistory.current) return;
117
133
  focusAfterHistory.current = false;
@@ -130,33 +146,27 @@ export function App() {
130
146
  }, [history.present]);
131
147
  useEffect(() => {
132
148
  if (loaded.fileSession) return;
133
- if (blocked || loaded.example) return;
134
- if (!validateComposition(draft).ok) return;
149
+ if (blocked) return;
150
+ if (!validation.ok) return;
135
151
  const timer = setTimeout(() => {
136
152
  try {
137
- persistDraft(draft);
153
+ if (loaded.exampleName) persistExampleDraft(loaded.exampleName, draft);
154
+ else persistDraft(draft);
155
+ setSavedDraft(draft);
138
156
  } catch {
139
157
  setError(
140
- "Unable to save this draft. Export your work or reset saved draft to retry.",
158
+ "Unable to save this browser-local draft. Download JSON or reset to retry.",
141
159
  );
142
160
  setBlocked(true);
143
161
  }
144
162
  }, 250);
145
163
  return () => clearTimeout(timer);
146
- }, [draft, blocked, loaded.example, loaded.fileSession]);
147
- useEffect(() => {
148
- if (!notice) return;
149
- const timer = window.setTimeout(() => setToastClosing(true), 3000);
150
- return () => window.clearTimeout(timer);
151
- }, [notice]);
164
+ }, [draft, validation, blocked, loaded.exampleName, loaded.fileSession]);
152
165
  useEffect(() => {
153
- if (!toastClosing) return;
154
- const timer = window.setTimeout(() => {
155
- setNotice(undefined);
156
- setToastClosing(false);
157
- }, 280);
166
+ if (!notice || notice.tone === "error" || toastPaused) return;
167
+ const timer = window.setTimeout(() => setNotice(undefined), 3000);
158
168
  return () => window.clearTimeout(timer);
159
- }, [toastClosing]);
169
+ }, [notice, toastPaused]);
160
170
  useEffect(() => {
161
171
  const narrow = window.matchMedia("(max-width: 1200px)");
162
172
  const compact = window.matchMedia("(max-width: 900px)");
@@ -174,7 +184,7 @@ export function App() {
174
184
  }, []);
175
185
  useEffect(() => {
176
186
  function keydown(event: KeyboardEvent) {
177
- if (presenting || fileSession.building) return;
187
+ if (presenting || fileLocked || fileSession.building) return;
178
188
  const modifier = event.metaKey || event.ctrlKey;
179
189
  const target = event.target;
180
190
  const editable =
@@ -231,10 +241,18 @@ export function App() {
231
241
  }
232
242
  window.addEventListener("keydown", keydown);
233
243
  return () => window.removeEventListener("keydown", keydown);
234
- }, [draft, presenting, selected, vectorSelection, fileSession.building]);
244
+ }, [draft, presenting, selected, vectorSelection, fileSession.building, fileLocked]);
235
245
  function showNotice(message: string, tone: "neutral" | "error" = "neutral") {
236
- setToastClosing(false);
237
- setNotice({ message: message.replace(/\.$/, ""), tone });
246
+ if (!message) setToastPaused(false);
247
+ setNotice(message ? { message: message.replace(/\.$/, ""), tone } : undefined);
248
+ }
249
+ function changeLeftPanel(collapsed: boolean) {
250
+ setLeftCollapsed(collapsed);
251
+ if (!collapsed && window.matchMedia("(max-width: 900px)").matches) setRightCollapsed(true);
252
+ }
253
+ function changeRightPanel(collapsed: boolean) {
254
+ setRightCollapsed(collapsed);
255
+ if (!collapsed && window.matchMedia("(max-width: 900px)").matches) setLeftCollapsed(true);
238
256
  }
239
257
  function updateDraft(
240
258
  next: Draft,
@@ -439,7 +457,7 @@ export function App() {
439
457
  const page = draft.slides.find(s => s.id === target.slideId);
440
458
  if (!page) return;
441
459
  selectSlide(page.id);
442
- setLeftCollapsed(false);
460
+ changeLeftPanel(false);
443
461
  setLeftView("notes");
444
462
  const component = page.components.find(c => c.id === target.componentId);
445
463
  if (component) {
@@ -459,7 +477,7 @@ export function App() {
459
477
  const requested = await fileSession.build(instruction, slide.id, selected, vectorSelection?.componentId === selected ? vectorSelection?.elementId : undefined);
460
478
  if (requested) {
461
479
  setNotice(undefined);
462
- setToastClosing(false);
480
+ setToastPaused(false);
463
481
  }
464
482
  } catch (requestError) {
465
483
  showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.", "error");
@@ -524,20 +542,70 @@ export function App() {
524
542
  setExportBusy(false);
525
543
  }
526
544
  }
545
+ function downloadJSON() {
546
+ const validation = validateComposition(draft);
547
+ if (!validation.ok) {
548
+ showNotice("Fix the composition before downloading JSON.", "error");
549
+ return;
550
+ }
551
+ const blob = new Blob([canonicalJSON(draft)], { type: "application/json" });
552
+ const url = URL.createObjectURL(blob);
553
+ const link = document.createElement("a");
554
+ link.href = url;
555
+ link.download = `${draft.title.replace(/[^a-z0-9_-]+/gi, "-") || "konpeki-composition"}.json`;
556
+ link.click();
557
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
558
+ showNotice("Editable composition JSON downloaded.");
559
+ }
560
+ function startBlank() {
561
+ if (!window.confirm(
562
+ "Start with a blank composition? You can undo this replacement until you reload.",
563
+ )) return;
564
+ const next = initialDraft(true);
565
+ focusAfterHistory.current = true;
566
+ updateDraft(next, {
567
+ activeSlideId: next.slides[0].id,
568
+ selected: undefined,
569
+ });
570
+ setTitleError(false);
571
+ showNotice("Blank composition started. Undo restores the previous document.");
572
+ }
527
573
  function reset() {
574
+ const target = loaded.exampleName ? "example" : "saved local draft";
575
+ if (!window.confirm(
576
+ `Reset this ${target}? This removes its browser-local working copy.`,
577
+ )) return;
528
578
  try {
529
- clearStoredDraft();
530
- const next = initialDraft(true);
579
+ if (loaded.exampleName) clearStoredExampleDraft(loaded.exampleName);
580
+ else clearStoredDraft();
581
+ const next = loaded.exampleName
582
+ ? structuredClone(exampleDraft(loaded.exampleName)!)
583
+ : initialDraft(true);
531
584
  setHistory(
532
585
  createHistory({ draft: next, activeSlideId: next.slides[0].id }),
533
586
  );
534
587
  setBlocked(false);
588
+ setRequiresReset(false);
535
589
  setError("");
536
- showNotice("Saved draft reset.");
590
+ setTitleError(false);
591
+ showNotice(loaded.exampleName ? "Example reset." : "Saved draft reset.");
537
592
  } catch {
593
+ setBlocked(true);
538
594
  setError("Storage remains unavailable. Export your work to keep it.");
539
595
  }
540
596
  }
597
+ const browserSaveMessage = !validation.ok
598
+ ? `Autosave paused: ${validation.issues[0]?.path || "document"} ${validation.issues[0]?.message || "is invalid"}. Fix this to resume saving.`
599
+ : blocked
600
+ ? "Changes are not being saved. Download JSON to keep your work."
601
+ : savedDraft !== draft
602
+ ? "Saving changes in this browser…"
603
+ : "Your editable composition is saved only in this browser.";
604
+ const recoveryMessage = fileSession.opening
605
+ ? "Opening the file… Editing is paused."
606
+ : !validation.ok
607
+ ? `Changes are not saved: ${validation.issues[0]?.path || "document"} ${validation.issues[0]?.message || "is invalid"}. Undo the edit or correct this value.`
608
+ : error;
541
609
  const selectedComponent = slide.components.find((c) => c.id === selected);
542
610
  const buildState = fileSession.building
543
611
  ? fileSession.review.request?.status === "working" ? "working" : "ready"
@@ -550,25 +618,36 @@ export function App() {
550
618
  <a className="skip-link" href="#canvas-stage">
551
619
  Skip to canvas
552
620
  </a>
553
- {error && (
554
- <div className="recovery" role="alert">
555
- <span>{error}</span>
556
- <button type="button" onClick={reset}>
557
- Reset saved draft
558
- </button>
559
- </div>
560
- )}
621
+ <FeedbackNotice kind="recovery">
622
+ {recoveryMessage ? <>
623
+ <span>{recoveryMessage}</span>
624
+ <div className="recovery-actions">
625
+ {!validation.ok ? <button type="button" onClick={() => setHistory(current => undoHistory(current))}>Undo edit</button> : <>
626
+ {(!loaded.fileSession || fileSession.ready) && <button type="button" onClick={downloadJSON}>Download JSON</button>}
627
+ {loaded.fileSession ? fileSession.status === "conflict" ? (
628
+ <button type="button" onClick={() => {
629
+ if (window.confirm("Replace your browser edits with the current file? Download JSON first to keep a copy.")) void fileSession.reload();
630
+ }}>Load file version</button>
631
+ ) : <button type="button" disabled={fileSession.opening} onClick={() => { void fileSession.retry(); }}>{fileSession.opening ? "Opening…" : fileSession.ready ? "Retry save" : "Retry open"}</button> : <>
632
+ {!requiresReset && <button type="button" onClick={() => { setBlocked(false); setError(""); }}>Retry save</button>}
633
+ <button type="button" onClick={reset}>{loaded.exampleName ? "Reset example" : "Reset saved draft"}</button>
634
+ </>}
635
+ </>}
636
+ </div>
637
+ </> : null}
638
+ </FeedbackNotice>
561
639
  <div
562
640
  className={`workspace ${fileSession.building ? "is-building" : ""} ${leftCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""}`}
563
641
  aria-hidden={presenting || undefined}
564
- inert={presenting || undefined}
642
+ inert={presenting || fileLocked || undefined}
643
+ aria-busy={fileSession.opening || undefined}
565
644
  onDragOver={(event) => {
566
645
  if (!event.dataTransfer.types.includes("Files")) return;
567
646
  event.preventDefault();
568
647
  event.dataTransfer.dropEffect = "copy";
569
648
  }}
570
649
  onDrop={(event) => {
571
- if (fileSession.building) {
650
+ if (fileLocked || fileSession.building) {
572
651
  event.preventDefault();
573
652
  return;
574
653
  }
@@ -580,7 +659,7 @@ export function App() {
580
659
  >
581
660
  <WorkspaceChrome
582
661
  leftCollapsed={leftCollapsed}
583
- onToggleLeftPanel={() => setLeftCollapsed(!leftCollapsed)}
662
+ onToggleLeftPanel={() => changeLeftPanel(!leftCollapsed)}
584
663
  title={draft.title}
585
664
  titleRef={title}
586
665
  titleError={titleError}
@@ -597,10 +676,17 @@ export function App() {
597
676
  building={fileSession.building}
598
677
  buildState={buildState}
599
678
  onBuild={loaded.fileSession ? () => { void requestBuild(); } : undefined}
679
+ browserTools={loaded.fileSession ? undefined : {
680
+ example: Boolean(loaded.exampleName),
681
+ saveMessage: browserSaveMessage,
682
+ onImportJSON: (file) => { void openComposition(file); },
683
+ onDownloadJSON: downloadJSON,
684
+ onStartBlank: startBlank,
685
+ onReset: reset,
686
+ }}
600
687
  onSelectTool={() => selectComponent()}
601
688
  onComponentTool={useComponentTool}
602
- />
603
- <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
689
+ >
604
690
  <LeftPanel
605
691
  draft={draft}
606
692
  view={leftView}
@@ -613,6 +699,7 @@ export function App() {
613
699
  onAdd={fileSession.addNote}
614
700
  onRemove={fileSession.removeNote}
615
701
  onSelect={selectNoteTarget}
702
+ onNotice={(message) => showNotice(message, "error")}
616
703
  />}
617
704
  activeSlideId={slide.id}
618
705
  collapsed={leftCollapsed}
@@ -620,6 +707,8 @@ export function App() {
620
707
  onAddSlide={addNewSlide}
621
708
  onRemoveSlide={removeExistingSlide}
622
709
  />
710
+ </WorkspaceChrome>
711
+ <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
623
712
  <Canvas
624
713
  key={slide.id}
625
714
  revisionNotes={fileSession.review.notes.filter(note => !note.resolved)}
@@ -635,7 +724,7 @@ export function App() {
635
724
  setVectorSelection(selection);
636
725
  setRightView("settings");
637
726
  if (edit) {
638
- setRightCollapsed(false);
727
+ changeRightPanel(false);
639
728
  setVectorEditRequest((request) => request + 1);
640
729
  }
641
730
  } else setVectorSelection(undefined);
@@ -684,12 +773,19 @@ export function App() {
684
773
  slide={slide}
685
774
  draft={draft}
686
775
  onView={setRightView}
687
- onCollapsedChange={setRightCollapsed}
776
+ onCollapsedChange={changeRightPanel}
688
777
  onSelectSlide={() => selectComponent()}
689
778
  onSelectComponent={selectComponent}
690
779
  onSelectVectorElement={(elementId) =>
691
780
  selected && setVectorSelection({ componentId: selected, elementId })
692
781
  }
782
+ onSelectOverflow={(componentId, elementId) => {
783
+ selectComponent(componentId);
784
+ setVectorSelection(elementId ? { componentId, elementId } : undefined);
785
+ setRightView("settings");
786
+ changeRightPanel(false);
787
+ if (elementId) setVectorEditRequest(request => request + 1);
788
+ }}
693
789
  onReorderPaintOrder={updatePaintOrder}
694
790
  onComponent={updateComponent}
695
791
  onSlide={(nextSlide, mergeKey) =>
@@ -740,13 +836,10 @@ export function App() {
740
836
  onExit={() => setPresenting(false)}
741
837
  />
742
838
  )}
743
- <div
744
- className={`toast ${notice ? "visible" : ""} ${notice?.tone === "error" ? "error" : ""} ${toastClosing ? "closing" : ""}`}
745
- >
746
- <span role={notice?.tone === "error" ? "alert" : "status"} aria-live={notice?.tone === "error" ? "assertive" : "polite"}>
747
- {notice?.message}
748
- </span>
749
- </div>
839
+ <FeedbackNotice kind="toast" tone={notice?.tone} onPauseChange={setToastPaused}
840
+ onDismiss={() => { setNotice(undefined); setToastPaused(false); }}>
841
+ {notice?.message}
842
+ </FeedbackNotice>
750
843
  </>
751
844
  );
752
845
  }
@@ -760,7 +760,7 @@ export function Canvas({
760
760
  const themeId = draft.theme?.id ?? "plex";
761
761
  const themeMode = draft.theme?.mode ?? "paper";
762
762
  const palette = composerPalette(themeId, themeMode);
763
- const typography = getTheme(themeLabel(themeId), themeMode);
763
+ const typography = getTheme(themeLabel(themeId), themeMode, draft.theme?.typography);
764
764
  const canvasStyle = {
765
765
  "--component-layers": slide.paintOrder.length,
766
766
  "--slide-bg": palette.bg,
@@ -791,8 +791,10 @@ export function Canvas({
791
791
  if (
792
792
  event.target instanceof Element &&
793
793
  !event.target.closest(".canvas")
794
- )
794
+ ) {
795
+ event.currentTarget.focus({ preventScroll: true });
795
796
  onSelect();
797
+ }
796
798
  }}
797
799
  >
798
800
  <div className="slide-wrap">
@@ -802,6 +804,7 @@ export function Canvas({
802
804
  aria-label="Page name"
803
805
  autoFocus
804
806
  value={nextSlideName}
807
+ onPointerDown={(event) => event.stopPropagation()}
805
808
  onChange={(event) => setNextSlideName(event.target.value)}
806
809
  onBlur={() => {
807
810
  const name = nextSlideName.trim();
@@ -850,7 +853,10 @@ export function Canvas({
850
853
  style={canvasStyle}
851
854
  aria-label="Page canvas"
852
855
  onPointerDown={(e) => {
853
- if (interactive && e.target === e.currentTarget) onSelect();
856
+ if (interactive && e.target === e.currentTarget) {
857
+ e.currentTarget.closest<HTMLElement>(".stage")?.focus({ preventScroll: true });
858
+ onSelect();
859
+ }
854
860
  }}
855
861
  onPointerMove={interactive ? move : undefined}
856
862
  onPointerUp={interactive ? () => end() : undefined}