konpeki 0.2.0 → 0.3.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 (39) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.md +54 -18
  4. package/SECURITY.md +22 -0
  5. package/SETUP.md +112 -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 +71 -11
  14. package/docs/workflow.md +11 -4
  15. package/index.html +17 -1
  16. package/package.json +8 -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/introducing-konpeki/PROMPT.md +21 -0
  25. package/slides/introducing-konpeki/README.md +52 -30
  26. package/slides/introducing-konpeki/SOURCE.md +16 -10
  27. package/slides/introducing-konpeki/author.ts +44 -42
  28. package/slides/introducing-konpeki/composition.json +135 -135
  29. package/src/app/App.tsx +87 -19
  30. package/src/components/Canvas.tsx +1 -1
  31. package/src/components/InspectorPanel.tsx +93 -40
  32. package/src/components/WorkspaceChrome.tsx +92 -5
  33. package/src/lib/storage.ts +38 -10
  34. package/src/main.tsx +3 -0
  35. package/src/styles/chrome.css +97 -25
  36. package/src/styles/feedback.css +20 -3
  37. package/src/styles/right-panel.css +28 -27
  38. package/src/styles/shell.css +60 -29
  39. 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";
@@ -50,15 +58,18 @@ function loadInitialDraft() {
50
58
  return {
51
59
  draft: initialDraft(true),
52
60
  storageBlocked: false,
53
- example: false,
61
+ exampleName: undefined,
54
62
  fileSession: true,
55
63
  };
56
- const example = exampleDraft(
57
- new URLSearchParams(window.location.search).get("example"),
58
- );
64
+ const exampleName = new URLSearchParams(window.location.search).get("example");
65
+ const example = exampleDraft(exampleName);
59
66
  return example
60
- ? { draft: example, storageBlocked: false, example: true, fileSession: false }
61
- : { ...loadDraft(), example: false, fileSession: false };
67
+ ? {
68
+ ...loadExampleDraft(exampleName!, example),
69
+ exampleName: exampleName!,
70
+ fileSession: false,
71
+ }
72
+ : { ...loadDraft(), exampleName: undefined, fileSession: false };
62
73
  }
63
74
 
64
75
  export function App() {
@@ -71,6 +82,8 @@ export function App() {
71
82
  );
72
83
  const { draft, selected, activeSlideId } = history.present;
73
84
  const slide = getSlide(draft, activeSlideId);
85
+ const validation = useMemo(() => validateComposition(draft), [draft]);
86
+ const [savedDraft, setSavedDraft] = useState<Draft>();
74
87
  const [blocked, setBlocked] = useState(loaded.storageBlocked);
75
88
  const [error, setError] = useState(loaded.error ?? "");
76
89
  const [notice, setNotice] = useState<{
@@ -130,20 +143,22 @@ export function App() {
130
143
  }, [history.present]);
131
144
  useEffect(() => {
132
145
  if (loaded.fileSession) return;
133
- if (blocked || loaded.example) return;
134
- if (!validateComposition(draft).ok) return;
146
+ if (blocked) return;
147
+ if (!validation.ok) return;
135
148
  const timer = setTimeout(() => {
136
149
  try {
137
- persistDraft(draft);
150
+ if (loaded.exampleName) persistExampleDraft(loaded.exampleName, draft);
151
+ else persistDraft(draft);
152
+ setSavedDraft(draft);
138
153
  } catch {
139
154
  setError(
140
- "Unable to save this draft. Export your work or reset saved draft to retry.",
155
+ "Unable to save this browser-local draft. Download JSON or reset to retry.",
141
156
  );
142
157
  setBlocked(true);
143
158
  }
144
159
  }, 250);
145
160
  return () => clearTimeout(timer);
146
- }, [draft, blocked, loaded.example, loaded.fileSession]);
161
+ }, [draft, validation, blocked, loaded.exampleName, loaded.fileSession]);
147
162
  useEffect(() => {
148
163
  if (!notice) return;
149
164
  const timer = window.setTimeout(() => setToastClosing(true), 3000);
@@ -524,20 +539,64 @@ export function App() {
524
539
  setExportBusy(false);
525
540
  }
526
541
  }
542
+ function downloadJSON() {
543
+ const validation = validateComposition(draft);
544
+ if (!validation.ok) {
545
+ showNotice("Fix the composition before downloading JSON.", "error");
546
+ return;
547
+ }
548
+ const blob = new Blob([canonicalJSON(draft)], { type: "application/json" });
549
+ const url = URL.createObjectURL(blob);
550
+ const link = document.createElement("a");
551
+ link.href = url;
552
+ link.download = `${draft.title.replace(/[^a-z0-9_-]+/gi, "-") || "konpeki-composition"}.json`;
553
+ link.click();
554
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
555
+ showNotice("Editable composition JSON downloaded.");
556
+ }
557
+ function startBlank() {
558
+ if (!window.confirm(
559
+ "Start with a blank composition? You can undo this replacement until you reload.",
560
+ )) return;
561
+ const next = initialDraft(true);
562
+ focusAfterHistory.current = true;
563
+ updateDraft(next, {
564
+ activeSlideId: next.slides[0].id,
565
+ selected: undefined,
566
+ });
567
+ setTitleError(false);
568
+ showNotice("Blank composition started. Undo restores the previous document.");
569
+ }
527
570
  function reset() {
571
+ const target = loaded.exampleName ? "example" : "saved local draft";
572
+ if (!window.confirm(
573
+ `Reset this ${target}? This removes its browser-local working copy.`,
574
+ )) return;
528
575
  try {
529
- clearStoredDraft();
530
- const next = initialDraft(true);
576
+ if (loaded.exampleName) clearStoredExampleDraft(loaded.exampleName);
577
+ else clearStoredDraft();
578
+ const next = loaded.exampleName
579
+ ? structuredClone(exampleDraft(loaded.exampleName)!)
580
+ : initialDraft(true);
531
581
  setHistory(
532
582
  createHistory({ draft: next, activeSlideId: next.slides[0].id }),
533
583
  );
534
584
  setBlocked(false);
535
585
  setError("");
536
- showNotice("Saved draft reset.");
586
+ setTitleError(false);
587
+ showNotice(loaded.exampleName ? "Example reset." : "Saved draft reset.");
537
588
  } catch {
589
+ setBlocked(true);
538
590
  setError("Storage remains unavailable. Export your work to keep it.");
539
591
  }
540
592
  }
593
+ const browserSaveMessage = !validation.ok
594
+ ? `Autosave paused: ${validation.issues[0]?.path || "document"} ${validation.issues[0]?.message || "is invalid"}. Fix this to resume saving.`
595
+ : blocked
596
+ ? "Changes are not being saved. Download JSON to keep your work."
597
+ : savedDraft !== draft
598
+ ? "Saving changes in this browser…"
599
+ : "Your editable composition is saved only in this browser.";
541
600
  const selectedComponent = slide.components.find((c) => c.id === selected);
542
601
  const buildState = fileSession.building
543
602
  ? fileSession.review.request?.status === "working" ? "working" : "ready"
@@ -554,7 +613,7 @@ export function App() {
554
613
  <div className="recovery" role="alert">
555
614
  <span>{error}</span>
556
615
  <button type="button" onClick={reset}>
557
- Reset saved draft
616
+ {loaded.exampleName ? "Reset example" : "Reset saved draft"}
558
617
  </button>
559
618
  </div>
560
619
  )}
@@ -597,10 +656,17 @@ export function App() {
597
656
  building={fileSession.building}
598
657
  buildState={buildState}
599
658
  onBuild={loaded.fileSession ? () => { void requestBuild(); } : undefined}
659
+ browserTools={loaded.fileSession ? undefined : {
660
+ example: Boolean(loaded.exampleName),
661
+ saveMessage: browserSaveMessage,
662
+ onImportJSON: (file) => { void openComposition(file); },
663
+ onDownloadJSON: downloadJSON,
664
+ onStartBlank: startBlank,
665
+ onReset: reset,
666
+ }}
600
667
  onSelectTool={() => selectComponent()}
601
668
  onComponentTool={useComponentTool}
602
- />
603
- <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
669
+ >
604
670
  <LeftPanel
605
671
  draft={draft}
606
672
  view={leftView}
@@ -620,6 +686,8 @@ export function App() {
620
686
  onAddSlide={addNewSlide}
621
687
  onRemoveSlide={removeExistingSlide}
622
688
  />
689
+ </WorkspaceChrome>
690
+ <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
623
691
  <Canvas
624
692
  key={slide.id}
625
693
  revisionNotes={fileSession.review.notes.filter(note => !note.resolved)}
@@ -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,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  themeIds,
3
+ typographyIds,
3
4
  type CompositionComponent,
4
5
  type CompositionSlide,
5
6
  type ChartTemplate,
@@ -7,6 +8,7 @@ import {
7
8
  type Rect,
8
9
  type TextColor,
9
10
  type ThemeId,
11
+ type TypographyId,
10
12
  type VectorElement,
11
13
  } from "../../composition/types.ts";
12
14
  import {
@@ -30,6 +32,7 @@ import {
30
32
  type Draft,
31
33
  } from "../lib/model.ts";
32
34
  import { composerPalette, themeLabel } from "../lib/theme.ts";
35
+ import { getTheme, typographyLabels } from "../../design/themes/index.ts";
33
36
  import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
34
37
  import { Field, Select, humanize } from "./ui.tsx";
35
38
  import { moveVectorElement, removeVectorElement } from "../../composition/vector.ts";
@@ -141,7 +144,7 @@ function AppearancePicker({
141
144
  );
142
145
  }
143
146
 
144
- function ThemePicker({
147
+ function PalettePicker({
145
148
  value,
146
149
  mode,
147
150
  onChange,
@@ -152,7 +155,7 @@ function ThemePicker({
152
155
  }) {
153
156
  return (
154
157
  <fieldset className="theme-picker">
155
- <legend>Theme</legend>
158
+ <legend>Color palette</legend>
156
159
  <div className="theme-options">
157
160
  {themeIds.map((id) => {
158
161
  const palette = composerPalette(id, mode);
@@ -203,6 +206,22 @@ function SegmentedControl({ label, value, options, onChange }: {
203
206
  );
204
207
  }
205
208
 
209
+ function AutoTemplateButton({ selected, onClick }: { selected: boolean; onClick: () => void }) {
210
+ return (
211
+ <button type="button" className={selected ? "selected" : ""} aria-pressed={selected} onClick={onClick}>
212
+ <svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
213
+ <rect x="13" y="5" width="22" height="22" rx="4" />
214
+ <circle className="filled" cx="19" cy="11" r="1.5" />
215
+ <circle className="filled" cx="29" cy="11" r="1.5" />
216
+ <circle className="filled" cx="24" cy="16" r="1.5" />
217
+ <circle className="filled" cx="19" cy="21" r="1.5" />
218
+ <circle className="filled" cx="29" cy="21" r="1.5" />
219
+ </svg>
220
+ <span>YOLO</span>
221
+ </button>
222
+ );
223
+ }
224
+
206
225
  function DiagramTypePicker({
207
226
  value,
208
227
  selection,
@@ -218,22 +237,7 @@ function DiagramTypePicker({
218
237
  <div className="diagram-type-options" aria-labelledby="diagram-type-label">
219
238
  <div className="diagram-type-category">
220
239
  <div>
221
- <button
222
- type="button"
223
- className={selection === "auto" ? "selected" : ""}
224
- aria-pressed={selection === "auto"}
225
- onClick={() => onChange(value, "auto")}
226
- >
227
- <svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
228
- <rect x="13" y="5" width="22" height="22" rx="4" />
229
- <circle className="filled" cx="19" cy="11" r="1.5" />
230
- <circle className="filled" cx="29" cy="11" r="1.5" />
231
- <circle className="filled" cx="24" cy="16" r="1.5" />
232
- <circle className="filled" cx="19" cy="21" r="1.5" />
233
- <circle className="filled" cx="29" cy="21" r="1.5" />
234
- </svg>
235
- <span>YOLO</span>
236
- </button>
240
+ <AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
237
241
  </div>
238
242
  </div>
239
243
  {diagramCategories.map((category) => (
@@ -270,21 +274,60 @@ function ChartTypePicker({ value, selection, hasTopology, onChange }: {
270
274
  }) {
271
275
  return (
272
276
  <details className="field chart-type-field diagram-type-field">
273
- <summary>{selection === "auto" ? "YOLO" : `Required form: ${chartDefinitions[value].label}`}</summary>
274
- <button type="button" aria-pressed={selection === "auto"} onClick={() => onChange(value, "auto")}>YOLO</button>
275
- <AppearancePicker
276
- label="Chart form"
277
- name="template"
278
- value={selection === "explicit" ? value : ""}
279
- options={chartTemplates}
280
- labels={Object.fromEntries(Object.entries(chartDefinitions).map(([type, definition]) => [type, definition.label]))}
281
- description={`${chartDefinitions[value].expression}${hasTopology ? " Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO." : ""}`}
282
- disabledOptions={hasTopology ? new Set(chartTemplates.filter(type => type !== "sankey")) : undefined}
283
- onChange={(value) => {
284
- const template = chartTemplates.find(type => type === value);
285
- if (template) onChange(template, "explicit");
286
- }}
287
- />
277
+ <summary id="chart-type-label">Template</summary>
278
+ <div className="diagram-type-options chart-type-options" aria-labelledby="chart-type-label">
279
+ <div className="diagram-type-category">
280
+ <div>
281
+ <AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
282
+ </div>
283
+ </div>
284
+ <div className="diagram-type-category" role="group" aria-label="Chart templates">
285
+ <div>
286
+ {chartTemplates.map((template) => (
287
+ <button
288
+ key={template}
289
+ type="button"
290
+ className={selection === "explicit" && value === template ? "selected" : ""}
291
+ aria-pressed={selection === "explicit" && value === template}
292
+ disabled={hasTopology && template !== "sankey"}
293
+ title={hasTopology && template !== "sankey"
294
+ ? "Remove the recorded Sankey topology before changing chart type."
295
+ : chartDefinitions[template].expression}
296
+ onClick={() => onChange(template, "explicit")}
297
+ >
298
+ <span className="appearance-preview" data-property="template" data-value={template} aria-hidden="true">
299
+ {template === "scatter" ? (
300
+ <svg viewBox="0 0 36 24">
301
+ <path d="M3 2v20h31" fill="none" stroke="var(--preview-muted)" strokeWidth="1.2" />
302
+ {[[7, 6], [10, 11], [14, 9], [17, 13], [20, 16], [24, 15], [28, 18], [29, 7], [32, 19]].map(([cx, cy], index) => (
303
+ <circle key={index} cx={cx} cy={cy} r="1.35" fill="var(--preview-accent)" />
304
+ ))}
305
+ </svg>
306
+ ) : template === "sankey" ? (
307
+ <svg viewBox="0 0 36 24" fill="var(--preview-accent)">
308
+ <g fillOpacity="0.3">
309
+ <path d="M4 4C10.5 4 10.5 2 17 2V12.5C10.5 12.5 10.5 14.5 4 14.5Z" />
310
+ <path d="M4 14.5C10.5 14.5 10.5 17.5 17 17.5V22C10.5 22 10.5 19 4 19Z" />
311
+ <path d="M19 8C25.5 8 25.5 10 32 10V14.5C25.5 14.5 25.5 12.5 19 12.5Z" />
312
+ <path d="M19 17.5C25.5 17.5 25.5 18 32 18V22.5C25.5 22.5 25.5 22 19 22Z" />
313
+ </g>
314
+ <path d="M19 2C25.5 2 25.5 1 32 1V7C25.5 7 25.5 8 19 8Z" fillOpacity="0.65" />
315
+ <rect x="2" y="4" width="2" height="15" />
316
+ <rect x="17" y="2" width="2" height="10.5" />
317
+ <rect x="17" y="17.5" width="2" height="4.5" />
318
+ <rect x="32" y="1" width="2" height="6" />
319
+ <rect x="32" y="10" width="2" height="4.5" />
320
+ <rect x="32" y="18" width="2" height="4.5" />
321
+ </svg>
322
+ ) : Array.from({ length: 4 }, (_, index) => <i key={index} />)}
323
+ </span>
324
+ <span>{chartDefinitions[template].label}</span>
325
+ </button>
326
+ ))}
327
+ </div>
328
+ </div>
329
+ </div>
330
+ {hasTopology && <small className="visualization-description">Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO.</small>}
288
331
  </details>
289
332
  );
290
333
  }
@@ -326,32 +369,42 @@ export function InspectorPanel({
326
369
  target?.scrollIntoView({ block: "nearest" });
327
370
  control?.select();
328
371
  }, [vectorEditRequest]);
372
+ const theme = draft.theme ?? { id: "plex", mode: "paper" };
373
+ const typography = getTheme(themeLabel(theme.id), theme.mode, theme.typography);
329
374
  if (!component)
330
375
  return (
331
376
  <section className="inspector-content" aria-labelledby="inspector-heading">
332
377
  <h2 id="inspector-heading">Page</h2>
333
378
  <PageSizePicker key={slide.id} slide={slide} onChange={onSlide} />
334
379
  <h2>Document appearance</h2>
335
- <ThemePicker
336
- value={draft.theme?.id ?? "plex"}
337
- mode={draft.theme?.mode ?? "paper"}
380
+ <Select
381
+ label="Font"
382
+ value={typography.typographyId}
383
+ options={typographyIds}
384
+ optionLabels={typographyLabels}
385
+ onChange={(value) => onDraft({ theme: { ...theme, typography: value as TypographyId } })}
386
+ />
387
+ <PalettePicker
388
+ value={theme.id}
389
+ mode={theme.mode}
338
390
  onChange={(value) =>
339
391
  onDraft({
340
392
  theme: {
393
+ ...theme,
341
394
  id: value,
342
- mode: draft.theme?.mode ?? "paper",
395
+ typography: typography.typographyId,
343
396
  },
344
397
  })
345
398
  }
346
399
  />
347
400
  <SegmentedControl
348
- label="Theme mode"
349
- value={draft.theme?.mode ?? "paper"}
401
+ label="Background"
402
+ value={theme.mode}
350
403
  options={["paper", "night"]}
351
404
  onChange={(value) =>
352
405
  onDraft({
353
406
  theme: {
354
- id: draft.theme?.id ?? "plex",
407
+ ...theme,
355
408
  mode: value as "paper" | "night",
356
409
  },
357
410
  })
@@ -1,4 +1,4 @@
1
- import type { RefObject } from "react";
1
+ import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
2
  import type { CompositionComponent } from "../../composition/types.ts";
3
3
  import { componentLabels } from "../lib/model.ts";
4
4
  import mark from "../assets/konpeki-mark.png";
@@ -33,8 +33,8 @@ function DockIcon({
33
33
  ) : kind === "diagram" ? (
34
34
  <>
35
35
  <rect x="3" y="5" width="6" height="5" rx="1" />
36
- <rect x="15" y="14" width="6" height="5" rx="1" />
37
- <path d="M9 8h6v8M12 13l3 3 3-3" />
36
+ <rect x="15" y="16" width="6" height="5" rx="1" />
37
+ <path d="M9 8h9v8m-2.5-2.5L18 16l2.5-2.5" />
38
38
  </>
39
39
  ) : kind === "image" ? (
40
40
  <>
@@ -68,11 +68,14 @@ export function WorkspaceChrome({
68
68
  building,
69
69
  buildState,
70
70
  onBuild,
71
+ browserTools,
71
72
  onSelectTool,
72
73
  onComponentTool,
73
74
  leftCollapsed,
74
75
  onToggleLeftPanel,
76
+ children,
75
77
  }: {
78
+ children: ReactNode;
76
79
  leftCollapsed: boolean;
77
80
  onToggleLeftPanel: () => void;
78
81
  title: string;
@@ -88,14 +91,31 @@ export function WorkspaceChrome({
88
91
  building: boolean;
89
92
  buildState?: "ready" | "working";
90
93
  onBuild?: () => void;
94
+ browserTools?: {
95
+ example: boolean;
96
+ saveMessage: string;
97
+ onImportJSON: (file: File) => void;
98
+ onDownloadJSON: () => void;
99
+ onStartBlank: () => void;
100
+ onReset: () => void;
101
+ };
91
102
  onSelectTool: () => void;
92
103
  onComponentTool: (kind: CompositionComponent["kind"]) => void;
93
104
  }) {
105
+ const browserMenu = useRef<HTMLDetailsElement>(null);
106
+ const importInput = useRef<HTMLInputElement>(null);
107
+ function closeBrowserMenu() {
108
+ if (browserMenu.current) browserMenu.current.open = false;
109
+ }
110
+ useEffect(() => {
111
+ if (leftCollapsed) closeBrowserMenu();
112
+ }, [leftCollapsed]);
94
113
  return (
95
114
  <>
115
+ <div className={`left-sidebar${leftCollapsed ? " collapsed" : ""}`}>
96
116
  <header className="document-island" aria-label="Document controls">
97
117
  <img src={mark} alt="Konpeki" />
98
- <label className="document-title">
118
+ <label className="document-title" inert={leftCollapsed}>
99
119
  <span className="sr-only">Composition title</span>
100
120
  <input
101
121
  ref={titleRef}
@@ -116,7 +136,7 @@ export function WorkspaceChrome({
116
136
  )}
117
137
  {fileStatus && fileStatus !== "saved" && (
118
138
  <span
119
- className={`file-status ${fileStatus}`}
139
+ className={`file-status ${fileStatus}${leftCollapsed ? " sr-only" : ""}`}
120
140
  role="status"
121
141
  aria-live="polite"
122
142
  title={fileStatus === "conflict"
@@ -130,6 +150,7 @@ export function WorkspaceChrome({
130
150
  )}
131
151
  <button type="button" className="panel-toggle"
132
152
  aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
153
+ aria-expanded={!leftCollapsed}
133
154
  onClick={onToggleLeftPanel}>
134
155
  <svg className="panel-collapse-icon" viewBox="0 0 24 24" aria-hidden="true">
135
156
  <rect x="3" y="4" width="18" height="16" rx="2" />
@@ -138,6 +159,10 @@ export function WorkspaceChrome({
138
159
  </button>
139
160
  </header>
140
161
 
162
+ <div className="editor-content" inert={building} aria-busy={building}>
163
+ {children}
164
+ </div>
165
+
141
166
  <div className="action-island" aria-label="Document actions" inert={leftCollapsed}>
142
167
  <button
143
168
  type="button"
@@ -158,6 +183,67 @@ export function WorkspaceChrome({
158
183
  <path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
159
184
  </svg>
160
185
  </button>
186
+ {browserTools && (
187
+ <details className="browser-menu" ref={browserMenu}>
188
+ <summary>Browser</summary>
189
+ <div className="browser-menu-popover">
190
+ <strong>Browser playground</strong>
191
+ <p role="status">
192
+ {browserTools.saveMessage}
193
+ {" "}Nothing syncs automatically.
194
+ </p>
195
+ <div className="browser-menu-actions">
196
+ <button type="button" onClick={() => importInput.current?.click()}>
197
+ Import JSON
198
+ </button>
199
+ <input
200
+ ref={importInput}
201
+ hidden
202
+ type="file"
203
+ accept="application/json,.json"
204
+ aria-label="Choose composition JSON"
205
+ onChange={(event) => {
206
+ const file = event.target.files?.[0];
207
+ event.target.value = "";
208
+ if (!file) return;
209
+ closeBrowserMenu();
210
+ browserTools.onImportJSON(file);
211
+ }}
212
+ />
213
+ <button type="button" onClick={() => {
214
+ closeBrowserMenu();
215
+ browserTools.onDownloadJSON();
216
+ }}>
217
+ Download JSON
218
+ </button>
219
+ <button type="button" onClick={() => {
220
+ closeBrowserMenu();
221
+ browserTools.onStartBlank();
222
+ }}>
223
+ Start blank
224
+ </button>
225
+ <button type="button" onClick={() => {
226
+ closeBrowserMenu();
227
+ browserTools.onReset();
228
+ }}>
229
+ {browserTools.example ? "Reset example" : "Reset local draft"}
230
+ </button>
231
+ </div>
232
+ <p className="browser-agent-handoff">
233
+ To continue with a coding agent, download the editable JSON and
234
+ follow the{" "}
235
+ <a
236
+ href="https://github.com/vcfgdev/konpeki/blob/main/SETUP.md"
237
+ target="_blank"
238
+ rel="noreferrer"
239
+ >
240
+ setup guidance
241
+ </a>
242
+ .
243
+ </p>
244
+ </div>
245
+ </details>
246
+ )}
161
247
  {onBuild && (
162
248
  <button
163
249
  type="button"
@@ -171,6 +257,7 @@ export function WorkspaceChrome({
171
257
  </button>
172
258
  )}
173
259
  </div>
260
+ </div>
174
261
 
175
262
  <nav className="component-dock" aria-label="Canvas tools" inert={building}>
176
263
  <button
@@ -5,39 +5,67 @@ import {
5
5
  type Draft,
6
6
  } from "./model.ts";
7
7
  export const storageKey = "konpeki-composer/v1";
8
+ export const exampleStorageKey = (name: string) =>
9
+ `konpeki-composer/examples/v1/${encodeURIComponent(name)}`;
8
10
  export type LoadedDraft = {
9
11
  draft: Draft;
10
12
  storageBlocked: boolean;
11
13
  error?: string;
12
14
  };
13
- export function loadDraft(): LoadedDraft {
15
+ function loadStoredDraft(
16
+ key: string,
17
+ fallback: Draft,
18
+ unreadableMessage: string,
19
+ ): LoadedDraft {
14
20
  try {
15
- const saved = localStorage.getItem(storageKey);
16
- if (saved === null) return { draft: initialDraft(true), storageBlocked: false };
21
+ const saved = localStorage.getItem(key);
22
+ if (saved === null) return { draft: fallback, storageBlocked: false };
17
23
  const result = parseStoredDraft(saved);
18
24
  return result.ok
19
25
  ? { draft: result.draft, storageBlocked: false }
20
26
  : {
21
- draft: initialDraft(true),
27
+ draft: fallback,
22
28
  storageBlocked: true,
23
- error:
24
- "Saved data could not be read. It has not been changed. Reset saved draft to recover.",
29
+ error: unreadableMessage,
25
30
  };
26
31
  } catch {
27
32
  return {
28
- draft: initialDraft(true),
33
+ draft: fallback,
29
34
  storageBlocked: true,
30
35
  error:
31
- "Storage is unavailable. You can still export your work; reset to retry saving.",
36
+ "Browser storage is unavailable. You can still download JSON; reset to retry saving.",
32
37
  };
33
38
  }
34
39
  }
35
- export function persistDraft(draft: Draft) {
40
+ function persistStoredDraft(key: string, draft: Draft) {
36
41
  const value = serializeDraft(draft);
37
42
  if (!parseStoredDraft(value).ok)
38
43
  throw new Error("Invalid draft cannot be saved.");
39
- localStorage.setItem(storageKey, value);
44
+ localStorage.setItem(key, value);
45
+ }
46
+ export function loadDraft(): LoadedDraft {
47
+ return loadStoredDraft(
48
+ storageKey,
49
+ initialDraft(true),
50
+ "Saved data could not be read. It has not been changed. Reset saved draft to recover.",
51
+ );
52
+ }
53
+ export function loadExampleDraft(name: string, bundled: Draft): LoadedDraft {
54
+ return loadStoredDraft(
55
+ exampleStorageKey(name),
56
+ bundled,
57
+ "This example's saved working copy could not be read. It has not been changed. Reset the example to recover.",
58
+ );
59
+ }
60
+ export function persistDraft(draft: Draft) {
61
+ persistStoredDraft(storageKey, draft);
62
+ }
63
+ export function persistExampleDraft(name: string, draft: Draft) {
64
+ persistStoredDraft(exampleStorageKey(name), draft);
40
65
  }
41
66
  export function clearStoredDraft() {
42
67
  localStorage.removeItem(storageKey);
43
68
  }
69
+ export function clearStoredExampleDraft(name: string) {
70
+ localStorage.removeItem(exampleStorageKey(name));
71
+ }
package/src/main.tsx CHANGED
@@ -9,6 +9,9 @@ import "@fontsource/ibm-plex-serif/latin-600.css";
9
9
  import "@fontsource/noto-sans/latin-400.css";
10
10
  import "@fontsource/noto-sans/latin-500.css";
11
11
  import "@fontsource/noto-sans/latin-600.css";
12
+ import "@fontsource/hanken-grotesk/latin-400.css";
13
+ import "@fontsource/hanken-grotesk/latin-500.css";
14
+ import "@fontsource/hanken-grotesk/latin-600.css";
12
15
  import { App } from "./app/App.tsx";
13
16
  import "./styles/base.css";
14
17
  import "./styles/shell.css";