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.
- package/AGENTS.md +1 -1
- package/CONTRIBUTING.md +36 -0
- package/README.md +81 -28
- package/SECURITY.md +22 -0
- package/SETUP.md +128 -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 +79 -11
- package/docs/workflow.md +11 -4
- package/index.html +17 -1
- package/package.json +10 -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/README.md +35 -132
- package/slides/drawing-references.md +32 -0
- package/slides/github-cover/PROMPT.md +21 -0
- package/slides/github-cover/README.md +21 -0
- package/slides/github-cover/author.ts +67 -0
- package/slides/github-cover/composition.json +595 -0
- package/slides/introducing-konpeki/PROMPT.md +25 -14
- package/slides/introducing-konpeki/README.md +32 -42
- package/slides/introducing-konpeki/SOURCE.md +13 -14
- package/slides/introducing-konpeki/author.ts +44 -42
- package/slides/introducing-konpeki/composition.json +135 -135
- package/src/app/App.tsx +149 -56
- package/src/components/Canvas.tsx +9 -3
- package/src/components/InspectorPanel.tsx +104 -67
- package/src/components/RevisionNotes.tsx +6 -7
- package/src/components/RightPanel.tsx +42 -10
- package/src/components/VectorOverflowWarning.tsx +35 -8
- package/src/components/WorkspaceChrome.tsx +125 -10
- package/src/components/ui.tsx +95 -1
- package/src/lib/storage.ts +38 -10
- package/src/lib/use-file-session.ts +60 -38
- package/src/main.tsx +3 -0
- package/src/styles/base.css +7 -0
- package/src/styles/chrome.css +188 -25
- package/src/styles/feedback.css +101 -49
- package/src/styles/left-panel.css +27 -6
- package/src/styles/right-panel.css +114 -30
- package/src/styles/shell.css +96 -33
- package/.agents/skills/authoring-visuals/SKILL.md +0 -93
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useRef, useState, 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,48 @@ 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 [browserMenuOpen, setBrowserMenuOpen] = useState(false);
|
|
107
|
+
const importInput = useRef<HTMLInputElement>(null);
|
|
108
|
+
function closeBrowserMenu(restoreFocus = true) {
|
|
109
|
+
const menu = browserMenu.current;
|
|
110
|
+
if (!menu?.open) return;
|
|
111
|
+
setBrowserMenuOpen(false);
|
|
112
|
+
if (restoreFocus) menu.querySelector("summary")?.focus();
|
|
113
|
+
}
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (leftCollapsed) closeBrowserMenu(false);
|
|
116
|
+
}, [leftCollapsed]);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
function dismissOutside(event: Event) {
|
|
119
|
+
if (event.target instanceof Node && !browserMenu.current?.contains(event.target)) {
|
|
120
|
+
closeBrowserMenu(false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
document.addEventListener("pointerdown", dismissOutside);
|
|
124
|
+
document.addEventListener("focusin", dismissOutside);
|
|
125
|
+
return () => {
|
|
126
|
+
document.removeEventListener("pointerdown", dismissOutside);
|
|
127
|
+
document.removeEventListener("focusin", dismissOutside);
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
94
130
|
return (
|
|
95
131
|
<>
|
|
132
|
+
<div className={`left-sidebar${leftCollapsed ? " collapsed" : ""}`}>
|
|
96
133
|
<header className="document-island" aria-label="Document controls">
|
|
97
134
|
<img src={mark} alt="Konpeki" />
|
|
98
|
-
<label className="document-title">
|
|
135
|
+
<label className="document-title" inert={leftCollapsed}>
|
|
99
136
|
<span className="sr-only">Composition title</span>
|
|
100
137
|
<input
|
|
101
138
|
ref={titleRef}
|
|
@@ -114,22 +151,21 @@ export function WorkspaceChrome({
|
|
|
114
151
|
Enter a title.
|
|
115
152
|
</span>
|
|
116
153
|
)}
|
|
117
|
-
{fileStatus &&
|
|
154
|
+
{fileStatus && (
|
|
118
155
|
<span
|
|
119
156
|
className={`file-status ${fileStatus}`}
|
|
120
157
|
role="status"
|
|
121
158
|
aria-live="polite"
|
|
122
|
-
title={fileStatus === "
|
|
123
|
-
? "The file changed elsewhere while this browser has edits."
|
|
124
|
-
: undefined}
|
|
159
|
+
title={fileStatus === "saved" ? "Saved to file" : fileStatus === "saving" ? "Saving to file" : fileStatus === "loading" ? "Opening file" : "Changes are not saved — see the recovery notice"}
|
|
125
160
|
>
|
|
126
|
-
{fileStatus === "loading" ? "Opening"
|
|
161
|
+
<span className="sr-only">{fileStatus === "saved" ? "Saved" : fileStatus === "loading" ? "Opening"
|
|
127
162
|
: fileStatus === "saving" ? "Saving"
|
|
128
|
-
: fileStatus === "conflict" ? "Conflict" : "Save error"}
|
|
163
|
+
: fileStatus === "conflict" ? "Conflict" : "Save error"}</span>
|
|
129
164
|
</span>
|
|
130
165
|
)}
|
|
131
166
|
<button type="button" className="panel-toggle"
|
|
132
167
|
aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
|
|
168
|
+
aria-expanded={!leftCollapsed}
|
|
133
169
|
onClick={onToggleLeftPanel}>
|
|
134
170
|
<svg className="panel-collapse-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
135
171
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
@@ -138,6 +174,10 @@ export function WorkspaceChrome({
|
|
|
138
174
|
</button>
|
|
139
175
|
</header>
|
|
140
176
|
|
|
177
|
+
<div className="editor-content" inert={building} aria-busy={building}>
|
|
178
|
+
{children}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
141
181
|
<div className="action-island" aria-label="Document actions" inert={leftCollapsed}>
|
|
142
182
|
<button
|
|
143
183
|
type="button"
|
|
@@ -158,6 +198,80 @@ export function WorkspaceChrome({
|
|
|
158
198
|
<path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
|
|
159
199
|
</svg>
|
|
160
200
|
</button>
|
|
201
|
+
{browserTools && (
|
|
202
|
+
<details className="browser-menu" ref={browserMenu} open={browserMenuOpen} onKeyDown={(event) => {
|
|
203
|
+
if (event.key === "Escape" && browserMenu.current?.open) {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
event.stopPropagation();
|
|
206
|
+
closeBrowserMenu();
|
|
207
|
+
}
|
|
208
|
+
}}>
|
|
209
|
+
<summary onClick={(event) => {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
setBrowserMenuOpen(open => !open);
|
|
212
|
+
}}>
|
|
213
|
+
Demo Mode
|
|
214
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 10 4-4 4 4" /></svg>
|
|
215
|
+
</summary>
|
|
216
|
+
<div className="browser-menu-popover" role="region" aria-label="Demo Mode" inert={!browserMenuOpen}>
|
|
217
|
+
<div className="browser-menu-intro">
|
|
218
|
+
<strong>Demo Mode</strong>
|
|
219
|
+
<p>
|
|
220
|
+
Explore the canvas here. No agent connection or file sync.{" "}
|
|
221
|
+
<span role="status">{browserTools.saveMessage}</span>
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="browser-menu-actions">
|
|
225
|
+
<button type="button" onClick={() => importInput.current?.click()}>
|
|
226
|
+
Import
|
|
227
|
+
</button>
|
|
228
|
+
<input
|
|
229
|
+
ref={importInput}
|
|
230
|
+
hidden
|
|
231
|
+
type="file"
|
|
232
|
+
accept="application/json,.json"
|
|
233
|
+
aria-label="Choose composition JSON"
|
|
234
|
+
onChange={(event) => {
|
|
235
|
+
const file = event.target.files?.[0];
|
|
236
|
+
event.target.value = "";
|
|
237
|
+
if (!file) return;
|
|
238
|
+
closeBrowserMenu();
|
|
239
|
+
browserTools.onImportJSON(file);
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<button type="button" onClick={() => {
|
|
243
|
+
closeBrowserMenu();
|
|
244
|
+
browserTools.onDownloadJSON();
|
|
245
|
+
}}>
|
|
246
|
+
Export
|
|
247
|
+
</button>
|
|
248
|
+
<button type="button" className="secondary" onClick={() => {
|
|
249
|
+
closeBrowserMenu();
|
|
250
|
+
browserTools.onStartBlank();
|
|
251
|
+
}}>
|
|
252
|
+
Start blank
|
|
253
|
+
</button>
|
|
254
|
+
<button type="button" className="secondary" onClick={() => {
|
|
255
|
+
closeBrowserMenu();
|
|
256
|
+
browserTools.onReset();
|
|
257
|
+
}}>
|
|
258
|
+
{browserTools.example ? "Reset example" : "Reset local draft"}
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="browser-agent-handoff">
|
|
262
|
+
<strong>Continue with an agent</strong>
|
|
263
|
+
<p>Export the JSON file, then ask your coding agent to open it with Konpeki.</p>
|
|
264
|
+
<a
|
|
265
|
+
href="https://github.com/vcfgdev/konpeki/blob/main/SETUP.md"
|
|
266
|
+
target="_blank"
|
|
267
|
+
rel="noreferrer"
|
|
268
|
+
>
|
|
269
|
+
Setup guide
|
|
270
|
+
</a>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</details>
|
|
274
|
+
)}
|
|
161
275
|
{onBuild && (
|
|
162
276
|
<button
|
|
163
277
|
type="button"
|
|
@@ -171,6 +285,7 @@ export function WorkspaceChrome({
|
|
|
171
285
|
</button>
|
|
172
286
|
)}
|
|
173
287
|
</div>
|
|
288
|
+
</div>
|
|
174
289
|
|
|
175
290
|
<nav className="component-dock" aria-label="Canvas tools" inert={building}>
|
|
176
291
|
<button
|
package/src/components/ui.tsx
CHANGED
|
@@ -1,4 +1,98 @@
|
|
|
1
|
-
import { useId, type ReactNode } from "react";
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
/** Retain the last content while CSS transitions out; hidden notices are inert. */
|
|
5
|
+
export function FeedbackNotice({ kind, children, tone = "neutral", onDismiss, onPauseChange }: {
|
|
6
|
+
kind: "toast" | "recovery";
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
tone?: "neutral" | "error";
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
onPauseChange?: (paused: boolean) => void;
|
|
11
|
+
}) {
|
|
12
|
+
const visible = Boolean(children);
|
|
13
|
+
const [last, setLast] = useState({ children, tone });
|
|
14
|
+
if (visible && (children !== last.children || tone !== last.tone)) setLast({ children, tone });
|
|
15
|
+
return <div className={`feedback-notice ${kind}${visible ? " visible" : ""}${last.tone === "error" ? " error" : ""}`}
|
|
16
|
+
inert={!visible} aria-hidden={!visible} role={kind === "recovery" && visible ? "alert" : undefined}
|
|
17
|
+
onMouseEnter={() => onPauseChange?.(true)} onMouseLeave={() => onPauseChange?.(false)}
|
|
18
|
+
onFocus={() => onPauseChange?.(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) onPauseChange?.(false); }}>
|
|
19
|
+
{kind === "toast" ? <span role={visible ? last.tone === "error" ? "alert" : "status" : undefined}
|
|
20
|
+
aria-live={visible ? last.tone === "error" ? "assertive" : "polite" : "off"}>{last.children}</span> : last.children}
|
|
21
|
+
{onDismiss && last.children && <button type="button" className="toast-dismiss" aria-label="Dismiss notification" onClick={onDismiss}>×</button>}
|
|
22
|
+
</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep incomplete input out of the document, without silently correcting it. */
|
|
26
|
+
export function NumberField({ label, name, value, min, max, step = 1, unit, onCommit, onEditEnd }: {
|
|
27
|
+
label: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
value: number;
|
|
30
|
+
min: number;
|
|
31
|
+
max: number;
|
|
32
|
+
step?: number;
|
|
33
|
+
unit?: string;
|
|
34
|
+
onCommit: (value: number) => void;
|
|
35
|
+
onEditEnd: () => void;
|
|
36
|
+
}) {
|
|
37
|
+
const [text, setText] = useState(String(value));
|
|
38
|
+
const [error, setError] = useState("");
|
|
39
|
+
const [showError, setShowError] = useState(false);
|
|
40
|
+
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
41
|
+
const input = useRef<HTMLInputElement>(null);
|
|
42
|
+
const id = useId();
|
|
43
|
+
useEffect(() => { setText(String(value)); setError(""); }, [value]);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
if (!error || !showError) return;
|
|
46
|
+
function place() {
|
|
47
|
+
const rect = input.current!.getBoundingClientRect();
|
|
48
|
+
const panel = input.current!.closest(".right-panel")!.getBoundingClientRect();
|
|
49
|
+
const beside = panel.left >= 276;
|
|
50
|
+
setPosition({ top: Math.max(8, Math.min(beside ? rect.top : rect.bottom + 6, window.innerHeight - 80)), left: beside ? panel.left - 268 : Math.max(8, Math.min(rect.left, window.innerWidth - 268)) });
|
|
51
|
+
if (rect.bottom < panel.top + 58 || rect.top > panel.bottom) setShowError(false);
|
|
52
|
+
}
|
|
53
|
+
function focus(event: FocusEvent) {
|
|
54
|
+
if (event.target !== input.current) setShowError(false);
|
|
55
|
+
}
|
|
56
|
+
place();
|
|
57
|
+
window.addEventListener("resize", place);
|
|
58
|
+
window.addEventListener("scroll", place, true);
|
|
59
|
+
document.addEventListener("focusin", focus);
|
|
60
|
+
return () => {
|
|
61
|
+
window.removeEventListener("resize", place);
|
|
62
|
+
window.removeEventListener("scroll", place, true);
|
|
63
|
+
document.removeEventListener("focusin", focus);
|
|
64
|
+
};
|
|
65
|
+
}, [error, showError]);
|
|
66
|
+
function commit() {
|
|
67
|
+
const number = Number(text);
|
|
68
|
+
if (!text.trim() || !Number.isFinite(number) || number < min || number > max) {
|
|
69
|
+
setError(`${label} must be ${min}–${max}${unit ? ` ${unit}` : ""}. Escape restores the previous value.`);
|
|
70
|
+
setShowError(true);
|
|
71
|
+
} else {
|
|
72
|
+
setError("");
|
|
73
|
+
if (number !== value) onCommit(number);
|
|
74
|
+
}
|
|
75
|
+
onEditEnd();
|
|
76
|
+
}
|
|
77
|
+
return <Field label={label}>
|
|
78
|
+
<span className={unit ? "parameter-input" : "number-input"}>
|
|
79
|
+
<input ref={input} name={name} type="number" autoComplete="off" min={min} max={max} step={step}
|
|
80
|
+
value={text} aria-invalid={error ? true : undefined} aria-describedby={error ? id : undefined}
|
|
81
|
+
onChange={event => setText(event.target.value)} onFocus={() => setShowError(true)} onBlur={commit}
|
|
82
|
+
onKeyDown={event => {
|
|
83
|
+
if ((event.metaKey || event.ctrlKey) && ["z", "y"].includes(event.key.toLowerCase()) && text !== String(value)) event.stopPropagation();
|
|
84
|
+
if (event.key === "Enter") { event.preventDefault(); commit(); }
|
|
85
|
+
if (event.key === "Escape") {
|
|
86
|
+
event.preventDefault(); event.stopPropagation();
|
|
87
|
+
setText(String(value)); setError(""); onEditEnd();
|
|
88
|
+
}
|
|
89
|
+
}} />
|
|
90
|
+
{unit && <span className="parameter-unit" aria-hidden="true">{unit}</span>}
|
|
91
|
+
</span>
|
|
92
|
+
{error && createPortal(<span id={id} role="alert" className={showError ? "parameter-error" : "sr-only"} style={showError ? position : undefined}>{error}</span>, document.body)}
|
|
93
|
+
</Field>;
|
|
94
|
+
}
|
|
95
|
+
|
|
2
96
|
export function Field({
|
|
3
97
|
label,
|
|
4
98
|
children,
|
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
|
+
}
|
|
@@ -35,6 +35,8 @@ export function useFileSession(
|
|
|
35
35
|
const reviewRef = useRef(review);
|
|
36
36
|
const buildPending = useRef(false);
|
|
37
37
|
const submittingBuild = useRef(false);
|
|
38
|
+
const loading = useRef(false);
|
|
39
|
+
const loadGeneration = useRef(0);
|
|
38
40
|
const callbacksRef = useRef(callbacks);
|
|
39
41
|
callbacksRef.current = callbacks;
|
|
40
42
|
const draftRef = useRef(draft);
|
|
@@ -49,29 +51,15 @@ export function useFileSession(
|
|
|
49
51
|
|
|
50
52
|
useEffect(() => {
|
|
51
53
|
if (!enabled || !token) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
revision: session.revision,
|
|
58
|
-
savedJSON: canonicalJSON(session.document),
|
|
59
|
-
};
|
|
60
|
-
setStatus("saved");
|
|
61
|
-
receiveReview(session.review);
|
|
62
|
-
callbacksRef.current.onOpen(session.document, session.name);
|
|
63
|
-
}).catch((error) => {
|
|
64
|
-
if (cancelled) return;
|
|
65
|
-
setStatus("error");
|
|
66
|
-
callbacksRef.current.onError(
|
|
67
|
-
error instanceof Error ? error.message : "Could not open the file-backed composition.",
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
return () => { cancelled = true; };
|
|
54
|
+
void reload();
|
|
55
|
+
return () => {
|
|
56
|
+
loadGeneration.current++;
|
|
57
|
+
loading.current = false;
|
|
58
|
+
};
|
|
71
59
|
}, [enabled, token]);
|
|
72
60
|
|
|
73
61
|
useEffect(() => {
|
|
74
|
-
if (!enabled || !state.current.ready || ["conflict", "error"].includes(status))
|
|
62
|
+
if (!enabled || !state.current.ready || ["loading", "conflict", "error"].includes(status))
|
|
75
63
|
return;
|
|
76
64
|
if (!validateComposition(draft).ok) return;
|
|
77
65
|
const timer = window.setTimeout(() => { void save(draft); }, 250);
|
|
@@ -82,19 +70,20 @@ export function useFileSession(
|
|
|
82
70
|
if (!enabled || !token) return;
|
|
83
71
|
let cancelled = false;
|
|
84
72
|
const timer = window.setInterval(() => {
|
|
85
|
-
if (!state.current.ready || state.current.drain || submittingBuild.current) return;
|
|
73
|
+
if (!state.current.ready || loading.current || state.current.drain || submittingBuild.current) return;
|
|
86
74
|
const revision = state.current.revision;
|
|
75
|
+
const generation = loadGeneration.current;
|
|
87
76
|
void loadFileSession(token).then((remote) => {
|
|
88
|
-
if (cancelled || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
77
|
+
if (cancelled || generation !== loadGeneration.current || loading.current || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
89
78
|
receiveReview(remote.review);
|
|
90
|
-
|
|
79
|
+
if (canonicalJSON(draftRef.current) === state.current.savedJSON) {
|
|
80
|
+
setStatus("saved");
|
|
81
|
+
callbacksRef.current.onError("");
|
|
82
|
+
}
|
|
91
83
|
if (remote.revision === revision) return;
|
|
92
84
|
if (canonicalJSON(draftRef.current) !== state.current.savedJSON) {
|
|
93
85
|
setStatus("conflict");
|
|
94
|
-
callbacksRef.current.
|
|
95
|
-
"The file changed elsewhere. Neither version was overwritten.",
|
|
96
|
-
"error",
|
|
97
|
-
);
|
|
86
|
+
callbacksRef.current.onError("The file changed elsewhere. Your browser edits are not saved; neither version was overwritten.");
|
|
98
87
|
return;
|
|
99
88
|
}
|
|
100
89
|
state.current = {
|
|
@@ -104,12 +93,10 @@ export function useFileSession(
|
|
|
104
93
|
};
|
|
105
94
|
setStatus("saved");
|
|
106
95
|
callbacksRef.current.onExternalChange(remote.document);
|
|
107
|
-
}).catch(() => {
|
|
108
|
-
if (cancelled || submittingBuild.current || revision !== state.current.revision) return;
|
|
96
|
+
}).catch((error) => {
|
|
97
|
+
if (cancelled || generation !== loadGeneration.current || loading.current || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
109
98
|
setStatus("error");
|
|
110
|
-
|
|
111
|
-
callbacksRef.current.onNotice("Could not load the agent result. Check the local file service.", "error");
|
|
112
|
-
}
|
|
99
|
+
callbacksRef.current.onError(`Could not load the latest file. ${error instanceof Error ? error.message : "Check the local file service."}`);
|
|
113
100
|
});
|
|
114
101
|
}, 1000);
|
|
115
102
|
return () => {
|
|
@@ -132,6 +119,7 @@ export function useFileSession(
|
|
|
132
119
|
}
|
|
133
120
|
|
|
134
121
|
async function noteAction(action: () => Promise<ReviewState>, saveFirst = false) {
|
|
122
|
+
if (loading.current || !state.current.ready) throw new Error("Wait for the file to open before submitting.");
|
|
135
123
|
if (submittingBuild.current) throw new Error("Wait for the current save before submitting.");
|
|
136
124
|
submittingBuild.current = true;
|
|
137
125
|
try {
|
|
@@ -141,7 +129,8 @@ export function useFileSession(
|
|
|
141
129
|
}
|
|
142
130
|
|
|
143
131
|
function save(next: Draft): Promise<string | undefined> {
|
|
144
|
-
if (!token || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
|
|
132
|
+
if (!token || loading.current || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
|
|
133
|
+
if (!validateComposition(next).ok) return Promise.resolve(undefined);
|
|
145
134
|
const serialized = canonicalJSON(next);
|
|
146
135
|
if (serialized === state.current.savedJSON && !state.current.drain)
|
|
147
136
|
return Promise.resolve(state.current.revision);
|
|
@@ -170,15 +159,15 @@ export function useFileSession(
|
|
|
170
159
|
state.current.revision = result.revision;
|
|
171
160
|
state.current.savedJSON = pending.serialized;
|
|
172
161
|
setStatus("saved");
|
|
162
|
+
callbacksRef.current.onError("");
|
|
173
163
|
} catch (error) {
|
|
174
164
|
state.current.pending = undefined;
|
|
175
165
|
const conflict = error instanceof FileSessionError && error.status === 409;
|
|
176
166
|
setStatus(conflict ? "conflict" : "error");
|
|
177
|
-
callbacksRef.current.
|
|
167
|
+
callbacksRef.current.onError(
|
|
178
168
|
conflict
|
|
179
|
-
? "The
|
|
180
|
-
: error instanceof Error ? error.message : "Could not save the composition file."
|
|
181
|
-
"error",
|
|
169
|
+
? "The file changed elsewhere. Your browser edits are not saved; neither version was overwritten."
|
|
170
|
+
: `Changes are not saved. ${error instanceof Error ? error.message : "Could not save the composition file."}`,
|
|
182
171
|
);
|
|
183
172
|
return;
|
|
184
173
|
}
|
|
@@ -191,7 +180,7 @@ export function useFileSession(
|
|
|
191
180
|
componentId?: string,
|
|
192
181
|
elementId?: string,
|
|
193
182
|
) {
|
|
194
|
-
if (!token || buildPending.current) return false;
|
|
183
|
+
if (!token || loading.current || !state.current.ready || buildPending.current) return false;
|
|
195
184
|
if (submittingBuild.current) throw new Error("Wait for your note to finish saving before building.");
|
|
196
185
|
buildPending.current = true;
|
|
197
186
|
submittingBuild.current = true;
|
|
@@ -218,8 +207,41 @@ export function useFileSession(
|
|
|
218
207
|
}
|
|
219
208
|
}
|
|
220
209
|
|
|
210
|
+
async function reload() {
|
|
211
|
+
if (!token || loading.current || state.current.drain || submittingBuild.current) return;
|
|
212
|
+
loading.current = true;
|
|
213
|
+
const generation = ++loadGeneration.current;
|
|
214
|
+
const requestedJSON = canonicalJSON(draftRef.current);
|
|
215
|
+
setStatus("loading");
|
|
216
|
+
try {
|
|
217
|
+
const session = await loadFileSession(token);
|
|
218
|
+
if (generation !== loadGeneration.current) return;
|
|
219
|
+
if (canonicalJSON(draftRef.current) !== requestedJSON) {
|
|
220
|
+
setStatus("conflict");
|
|
221
|
+
callbacksRef.current.onError("Your browser edits changed while opening the file. Neither version was overwritten. Download JSON before loading the file again.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
state.current = { ready: true, revision: session.revision, savedJSON: canonicalJSON(session.document) };
|
|
225
|
+
draftRef.current = session.document;
|
|
226
|
+
receiveReview(session.review);
|
|
227
|
+
callbacksRef.current.onOpen(session.document, session.name);
|
|
228
|
+
callbacksRef.current.onError("");
|
|
229
|
+
setStatus("saved");
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (generation !== loadGeneration.current) return;
|
|
232
|
+
setStatus("error");
|
|
233
|
+
callbacksRef.current.onError(error instanceof Error ? error.message : "Could not open the file.");
|
|
234
|
+
} finally {
|
|
235
|
+
if (generation === loadGeneration.current) loading.current = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
return {
|
|
222
240
|
status, build, building, review,
|
|
241
|
+
opening: status === "loading",
|
|
242
|
+
ready: state.current.ready,
|
|
243
|
+
reload,
|
|
244
|
+
retry: () => state.current.ready && !activeRequest(reviewRef.current.request) ? save(draftRef.current) : reload(),
|
|
223
245
|
addNote: (target: ReviewTarget, text: string) => noteAction(() => addNote(token!, target, text), true),
|
|
224
246
|
removeNote: (id: string) => noteAction(() => removeNote(token!, id)),
|
|
225
247
|
cancel: () => noteAction(() => cancelRequest(token!, reviewRef.current.request!.id)),
|
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";
|
package/src/styles/base.css
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
--ui-hover: #f2f2f2;
|
|
18
18
|
--ui-selected: #edf6fa;
|
|
19
19
|
--ui-shadow: 0 8px 28px #00000014, 0 1px 3px #00000012;
|
|
20
|
+
--ui-floating-shadow: 0 0 0 1px #0000000f, 0 2px 4px #00000006, 0 8px 24px #0000000a;
|
|
20
21
|
}
|
|
21
22
|
* {
|
|
22
23
|
box-sizing: border-box;
|
|
@@ -52,6 +53,12 @@ button:disabled {
|
|
|
52
53
|
cursor: default;
|
|
53
54
|
color: #767676;
|
|
54
55
|
}
|
|
56
|
+
.right-panel input[type="number"],
|
|
57
|
+
.revision-note-count,
|
|
58
|
+
.overflow-trigger,
|
|
59
|
+
.thumbnail-page-number {
|
|
60
|
+
font-variant-numeric: tabular-nums;
|
|
61
|
+
}
|
|
55
62
|
:focus-visible {
|
|
56
63
|
outline: 2px solid var(--accent);
|
|
57
64
|
outline-offset: 3px;
|