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
@@ -1,4 +1,4 @@
1
- import type { RefObject } from "react";
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="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,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 && fileStatus !== "saved" && (
154
+ {fileStatus && (
118
155
  <span
119
156
  className={`file-status ${fileStatus}`}
120
157
  role="status"
121
158
  aria-live="polite"
122
- title={fileStatus === "conflict"
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
@@ -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,
@@ -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
+ }
@@ -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
- let cancelled = false;
53
- void loadFileSession(token).then((session) => {
54
- if (cancelled) return;
55
- state.current = {
56
- ready: true,
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
- setStatus(current => current === "error" ? "saved" : current);
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.onNotice(
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
- if (buildPending.current) {
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.onNotice(
167
+ callbacksRef.current.onError(
178
168
  conflict
179
- ? "The composition changed outside this browser. Your edits were not overwritten."
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";
@@ -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;