konpeki 0.3.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.
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../../composition/types.ts";
6
6
  import { componentInstanceLabel, type Draft } from "../lib/model.ts";
7
7
  import { InspectorPanel } from "./InspectorPanel.tsx";
8
+ import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
8
9
 
9
10
  export type RightPanelView = "settings" | "layers";
10
11
 
@@ -20,6 +21,7 @@ function LayersPanel({
20
21
  onReorderPaintOrder: (ids: string[]) => void;
21
22
  }) {
22
23
  const [dragging, setDragging] = useState<string>();
24
+ const [dropIndex, setDropIndex] = useState<number>();
23
25
  const layers = [...slide.paintOrder].reverse();
24
26
  function moveLayer(id: string, toIndex: number) {
25
27
  const next = layers.filter((item) => item !== id);
@@ -33,7 +35,36 @@ function LayersPanel({
33
35
  <span>Front to back</span>
34
36
  </div>
35
37
  {layers.length ? (
36
- <div className="layer-list">
38
+ <div className="layer-list"
39
+ onDragOver={(event) => {
40
+ if (!dragging) return;
41
+ event.preventDefault();
42
+ const rows = [...event.currentTarget.querySelectorAll<HTMLElement>(".layer-row")];
43
+ const next = rows.findIndex((row) => {
44
+ const rect = row.getBoundingClientRect();
45
+ return event.clientY < rect.top + rect.height / 2;
46
+ });
47
+ const boundary = next < 0 ? layers.length : next;
48
+ const sourceIndex = layers.indexOf(dragging);
49
+ const unchanged = boundary === sourceIndex || boundary === sourceIndex + 1;
50
+ event.dataTransfer.dropEffect = unchanged ? "none" : "move";
51
+ setDropIndex(unchanged ? undefined : boundary);
52
+ }}
53
+ onDragLeave={(event) => {
54
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
55
+ setDropIndex(undefined);
56
+ }
57
+ }}
58
+ onDrop={(event) => {
59
+ if (!dragging) return;
60
+ event.preventDefault();
61
+ if (dropIndex !== undefined) {
62
+ moveLayer(dragging, dropIndex > layers.indexOf(dragging) ? dropIndex - 1 : dropIndex);
63
+ }
64
+ setDragging(undefined);
65
+ setDropIndex(undefined);
66
+ }}
67
+ >
37
68
  {layers.map((id, index) => {
38
69
  const component = slide.components.find((item) => item.id === id)!;
39
70
  const groups = slide.groups
@@ -44,23 +75,18 @@ function LayersPanel({
44
75
  <div
45
76
  key={id}
46
77
  data-layer={id}
78
+ data-drop-edge={dropIndex === index ? "before" : dropIndex === layers.length && index === layers.length - 1 ? "after" : undefined}
47
79
  className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
48
80
  draggable
49
81
  onDragStart={(event) => {
50
82
  setDragging(id);
83
+ setDropIndex(undefined);
51
84
  event.dataTransfer.effectAllowed = "move";
52
85
  event.dataTransfer.setData("text/plain", id);
53
86
  }}
54
- onDragEnd={() => setDragging(undefined)}
55
- onDragOver={(event) => {
56
- event.preventDefault();
57
- event.dataTransfer.dropEffect = "move";
58
- }}
59
- onDrop={(event) => {
60
- event.preventDefault();
61
- const source = dragging || event.dataTransfer.getData("text/plain");
62
- if (source && source !== id) moveLayer(source, index);
87
+ onDragEnd={() => {
63
88
  setDragging(undefined);
89
+ setDropIndex(undefined);
64
90
  }}
65
91
  >
66
92
  <span className="layer-drag" aria-hidden="true">⠿</span>
@@ -112,6 +138,7 @@ export function RightPanel({
112
138
  onSelectSlide,
113
139
  onSelectComponent,
114
140
  onSelectVectorElement,
141
+ onSelectOverflow,
115
142
  onReorderPaintOrder,
116
143
  onComponent,
117
144
  onSlide,
@@ -132,6 +159,7 @@ export function RightPanel({
132
159
  onSelectSlide: () => void;
133
160
  onSelectComponent: (id: string) => void;
134
161
  onSelectVectorElement: (id?: string) => void;
162
+ onSelectOverflow: (componentId: string, elementId?: string) => void;
135
163
  onReorderPaintOrder: (ids: string[]) => void;
136
164
  onComponent: (component: CompositionComponent, mergeKey?: string) => void;
137
165
  onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
@@ -154,6 +182,9 @@ export function RightPanel({
154
182
  </button>
155
183
  ))}
156
184
  </div>
185
+ <div className="overflow-control" inert={collapsed}>
186
+ <VectorOverflowWarning slide={slide} theme={draft.theme} onSelect={onSelectOverflow} />
187
+ </div>
157
188
  <button
158
189
  type="button"
159
190
  className="panel-toggle"
@@ -174,6 +205,7 @@ export function RightPanel({
174
205
  <div className="right-panel-body" inert={collapsed}>
175
206
  {view === "settings" ? (
176
207
  <InspectorPanel
208
+ key={component?.id ?? slide.id}
177
209
  component={component}
178
210
  componentLabel={componentLabel}
179
211
  slide={slide}
@@ -1,22 +1,31 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useId, useRef, useState } from "react";
2
2
  import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
3
+ import { componentInstanceLabel } from "../lib/model.ts";
3
4
 
4
5
  /** Measure rendered text, including nested SVG transforms, after fonts settle. */
5
- export function VectorOverflowWarning({ slide, theme }: {
6
+ export function VectorOverflowWarning({ slide, theme, onSelect }: {
6
7
  slide: CompositionSlide;
7
8
  theme: CompositionDocument["theme"];
9
+ onSelect: (componentId: string, elementId?: string) => void;
8
10
  }) {
9
- const [count, setCount] = useState(0);
11
+ const [issues, setIssues] = useState<{ componentId: string; elementId?: string; text: string }[]>([]);
12
+ const id = useId();
13
+ const popover = useRef<HTMLDivElement>(null);
14
+ useEffect(() => { popover.current?.hidePopover(); }, [slide.id]);
10
15
  useEffect(() => {
11
16
  let cancelled = false;
12
17
  let frame = 0;
13
18
  function measure() {
14
19
  frame = requestAnimationFrame(() => {
15
20
  if (cancelled) return;
16
- let overflow = 0;
21
+ const overflow: typeof issues = [];
22
+ function add(text: Element) {
23
+ const componentId = text.closest<HTMLElement>("[data-component]")?.dataset.component;
24
+ if (componentId) overflow.push({ componentId, elementId: text.getAttribute("data-vector-element") ?? undefined, text: text.textContent?.trim().slice(0, 80) ?? "Text" });
25
+ }
17
26
  document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
18
27
  const surface = text.parentElement;
19
- if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) overflow++;
28
+ if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) add(text);
20
29
  });
21
30
  document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
22
31
  const root = svg.getScreenCTM();
@@ -32,15 +41,33 @@ export function VectorOverflowWarning({ slide, theme }: {
32
41
  if (corners.some(([x, y]) => {
33
42
  const p = new DOMPoint(x, y).matrixTransform(transform);
34
43
  return p.x < view.x - 1 || p.y < view.y - 1 || p.x > view.x + view.width + 1 || p.y > view.y + view.height + 1;
35
- })) overflow++;
44
+ })) add(text);
36
45
  });
37
46
  });
38
- setCount(overflow);
47
+ setIssues(overflow);
48
+ if (!overflow.length) popover.current?.hidePopover();
39
49
  });
40
50
  }
41
51
  void document.fonts.ready.then(measure);
42
52
  document.fonts.addEventListener("loadingdone", measure);
43
53
  return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
44
54
  }, [slide, theme]);
45
- return count ? <p role="status" className="field-error">{count} text {count === 1 ? "element exceeds" : "elements exceed"} its component viewport on this slide. Revise the text or geometry; nothing was resized.</p> : null;
55
+ return <>
56
+ <span className="sr-only" role="status">{issues.length ? `${issues.length} text overflow ${issues.length === 1 ? "warning" : "warnings"} on this page` : ""}</span>
57
+ <button type="button" className="overflow-trigger" popoverTarget={id} disabled={!issues.length}
58
+ aria-label={`${issues.length} text overflow warnings`} title="Text overflow warnings">
59
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3 18 17H2ZM10 8v4m0 2v.5" /></svg>
60
+ <span>{issues.length}</span>
61
+ </button>
62
+ <div id={id} ref={popover} popover="auto" className="overflow-popover">
63
+ <strong>Text outside its frame</strong>
64
+ <p>Select an item to revise its text or size. Nothing was resized automatically.</p>
65
+ <ul>{issues.map(issue => <li key={`${issue.componentId}:${issue.elementId ?? "native"}`}>
66
+ <button type="button" onClick={() => { popover.current?.hidePopover(); onSelect(issue.componentId, issue.elementId); }}>
67
+ <strong>{componentInstanceLabel(slide.components, issue.componentId)}{issue.elementId ? ` · ${issue.elementId}` : ""}</strong>
68
+ <span>{issue.text}</span>
69
+ </button>
70
+ </li>)}</ul>
71
+ </div>
72
+ </>;
46
73
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, type ReactNode, 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";
@@ -103,13 +103,30 @@ export function WorkspaceChrome({
103
103
  onComponentTool: (kind: CompositionComponent["kind"]) => void;
104
104
  }) {
105
105
  const browserMenu = useRef<HTMLDetailsElement>(null);
106
+ const [browserMenuOpen, setBrowserMenuOpen] = useState(false);
106
107
  const importInput = useRef<HTMLInputElement>(null);
107
- function closeBrowserMenu() {
108
- if (browserMenu.current) browserMenu.current.open = false;
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();
109
113
  }
110
114
  useEffect(() => {
111
- if (leftCollapsed) closeBrowserMenu();
115
+ if (leftCollapsed) closeBrowserMenu(false);
112
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
+ }, []);
113
130
  return (
114
131
  <>
115
132
  <div className={`left-sidebar${leftCollapsed ? " collapsed" : ""}`}>
@@ -134,18 +151,16 @@ export function WorkspaceChrome({
134
151
  Enter a title.
135
152
  </span>
136
153
  )}
137
- {fileStatus && fileStatus !== "saved" && (
154
+ {fileStatus && (
138
155
  <span
139
- className={`file-status ${fileStatus}${leftCollapsed ? " sr-only" : ""}`}
156
+ className={`file-status ${fileStatus}`}
140
157
  role="status"
141
158
  aria-live="polite"
142
- title={fileStatus === "conflict"
143
- ? "The file changed elsewhere while this browser has edits."
144
- : 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"}
145
160
  >
146
- {fileStatus === "loading" ? "Opening"
161
+ <span className="sr-only">{fileStatus === "saved" ? "Saved" : fileStatus === "loading" ? "Opening"
147
162
  : fileStatus === "saving" ? "Saving"
148
- : fileStatus === "conflict" ? "Conflict" : "Save error"}
163
+ : fileStatus === "conflict" ? "Conflict" : "Save error"}</span>
149
164
  </span>
150
165
  )}
151
166
  <button type="button" className="panel-toggle"
@@ -184,17 +199,31 @@ export function WorkspaceChrome({
184
199
  </svg>
185
200
  </button>
186
201
  {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>
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>
195
224
  <div className="browser-menu-actions">
196
225
  <button type="button" onClick={() => importInput.current?.click()}>
197
- Import JSON
226
+ Import
198
227
  </button>
199
228
  <input
200
229
  ref={importInput}
@@ -214,33 +243,32 @@ export function WorkspaceChrome({
214
243
  closeBrowserMenu();
215
244
  browserTools.onDownloadJSON();
216
245
  }}>
217
- Download JSON
246
+ Export
218
247
  </button>
219
- <button type="button" onClick={() => {
248
+ <button type="button" className="secondary" onClick={() => {
220
249
  closeBrowserMenu();
221
250
  browserTools.onStartBlank();
222
251
  }}>
223
252
  Start blank
224
253
  </button>
225
- <button type="button" onClick={() => {
254
+ <button type="button" className="secondary" onClick={() => {
226
255
  closeBrowserMenu();
227
256
  browserTools.onReset();
228
257
  }}>
229
258
  {browserTools.example ? "Reset example" : "Reset local draft"}
230
259
  </button>
231
260
  </div>
232
- <p className="browser-agent-handoff">
233
- To continue with a coding agent, download the editable JSON and
234
- follow the{" "}
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>
235
264
  <a
236
265
  href="https://github.com/vcfgdev/konpeki/blob/main/SETUP.md"
237
266
  target="_blank"
238
267
  rel="noreferrer"
239
268
  >
240
- setup guidance
269
+ Setup guide
241
270
  </a>
242
- .
243
- </p>
271
+ </div>
244
272
  </div>
245
273
  </details>
246
274
  )}
@@ -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,
@@ -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)),
@@ -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;