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.
- package/AGENTS.md +1 -1
- package/CONTRIBUTING.md +36 -0
- package/README.md +54 -18
- package/SECURITY.md +22 -0
- package/SETUP.md +112 -51
- package/composition/README.md +16 -3
- package/composition/compile.ts +1 -1
- package/composition/schema.json +9 -0
- package/composition/schema.ts +3 -1
- package/composition/types.ts +3 -1
- package/design/themes/README.md +25 -7
- package/design/themes/index.ts +15 -4
- package/docs/development.md +71 -11
- package/docs/workflow.md +11 -4
- package/index.html +17 -1
- package/package.json +8 -2
- package/plugin.json +22 -0
- package/public/og.png +0 -0
- package/runtime/konpeki.mjs +41 -13
- package/skills/konpeki/SKILL.md +186 -0
- package/skills/konpeki/assets/blank.json +23 -0
- package/skills/konpeki/scripts/ensure-runtime.mjs +69 -0
- package/skills/konpeki/scripts/prepare-document.mjs +40 -0
- package/slides/introducing-konpeki/PROMPT.md +21 -0
- package/slides/introducing-konpeki/README.md +52 -30
- package/slides/introducing-konpeki/SOURCE.md +16 -10
- package/slides/introducing-konpeki/author.ts +44 -42
- package/slides/introducing-konpeki/composition.json +135 -135
- package/src/app/App.tsx +87 -19
- package/src/components/Canvas.tsx +1 -1
- package/src/components/InspectorPanel.tsx +93 -40
- package/src/components/WorkspaceChrome.tsx +92 -5
- package/src/lib/storage.ts +38 -10
- package/src/main.tsx +3 -0
- package/src/styles/chrome.css +97 -25
- package/src/styles/feedback.css +20 -3
- package/src/styles/right-panel.css +28 -27
- package/src/styles/shell.css +60 -29
- 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 {
|
|
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
|
-
|
|
61
|
+
exampleName: undefined,
|
|
54
62
|
fileSession: true,
|
|
55
63
|
};
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
);
|
|
64
|
+
const exampleName = new URLSearchParams(window.location.search).get("example");
|
|
65
|
+
const example = exampleDraft(exampleName);
|
|
59
66
|
return example
|
|
60
|
-
? {
|
|
61
|
-
|
|
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
|
|
134
|
-
if (!
|
|
146
|
+
if (blocked) return;
|
|
147
|
+
if (!validation.ok) return;
|
|
135
148
|
const timer = setTimeout(() => {
|
|
136
149
|
try {
|
|
137
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
|
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
|
-
<
|
|
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
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
395
|
+
typography: typography.typographyId,
|
|
343
396
|
},
|
|
344
397
|
})
|
|
345
398
|
}
|
|
346
399
|
/>
|
|
347
400
|
<SegmentedControl
|
|
348
|
-
label="
|
|
349
|
-
value={
|
|
401
|
+
label="Background"
|
|
402
|
+
value={theme.mode}
|
|
350
403
|
options={["paper", "night"]}
|
|
351
404
|
onChange={(value) =>
|
|
352
405
|
onDraft({
|
|
353
406
|
theme: {
|
|
354
|
-
|
|
407
|
+
...theme,
|
|
355
408
|
mode: value as "paper" | "night",
|
|
356
409
|
},
|
|
357
410
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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="
|
|
37
|
-
<path d="M9
|
|
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
|
package/src/lib/storage.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
+
function loadStoredDraft(
|
|
16
|
+
key: string,
|
|
17
|
+
fallback: Draft,
|
|
18
|
+
unreadableMessage: string,
|
|
19
|
+
): LoadedDraft {
|
|
14
20
|
try {
|
|
15
|
-
const saved = localStorage.getItem(
|
|
16
|
-
if (saved === null) return { draft:
|
|
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:
|
|
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:
|
|
33
|
+
draft: fallback,
|
|
29
34
|
storageBlocked: true,
|
|
30
35
|
error:
|
|
31
|
-
"
|
|
36
|
+
"Browser storage is unavailable. You can still download JSON; reset to retry saving.",
|
|
32
37
|
};
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
|
-
|
|
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(
|
|
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";
|