konpeki 0.1.0 → 0.2.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/src/app/App.tsx CHANGED
@@ -28,7 +28,8 @@ import {
28
28
  import { clearStoredDraft, loadDraft, persistDraft } from "../lib/storage.ts";
29
29
  import { diagramDefinition } from "../../composition/visualizations.ts";
30
30
  import { Canvas } from "../components/Canvas.tsx";
31
- import { LeftPanel } from "../components/LeftPanel.tsx";
31
+ import { BuildOrb } from "../components/BuildOrb.tsx";
32
+ import { LeftPanel, type LeftPanelView } from "../components/LeftPanel.tsx";
32
33
  import { Presentation } from "../components/Presentation.tsx";
33
34
  import {
34
35
  RightPanel,
@@ -39,6 +40,8 @@ import { exampleDraft } from "../lib/examples.ts";
39
40
  import { exportPagePNG } from "../lib/export-png.ts";
40
41
  import { fileSessionToken } from "../lib/file-session.ts";
41
42
  import { useFileSession } from "../lib/use-file-session.ts";
43
+ import { RevisionNotes } from "../components/RevisionNotes.tsx";
44
+ import type { ReviewTarget } from "../lib/review.ts";
42
45
 
43
46
  const sessionToken = fileSessionToken();
44
47
 
@@ -72,15 +75,15 @@ export function App() {
72
75
  const [error, setError] = useState(loaded.error ?? "");
73
76
  const [notice, setNotice] = useState<{
74
77
  message: string;
75
- undoableFor?: Draft;
78
+ tone: "neutral" | "error";
76
79
  }>();
77
80
  const [toastClosing, setToastClosing] = useState(false);
78
81
  const [leftCollapsed, setLeftCollapsed] = useState(false);
82
+ const [leftView, setLeftView] = useState<LeftPanelView>("pages");
79
83
  const [rightCollapsed, setRightCollapsed] = useState(false);
80
84
  const [rightView, setRightView] = useState<RightPanelView>("settings");
81
85
  const [vectorEditRequest, setVectorEditRequest] = useState(0);
82
86
  const [presenting, setPresenting] = useState(false);
83
- const [buildBusy, setBuildBusy] = useState(false);
84
87
  const [exportBusy, setExportBusy] = useState(false);
85
88
  const [vectorSelection, setVectorSelection] = useState<{
86
89
  componentId: string;
@@ -105,7 +108,6 @@ export function App() {
105
108
  ) ? current.present.activeSlideId : document.slides[0].id,
106
109
  selected: undefined,
107
110
  }));
108
- showNotice("Agent changes loaded. Undo restores the previous composition.");
109
111
  },
110
112
  onNotice: showNotice,
111
113
  onError: setError,
@@ -144,7 +146,7 @@ export function App() {
144
146
  }, [draft, blocked, loaded.example, loaded.fileSession]);
145
147
  useEffect(() => {
146
148
  if (!notice) return;
147
- const timer = window.setTimeout(() => setToastClosing(true), 5000);
149
+ const timer = window.setTimeout(() => setToastClosing(true), 3000);
148
150
  return () => window.clearTimeout(timer);
149
151
  }, [notice]);
150
152
  useEffect(() => {
@@ -155,9 +157,24 @@ export function App() {
155
157
  }, 280);
156
158
  return () => window.clearTimeout(timer);
157
159
  }, [toastClosing]);
160
+ useEffect(() => {
161
+ const narrow = window.matchMedia("(max-width: 1200px)");
162
+ const compact = window.matchMedia("(max-width: 900px)");
163
+ const adaptPanels = () => {
164
+ setRightCollapsed(narrow.matches);
165
+ setLeftCollapsed(compact.matches);
166
+ };
167
+ adaptPanels();
168
+ narrow.addEventListener("change", adaptPanels);
169
+ compact.addEventListener("change", adaptPanels);
170
+ return () => {
171
+ narrow.removeEventListener("change", adaptPanels);
172
+ compact.removeEventListener("change", adaptPanels);
173
+ };
174
+ }, []);
158
175
  useEffect(() => {
159
176
  function keydown(event: KeyboardEvent) {
160
- if (presenting) return;
177
+ if (presenting || fileSession.building) return;
161
178
  const modifier = event.metaKey || event.ctrlKey;
162
179
  const target = event.target;
163
180
  const editable =
@@ -166,12 +183,13 @@ export function App() {
166
183
  const slideRename =
167
184
  target instanceof HTMLInputElement &&
168
185
  target.getAttribute("aria-label") === "Page name";
186
+ const noteEditing = target instanceof HTMLTextAreaElement && target.id === "revision-note";
169
187
  if (event.key === "Escape" && !editable && vectorSelection) {
170
188
  setVectorSelection(undefined);
171
189
  return;
172
190
  }
173
191
  if (modifier && !event.altKey && event.key.toLowerCase() === "z") {
174
- if (slideRename) return;
192
+ if (slideRename || noteEditing) return;
175
193
  event.preventDefault();
176
194
  setHistory((current) =>
177
195
  event.shiftKey ? redoHistory(current) : undoHistory(current),
@@ -183,7 +201,7 @@ export function App() {
183
201
  !event.altKey &&
184
202
  event.key.toLowerCase() === "y"
185
203
  ) {
186
- if (slideRename) return;
204
+ if (slideRename || noteEditing) return;
187
205
  event.preventDefault();
188
206
  setHistory(redoHistory);
189
207
  return;
@@ -213,10 +231,10 @@ export function App() {
213
231
  }
214
232
  window.addEventListener("keydown", keydown);
215
233
  return () => window.removeEventListener("keydown", keydown);
216
- }, [draft, presenting, selected, vectorSelection]);
217
- function showNotice(message: string, undoableFor?: Draft) {
234
+ }, [draft, presenting, selected, vectorSelection, fileSession.building]);
235
+ function showNotice(message: string, tone: "neutral" | "error" = "neutral") {
218
236
  setToastClosing(false);
219
- setNotice({ message, undoableFor });
237
+ setNotice({ message: message.replace(/\.$/, ""), tone });
220
238
  }
221
239
  function updateDraft(
222
240
  next: Draft,
@@ -256,7 +274,7 @@ export function App() {
256
274
  function addNewSlide() {
257
275
  const result = appendSlide(draft);
258
276
  if (!result.slideId) {
259
- showNotice("Could not add a page until the composition is valid.");
277
+ showNotice("Could not add a page until the composition is valid.", "error");
260
278
  return;
261
279
  }
262
280
  focusAfterHistory.current = true;
@@ -268,7 +286,7 @@ export function App() {
268
286
  }
269
287
  function removeExistingSlide(id: string) {
270
288
  if (draft.slides.length === 1) {
271
- showNotice("A document needs at least one page.");
289
+ showNotice("A document needs at least one page.", "error");
272
290
  return;
273
291
  }
274
292
  const index = draft.slides.findIndex((item) => item.id === id);
@@ -283,15 +301,11 @@ export function App() {
283
301
  activeSlideId: nextActiveSlideId,
284
302
  selected: id === slide.id ? undefined : selected,
285
303
  });
286
- showNotice("Page deleted.", next);
304
+ showNotice("Page deleted.");
287
305
  }
288
306
  function finishEdit() {
289
307
  setHistory(finishHistoryEdit);
290
308
  }
291
- function performUndo() {
292
- focusAfterHistory.current = true;
293
- setHistory(undoHistory);
294
- }
295
309
  function performRedo() {
296
310
  focusAfterHistory.current = true;
297
311
  setHistory(redoHistory);
@@ -407,7 +421,7 @@ export function App() {
407
421
  function remove(id: string) {
408
422
  const issue = componentRemovalIssue(draft, id, slide.id);
409
423
  if (issue) {
410
- showNotice(issue);
424
+ showNotice(issue, "error");
411
425
  return;
412
426
  }
413
427
  const index = slide.components.findIndex((component) => component.id === id);
@@ -419,20 +433,36 @@ export function App() {
419
433
  : selected;
420
434
  focusAfterHistory.current = true;
421
435
  updateDraft(next, { selected: nextSelected });
422
- showNotice("Component deleted.", next);
436
+ showNotice("Component deleted.");
437
+ }
438
+ function selectNoteTarget(target: ReviewTarget & { id?: string }) {
439
+ const page = draft.slides.find(s => s.id === target.slideId);
440
+ if (!page) return;
441
+ selectSlide(page.id);
442
+ setLeftCollapsed(false);
443
+ setLeftView("notes");
444
+ const component = page.components.find(c => c.id === target.componentId);
445
+ if (component) {
446
+ selectComponent(component.id);
447
+ if (target.elementId && component.customVisual?.format === "vector" && component.customVisual.elements.some(e => e.id === target.elementId))
448
+ setVectorSelection({ componentId: component.id, elementId: target.elementId });
449
+ }
450
+ if (target.id) requestAnimationFrame(() => document.getElementById(`note-${target.id}`)?.scrollIntoView({ block: "nearest" }));
423
451
  }
424
452
  async function requestBuild() {
425
- setBuildBusy(true);
426
453
  try {
427
- const instruction = selected
454
+ const instruction = fileSession.review.notes.some(note => !note.resolved)
455
+ ? "Apply the attached revision notes to their named targets. Preserve unrelated human edits. Validate and inspect the result, then acknowledge this request with konpeki finish."
456
+ : selected
428
457
  ? "Review the selected component in context and build it from the current saved composition."
429
458
  : "Review the current slides and build them from the current saved composition.";
430
- if (!await fileSession.build(instruction, slide.id, selected)) return;
431
- showNotice("Build request sent. The agent will reread this saved revision.");
459
+ const requested = await fileSession.build(instruction, slide.id, selected, vectorSelection?.componentId === selected ? vectorSelection?.elementId : undefined);
460
+ if (requested) {
461
+ setNotice(undefined);
462
+ setToastClosing(false);
463
+ }
432
464
  } catch (requestError) {
433
- showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.");
434
- } finally {
435
- setBuildBusy(false);
465
+ showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.", "error");
436
466
  }
437
467
  }
438
468
  async function openComposition(file: File) {
@@ -440,12 +470,12 @@ export function App() {
440
470
  try {
441
471
  raw = await file.text();
442
472
  } catch {
443
- showNotice("Could not read that composition file.");
473
+ showNotice("Could not read that composition file.", "error");
444
474
  return;
445
475
  }
446
476
  const parsed = parseCompositionJSON(raw);
447
477
  if (!parsed.ok) {
448
- showNotice(`Could not open: ${parsed.message}`);
478
+ showNotice(`Could not open: ${parsed.message}`, "error");
449
479
  return;
450
480
  }
451
481
  focusAfterHistory.current = true;
@@ -454,7 +484,7 @@ export function App() {
454
484
  selected: undefined,
455
485
  });
456
486
  setTitleError(false);
457
- showNotice("Editable composition opened. Undo restores the previous document.", parsed.document);
487
+ showNotice("Editable composition opened. Undo restores the previous document.");
458
488
  }
459
489
  function present() {
460
490
  const validation = validateComposition(draft);
@@ -462,6 +492,7 @@ export function App() {
462
492
  const issue = validation.issues[0];
463
493
  showNotice(
464
494
  `Cannot present: ${issue?.path || "document"} ${issue?.message || "is invalid"}.`,
495
+ "error",
465
496
  );
466
497
  return;
467
498
  }
@@ -487,6 +518,7 @@ export function App() {
487
518
  } catch (exportError) {
488
519
  showNotice(
489
520
  `Could not export PNG: ${exportError instanceof Error ? exportError.message : "Unknown error"}`,
521
+ "error",
490
522
  );
491
523
  } finally {
492
524
  setExportBusy(false);
@@ -507,6 +539,9 @@ export function App() {
507
539
  }
508
540
  }
509
541
  const selectedComponent = slide.components.find((c) => c.id === selected);
542
+ const buildState = fileSession.building
543
+ ? fileSession.review.request?.status === "working" ? "working" : "ready"
544
+ : undefined;
510
545
  function useComponentTool(kind: CompositionComponent["kind"]) {
511
546
  add(kind);
512
547
  }
@@ -524,7 +559,7 @@ export function App() {
524
559
  </div>
525
560
  )}
526
561
  <div
527
- className={`workspace ${leftCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""}`}
562
+ className={`workspace ${fileSession.building ? "is-building" : ""} ${leftCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""}`}
528
563
  aria-hidden={presenting || undefined}
529
564
  inert={presenting || undefined}
530
565
  onDragOver={(event) => {
@@ -533,6 +568,10 @@ export function App() {
533
568
  event.dataTransfer.dropEffect = "copy";
534
569
  }}
535
570
  onDrop={(event) => {
571
+ if (fileSession.building) {
572
+ event.preventDefault();
573
+ return;
574
+ }
536
575
  const file = event.dataTransfer.files[0];
537
576
  if (!file) return;
538
577
  event.preventDefault();
@@ -555,13 +594,26 @@ export function App() {
555
594
  exporting={exportBusy}
556
595
  onPresent={present}
557
596
  fileStatus={loaded.fileSession ? fileSession.status : undefined}
558
- building={buildBusy}
597
+ building={fileSession.building}
598
+ buildState={buildState}
559
599
  onBuild={loaded.fileSession ? () => { void requestBuild(); } : undefined}
560
600
  onSelectTool={() => selectComponent()}
561
601
  onComponentTool={useComponentTool}
562
602
  />
603
+ <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
563
604
  <LeftPanel
564
605
  draft={draft}
606
+ view={leftView}
607
+ onView={setLeftView}
608
+ revisionNotes={loaded.fileSession && <RevisionNotes
609
+ document={draft}
610
+ target={{ slideId: slide.id, ...(selected ? { componentId: selected } : {}), ...(vectorSelection?.componentId === selected && vectorSelection?.elementId ? { elementId: vectorSelection.elementId } : {}) }}
611
+ review={fileSession.review}
612
+ disabled={fileSession.building || fileSession.status !== "saved"}
613
+ onAdd={fileSession.addNote}
614
+ onRemove={fileSession.removeNote}
615
+ onSelect={selectNoteTarget}
616
+ />}
565
617
  activeSlideId={slide.id}
566
618
  collapsed={leftCollapsed}
567
619
  onSelectSlide={selectSlide}
@@ -570,6 +622,8 @@ export function App() {
570
622
  />
571
623
  <Canvas
572
624
  key={slide.id}
625
+ revisionNotes={fileSession.review.notes.filter(note => !note.resolved)}
626
+ onSelectNote={selectNoteTarget}
573
627
  draft={draft}
574
628
  activeSlideId={slide.id}
575
629
  selected={selected}
@@ -654,6 +708,30 @@ export function App() {
654
708
  }
655
709
  onEditEnd={finishEdit}
656
710
  />
711
+ </div>
712
+ {fileSession.building && (
713
+ <div className="stage build-loading" role="status" aria-live="polite">
714
+ <div className="build-loading-card">
715
+ <div className="build-nebula"><BuildOrb active nebula /></div>
716
+ {buildState === "working" ? <>
717
+ <strong>Agent working</strong>
718
+ <span>Updating this composition. Editing resumes when the request finishes.</span>
719
+ </> : <>
720
+ <strong>Request ready</strong>
721
+ <span>Copy the handoff prompt to ask your coding agent to apply these changes.</span>
722
+ <button type="button" className="primary" onClick={async () => {
723
+ try {
724
+ await navigator.clipboard.writeText("Pick up my pending Konpeki request and apply the changes.");
725
+ showNotice("Prompt copied");
726
+ } catch {
727
+ showNotice("Could not copy. Ask your coding agent to pick up your pending Konpeki request", "error");
728
+ }
729
+ }}>Copy prompt</button>
730
+ </>}
731
+ <button type="button" onClick={() => { void fileSession.cancel().catch(error => showNotice(error.message, "error")); }}>Cancel request</button>
732
+ </div>
733
+ </div>
734
+ )}
657
735
  </div>
658
736
  {presenting && (
659
737
  <Presentation
@@ -663,31 +741,11 @@ export function App() {
663
741
  />
664
742
  )}
665
743
  <div
666
- className={`toast ${notice ? "visible" : ""} ${toastClosing ? "closing" : ""}`}
744
+ className={`toast ${notice ? "visible" : ""} ${notice?.tone === "error" ? "error" : ""} ${toastClosing ? "closing" : ""}`}
667
745
  >
668
- <span role="status" aria-live="polite">
746
+ <span role={notice?.tone === "error" ? "alert" : "status"} aria-live={notice?.tone === "error" ? "assertive" : "polite"}>
669
747
  {notice?.message}
670
748
  </span>
671
- {notice?.undoableFor === draft && (
672
- <button
673
- type="button"
674
- onClick={() => {
675
- performUndo();
676
- showNotice("Change undone.");
677
- }}
678
- >
679
- Undo
680
- </button>
681
- )}
682
- {notice && (
683
- <button
684
- type="button"
685
- aria-label="Dismiss notification"
686
- onClick={() => setToastClosing(true)}
687
- >
688
- ×
689
- </button>
690
- )}
691
749
  </div>
692
750
  </>
693
751
  );
@@ -0,0 +1,40 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /** Decorative, deterministic particles: no timers or animation dependency. */
4
+ export function BuildOrb({ active = false, nebula = false }: { active?: boolean; nebula?: boolean }) {
5
+ const count = nebula ? 96 : 24;
6
+ return <svg className={`build-orb${active ? " active" : ""}${nebula ? " nebula" : ""}`} viewBox="0 0 100 100" aria-hidden="true">
7
+ {Array.from({ length: count }, (_, i) => {
8
+ const angle = i / count * Math.PI * 2 - Math.PI / 2;
9
+ const y = nebula ? 34 * (2 * (i + 0.5) / count - 1) : Math.sin(angle) * 34;
10
+ const radius = Math.sqrt(34 ** 2 - y ** 2);
11
+ const x = nebula ? Math.cos(i * 2.39996) * radius : Math.cos(angle) * 34;
12
+ const squareEdge = i / count * 4;
13
+ const squareVertices = [[-28, -28], [28, -28], [28, 28], [-28, 28]];
14
+ const squareFrom = squareVertices[Math.floor(squareEdge) % 4];
15
+ const squareTo = squareVertices[(Math.floor(squareEdge) + 1) % 4];
16
+ const squareT = squareEdge % 1;
17
+ const edge = i / count * 3;
18
+ const vertices = [[0, -36], [32, 24], [-32, 24]];
19
+ const from = vertices[Math.floor(edge) % 3];
20
+ const to = vertices[(Math.floor(edge) + 1) % 3];
21
+ const t = edge % 1;
22
+ const hexEdge = i / count * 6;
23
+ const hexAngle = Math.floor(hexEdge) * Math.PI / 3 - Math.PI / 2;
24
+ const hexT = hexEdge % 1;
25
+ const style = {
26
+ "--x": `${x}px`, "--y": `${y}px`,
27
+ "--sx": `${squareFrom[0] + (squareTo[0] - squareFrom[0]) * squareT}px`,
28
+ "--sy": `${squareFrom[1] + (squareTo[1] - squareFrom[1]) * squareT}px`,
29
+ "--tx": `${from[0] + (to[0] - from[0]) * t}px`,
30
+ "--ty": `${from[1] + (to[1] - from[1]) * t}px`,
31
+ "--hx": `${34 * ((1 - hexT) * Math.cos(hexAngle) + hexT * Math.cos(hexAngle + Math.PI / 3))}px`,
32
+ "--hy": `${34 * ((1 - hexT) * Math.sin(hexAngle) + hexT * Math.sin(hexAngle + Math.PI / 3))}px`,
33
+ "--radius": `${radius}px`,
34
+ "--phase": `${-i * 2.39996 / (2 * Math.PI) * 12}s`,
35
+ opacity: nebula ? 0.65 : 1,
36
+ } as CSSProperties;
37
+ return <circle key={i} cx="50" cy="50" r={nebula ? 0.8 + (i % 3) * 0.45 : 2.5} style={style} />;
38
+ })}
39
+ </svg>;
40
+ }
@@ -26,6 +26,7 @@ import { composerPalette, themeLabel } from "../lib/theme.ts";
26
26
  import { getTheme } from "../../design/themes/index.ts";
27
27
  import { resolveVectorAttribute } from "../../composition/theme-tokens.ts";
28
28
  import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
29
+ import type { RevisionNote } from "../lib/review.ts";
29
30
 
30
31
  type ResizeCorner = "nw" | "ne" | "sw" | "se";
31
32
 
@@ -656,6 +657,8 @@ export function Canvas({
656
657
  onEditEnd,
657
658
  onAdd,
658
659
  onNotice,
660
+ revisionNotes = [],
661
+ onSelectNote,
659
662
  }: {
660
663
  mode?: "edit" | "present";
661
664
  draft: Draft;
@@ -673,6 +676,8 @@ export function Canvas({
673
676
  at: { x: number; y: number },
674
677
  ) => void;
675
678
  onNotice: (message: string) => void;
679
+ revisionNotes?: RevisionNote[];
680
+ onSelectNote?: (note: RevisionNote) => void;
676
681
  }) {
677
682
  const interactive = mode === "edit";
678
683
  const slide = getSlide(draft, activeSlideId);
@@ -1139,6 +1144,18 @@ export function Canvas({
1139
1144
  </div>
1140
1145
  );
1141
1146
  })}
1147
+ {interactive && revisionNotes.map((note, index) => {
1148
+ if (note.slideId !== slide.id || note.resolved) return null;
1149
+ const component = slide.components.find(c => c.id === note.componentId);
1150
+ if (note.componentId && !component) return null;
1151
+ const rect = component?.preferredRect;
1152
+ const previous = revisionNotes.slice(0, index).filter(n => !n.resolved && n.slideId === note.slideId && n.componentId === note.componentId).length;
1153
+ return <button key={note.id} type="button" className="revision-pin"
1154
+ aria-label={`Revision note ${index + 1}: ${note.text}`} title={note.text}
1155
+ style={{ left: rect ? `clamp(0px, calc(${(rect.x + rect.width) * 100 / slide.canvas.width}% - ${64 + previous * 44}px), calc(100% - 40px))` : `${3 + previous * 44}px`, top: rect ? `max(0px, calc(${rect.y * 100 / slide.canvas.height}% - 32px))` : "3px", zIndex: slide.paintOrder.length + 4 }}
1156
+ onPointerDown={event => event.stopPropagation()}
1157
+ onClick={event => { event.stopPropagation(); onSelectNote?.(note); }}>{index + 1}</button>;
1158
+ })}
1142
1159
  {slide.pageNumber?.style !== "none" && (
1143
1160
  <span
1144
1161
  className={`slide-page-number color-${slide.pageNumber?.color ?? "muted"}`}
@@ -57,6 +57,29 @@ const optionLabels: Record<string, string> = {
57
57
  "full-grid": "Full grid",
58
58
  };
59
59
 
60
+ function VectorActionIcon({ action }: { action: "backward" | "forward" | "delete" }) {
61
+ return (
62
+ <svg className="vector-action-icon" viewBox="0 0 20 20" aria-hidden="true">
63
+ {action === "delete" ? (
64
+ <>
65
+ <path d="M4 5h12M8 2.75h4M6 5l.65 11h6.7L14 5" />
66
+ <path d="M8.25 8v5M11.75 8v5" />
67
+ </>
68
+ ) : (
69
+ <>
70
+ <rect x="3" y="7" width="8" height="8" rx="1.5" />
71
+ <path d="M7 4h6a2 2 0 0 1 2 2v6" />
72
+ {action === "backward" ? (
73
+ <path d="m12.5 10 2.5 2.5 2.5-2.5" />
74
+ ) : (
75
+ <path d="m12.5 6.5 2.5-2.5 2.5 2.5" />
76
+ )}
77
+ </>
78
+ )}
79
+ </svg>
80
+ );
81
+ }
82
+
60
83
  function previewCount(key: string, value: string) {
61
84
  if (key === "tableStyle") return 6;
62
85
  if (key !== "layout") return 4;
@@ -502,7 +525,6 @@ export function InspectorPanel({
502
525
  {component.customVisual && (
503
526
  <fieldset className="custom-visual-summary">
504
527
  <legend>Agent visual</legend>
505
- <p>{component.customVisual.description}</p>
506
528
  <dl>
507
529
  <div>
508
530
  <dt>Source</dt>
@@ -523,11 +545,11 @@ export function InspectorPanel({
523
545
  <dd>{component.customVisual.fit ?? "contain"}</dd>
524
546
  </div>
525
547
  </dl>
526
- <p className="field-help">
527
- {component.customVisual.format === "vector"
528
- ? "Select the visual and press Enter or choose Edit elements. Double-click an element to edit its text or attributes here. Only top-level line endpoints have drag handles; paths use attribute controls."
529
- : "This legacy SVG is an opaque compatibility fallback. Ask an agent to convert it to editable vector elements."}
530
- </p>
548
+ {component.customVisual.format !== "vector" && (
549
+ <p className="field-help">
550
+ This SVG is opaque to the editor. Ask an agent to convert it to editable vector elements.
551
+ </p>
552
+ )}
531
553
  {vectorVisual && (
532
554
  <>
533
555
  <Field label="Vector element">
@@ -553,6 +575,8 @@ export function InspectorPanel({
553
575
  <button
554
576
  key={direction}
555
577
  type="button"
578
+ aria-label={direction === -1 ? "Send backward" : "Bring forward"}
579
+ title={direction === -1 ? "Send backward" : "Bring forward"}
556
580
  disabled={moveVectorElement(vectorVisual.elements, vectorElement.id, direction) === vectorVisual.elements}
557
581
  onClick={() => onComponent({
558
582
  ...component,
@@ -561,10 +585,16 @@ export function InspectorPanel({
561
585
  elements: moveVectorElement(vectorVisual.elements, vectorElement.id, direction),
562
586
  },
563
587
  })}
564
- >{direction === -1 ? "Send backward" : "Bring forward"}</button>
588
+ >
589
+ <VectorActionIcon action={direction === -1 ? "backward" : "forward"} />
590
+ {direction === -1 ? "Back" : "Forward"}
591
+ </button>
565
592
  ))}
566
593
  <button
567
594
  type="button"
595
+ className="vector-delete-action"
596
+ aria-label="Delete element"
597
+ title="Delete element"
568
598
  onClick={() => {
569
599
  onComponent({
570
600
  ...component,
@@ -575,7 +605,10 @@ export function InspectorPanel({
575
605
  });
576
606
  onSelectVectorElement(undefined);
577
607
  }}
578
- >Delete element</button>
608
+ >
609
+ <VectorActionIcon action="delete" />
610
+ Delete
611
+ </button>
579
612
  </div>
580
613
  {(vectorElement.kind === "text" || vectorElement.kind === "tspan") && (
581
614
  <Field label="Text">
@@ -1,10 +1,12 @@
1
- import type { CSSProperties } from "react";
1
+ import type { CSSProperties, ReactNode } from "react";
2
2
  import type {
3
3
  CompositionSlide,
4
4
  ThemeMode,
5
5
  } from "../../composition/types.ts";
6
6
  import type { Draft } from "../lib/model.ts";
7
7
 
8
+ export type LeftPanelView = "pages" | "notes";
9
+
8
10
  function ThumbnailCanvas({
9
11
  slide,
10
12
  themeMode,
@@ -50,6 +52,9 @@ export function LeftPanel({
50
52
  draft,
51
53
  activeSlideId,
52
54
  collapsed,
55
+ view,
56
+ onView,
57
+ revisionNotes,
53
58
  onSelectSlide,
54
59
  onAddSlide,
55
60
  onRemoveSlide,
@@ -57,6 +62,9 @@ export function LeftPanel({
57
62
  draft: Draft;
58
63
  activeSlideId: string;
59
64
  collapsed: boolean;
65
+ view: LeftPanelView;
66
+ onView: (view: LeftPanelView) => void;
67
+ revisionNotes?: ReactNode;
60
68
  onSelectSlide: (id: string) => void;
61
69
  onAddSlide: () => void;
62
70
  onRemoveSlide: (id: string) => void;
@@ -64,9 +72,13 @@ export function LeftPanel({
64
72
  return (
65
73
  <aside className={`left-panel${collapsed ? " collapsed" : ""}`} aria-label="Workspace panel" inert={collapsed}>
66
74
  <div className="panel-toolbar">
67
- <span className="panel-heading">Pages</span>
75
+ {revisionNotes ? <div className="panel-tabs" aria-label="Left panel view">
76
+ {(["pages", "notes"] as const).map(tab => <button key={tab} type="button"
77
+ aria-pressed={view === tab} className={view === tab ? "active" : ""}
78
+ onClick={() => onView(tab)}>{tab === "pages" ? "Pages" : "Notes"}</button>)}
79
+ </div> : <span className="panel-heading">Pages</span>}
68
80
  </div>
69
- <div className="slide-list">
81
+ <div className="slide-list" hidden={!!revisionNotes && view !== "pages"}>
70
82
  {draft.slides.map((item, index) => (
71
83
  <div key={item.id} className="slide-thumbnail-item">
72
84
  <button
@@ -102,6 +114,7 @@ export function LeftPanel({
102
114
  <span aria-hidden="true">+</span>
103
115
  </button>
104
116
  </div>
117
+ {revisionNotes && <div className="left-notes" hidden={view !== "notes"}>{revisionNotes}</div>}
105
118
  </aside>
106
119
  );
107
120
  }
@@ -0,0 +1,56 @@
1
+ import { useState } from "react";
2
+ import type { CompositionDocument } from "../../composition/types.ts";
3
+ import { componentInstanceLabel } from "../lib/model.ts";
4
+ import { activeRequest, type ReviewState, type ReviewTarget } from "../lib/review.ts";
5
+
6
+ export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect }: {
7
+ document: CompositionDocument;
8
+ target: ReviewTarget;
9
+ review: ReviewState;
10
+ disabled: boolean;
11
+ onAdd: (target: ReviewTarget, text: string) => Promise<void>;
12
+ onRemove: (id: string) => Promise<void>;
13
+ onSelect: (target: ReviewTarget) => void;
14
+ }) {
15
+ const [drafts, setDrafts] = useState<Record<string, string>>({});
16
+ const [saving, setSaving] = useState(false);
17
+ const [error, setError] = useState("");
18
+ const key = JSON.stringify(target);
19
+ const text = drafts[key] ?? "";
20
+ function label(value: ReviewTarget) {
21
+ const slide = document.slides.find(s => s.id === value.slideId);
22
+ if (!slide) return "Deleted page";
23
+ const component = slide.components.find(c => c.id === value.componentId);
24
+ if (value.componentId && !component) return `${slide.name} · Deleted component`;
25
+ const name = component ? componentInstanceLabel(slide.components, component.id) : "Page";
26
+ const element = value.elementId && component?.customVisual?.format === "vector"
27
+ ? component.customVisual.elements.find(e => e.id === value.elementId) : undefined;
28
+ return `${slide.name} · ${name}${value.elementId ? ` · ${element ? value.elementId : "Deleted element"}` : ""}`;
29
+ }
30
+ async function run(action: () => Promise<void>) {
31
+ setSaving(true); setError("");
32
+ try { await action(); }
33
+ catch (error) { setError(error instanceof Error ? error.message : "Could not save revision note"); }
34
+ finally { setSaving(false); }
35
+ }
36
+ const pending = review.notes.filter(n => !n.resolved);
37
+ return <section className="revision-notes" aria-label="Revision notes">
38
+ <form onSubmit={event => { event.preventDefault(); void run(async () => {
39
+ await onAdd(target, text);
40
+ setDrafts(current => ({ ...current, [key]: "" }));
41
+ }); }}>
42
+ <label htmlFor="revision-note">Request a change</label>
43
+ <p className="revision-note-scope">{label(target)}</p>
44
+ <textarea id="revision-note" value={text} maxLength={4000} rows={3} placeholder="Describe what to change…" disabled={disabled || saving}
45
+ onChange={event => setDrafts(current => ({ ...current, [key]: event.target.value }))} />
46
+ <div className="revision-note-submit"><button type="submit" disabled={disabled || saving || !text.trim()}>{saving ? "Saving…" : "Add note"}</button></div>
47
+ </form>
48
+ {error && <p role="alert" className="revision-note-error">{error}</p>}
49
+ <p className="revision-note-count">{pending.length} pending</p>
50
+ {pending.length > 0 && <ol className="revision-note-list">{pending.map((note, i) => <li key={note.id} id={`note-${note.id}`}>
51
+ <button type="button" className="revision-note-target" onClick={() => onSelect(note)}><span>{i + 1}</span>{label(note)}</button>
52
+ <p>{note.text}</p>
53
+ <button type="button" className="revision-note-remove" disabled={disabled || saving || activeRequest(review.request)} aria-label={`Remove note ${i + 1}`} onClick={() => { void run(() => onRemove(note.id)); }}>Remove</button>
54
+ </li>)}</ol>}
55
+ </section>;
56
+ }