konpeki 0.1.1 → 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,9 +75,11 @@ export function App() {
72
75
  const [error, setError] = useState(loaded.error ?? "");
73
76
  const [notice, setNotice] = useState<{
74
77
  message: string;
78
+ tone: "neutral" | "error";
75
79
  }>();
76
80
  const [toastClosing, setToastClosing] = useState(false);
77
81
  const [leftCollapsed, setLeftCollapsed] = useState(false);
82
+ const [leftView, setLeftView] = useState<LeftPanelView>("pages");
78
83
  const [rightCollapsed, setRightCollapsed] = useState(false);
79
84
  const [rightView, setRightView] = useState<RightPanelView>("settings");
80
85
  const [vectorEditRequest, setVectorEditRequest] = useState(0);
@@ -152,6 +157,21 @@ export function App() {
152
157
  }, 280);
153
158
  return () => window.clearTimeout(timer);
154
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
+ }, []);
155
175
  useEffect(() => {
156
176
  function keydown(event: KeyboardEvent) {
157
177
  if (presenting || fileSession.building) return;
@@ -163,12 +183,13 @@ export function App() {
163
183
  const slideRename =
164
184
  target instanceof HTMLInputElement &&
165
185
  target.getAttribute("aria-label") === "Page name";
186
+ const noteEditing = target instanceof HTMLTextAreaElement && target.id === "revision-note";
166
187
  if (event.key === "Escape" && !editable && vectorSelection) {
167
188
  setVectorSelection(undefined);
168
189
  return;
169
190
  }
170
191
  if (modifier && !event.altKey && event.key.toLowerCase() === "z") {
171
- if (slideRename) return;
192
+ if (slideRename || noteEditing) return;
172
193
  event.preventDefault();
173
194
  setHistory((current) =>
174
195
  event.shiftKey ? redoHistory(current) : undoHistory(current),
@@ -180,7 +201,7 @@ export function App() {
180
201
  !event.altKey &&
181
202
  event.key.toLowerCase() === "y"
182
203
  ) {
183
- if (slideRename) return;
204
+ if (slideRename || noteEditing) return;
184
205
  event.preventDefault();
185
206
  setHistory(redoHistory);
186
207
  return;
@@ -211,9 +232,9 @@ export function App() {
211
232
  window.addEventListener("keydown", keydown);
212
233
  return () => window.removeEventListener("keydown", keydown);
213
234
  }, [draft, presenting, selected, vectorSelection, fileSession.building]);
214
- function showNotice(message: string) {
235
+ function showNotice(message: string, tone: "neutral" | "error" = "neutral") {
215
236
  setToastClosing(false);
216
- setNotice({ message: message.replace(/\.$/, "") });
237
+ setNotice({ message: message.replace(/\.$/, ""), tone });
217
238
  }
218
239
  function updateDraft(
219
240
  next: Draft,
@@ -253,7 +274,7 @@ export function App() {
253
274
  function addNewSlide() {
254
275
  const result = appendSlide(draft);
255
276
  if (!result.slideId) {
256
- showNotice("Could not add a page until the composition is valid.");
277
+ showNotice("Could not add a page until the composition is valid.", "error");
257
278
  return;
258
279
  }
259
280
  focusAfterHistory.current = true;
@@ -265,7 +286,7 @@ export function App() {
265
286
  }
266
287
  function removeExistingSlide(id: string) {
267
288
  if (draft.slides.length === 1) {
268
- showNotice("A document needs at least one page.");
289
+ showNotice("A document needs at least one page.", "error");
269
290
  return;
270
291
  }
271
292
  const index = draft.slides.findIndex((item) => item.id === id);
@@ -400,7 +421,7 @@ export function App() {
400
421
  function remove(id: string) {
401
422
  const issue = componentRemovalIssue(draft, id, slide.id);
402
423
  if (issue) {
403
- showNotice(issue);
424
+ showNotice(issue, "error");
404
425
  return;
405
426
  }
406
427
  const index = slide.components.findIndex((component) => component.id === id);
@@ -414,14 +435,34 @@ export function App() {
414
435
  updateDraft(next, { selected: nextSelected });
415
436
  showNotice("Component deleted.");
416
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" }));
451
+ }
417
452
  async function requestBuild() {
418
453
  try {
419
- 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
420
457
  ? "Review the selected component in context and build it from the current saved composition."
421
458
  : "Review the current slides and build them from the current saved composition.";
422
- await fileSession.build(instruction, slide.id, selected);
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
+ }
423
464
  } catch (requestError) {
424
- showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.");
465
+ showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.", "error");
425
466
  }
426
467
  }
427
468
  async function openComposition(file: File) {
@@ -429,12 +470,12 @@ export function App() {
429
470
  try {
430
471
  raw = await file.text();
431
472
  } catch {
432
- showNotice("Could not read that composition file.");
473
+ showNotice("Could not read that composition file.", "error");
433
474
  return;
434
475
  }
435
476
  const parsed = parseCompositionJSON(raw);
436
477
  if (!parsed.ok) {
437
- showNotice(`Could not open: ${parsed.message}`);
478
+ showNotice(`Could not open: ${parsed.message}`, "error");
438
479
  return;
439
480
  }
440
481
  focusAfterHistory.current = true;
@@ -451,6 +492,7 @@ export function App() {
451
492
  const issue = validation.issues[0];
452
493
  showNotice(
453
494
  `Cannot present: ${issue?.path || "document"} ${issue?.message || "is invalid"}.`,
495
+ "error",
454
496
  );
455
497
  return;
456
498
  }
@@ -476,6 +518,7 @@ export function App() {
476
518
  } catch (exportError) {
477
519
  showNotice(
478
520
  `Could not export PNG: ${exportError instanceof Error ? exportError.message : "Unknown error"}`,
521
+ "error",
479
522
  );
480
523
  } finally {
481
524
  setExportBusy(false);
@@ -496,6 +539,9 @@ export function App() {
496
539
  }
497
540
  }
498
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;
499
545
  function useComponentTool(kind: CompositionComponent["kind"]) {
500
546
  add(kind);
501
547
  }
@@ -549,6 +595,7 @@ export function App() {
549
595
  onPresent={present}
550
596
  fileStatus={loaded.fileSession ? fileSession.status : undefined}
551
597
  building={fileSession.building}
598
+ buildState={buildState}
552
599
  onBuild={loaded.fileSession ? () => { void requestBuild(); } : undefined}
553
600
  onSelectTool={() => selectComponent()}
554
601
  onComponentTool={useComponentTool}
@@ -556,6 +603,17 @@ export function App() {
556
603
  <div className="editor-content" inert={fileSession.building} aria-busy={fileSession.building}>
557
604
  <LeftPanel
558
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
+ />}
559
617
  activeSlideId={slide.id}
560
618
  collapsed={leftCollapsed}
561
619
  onSelectSlide={selectSlide}
@@ -564,6 +622,8 @@ export function App() {
564
622
  />
565
623
  <Canvas
566
624
  key={slide.id}
625
+ revisionNotes={fileSession.review.notes.filter(note => !note.resolved)}
626
+ onSelectNote={selectNoteTarget}
567
627
  draft={draft}
568
628
  activeSlideId={slide.id}
569
629
  selected={selected}
@@ -652,9 +712,23 @@ export function App() {
652
712
  {fileSession.building && (
653
713
  <div className="stage build-loading" role="status" aria-live="polite">
654
714
  <div className="build-loading-card">
655
- <div className="build-loading-track" aria-hidden="true" />
656
- <strong>Waiting for agent</strong>
657
- <span>Your canvas will refresh when changes arrive.</span>
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>
658
732
  </div>
659
733
  </div>
660
734
  )}
@@ -667,9 +741,9 @@ export function App() {
667
741
  />
668
742
  )}
669
743
  <div
670
- className={`toast ${notice ? "visible" : ""} ${toastClosing ? "closing" : ""}`}
744
+ className={`toast ${notice ? "visible" : ""} ${notice?.tone === "error" ? "error" : ""} ${toastClosing ? "closing" : ""}`}
671
745
  >
672
- <span role="status" aria-live="polite">
746
+ <span role={notice?.tone === "error" ? "alert" : "status"} aria-live={notice?.tone === "error" ? "assertive" : "polite"}>
673
747
  {notice?.message}
674
748
  </span>
675
749
  </div>
@@ -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;
@@ -524,7 +547,7 @@ export function InspectorPanel({
524
547
  </dl>
525
548
  {component.customVisual.format !== "vector" && (
526
549
  <p className="field-help">
527
- This legacy SVG is an opaque compatibility fallback. Ask an agent to convert it to editable vector elements.
550
+ This SVG is opaque to the editor. Ask an agent to convert it to editable vector elements.
528
551
  </p>
529
552
  )}
530
553
  {vectorVisual && (
@@ -552,6 +575,8 @@ export function InspectorPanel({
552
575
  <button
553
576
  key={direction}
554
577
  type="button"
578
+ aria-label={direction === -1 ? "Send backward" : "Bring forward"}
579
+ title={direction === -1 ? "Send backward" : "Bring forward"}
555
580
  disabled={moveVectorElement(vectorVisual.elements, vectorElement.id, direction) === vectorVisual.elements}
556
581
  onClick={() => onComponent({
557
582
  ...component,
@@ -560,10 +585,16 @@ export function InspectorPanel({
560
585
  elements: moveVectorElement(vectorVisual.elements, vectorElement.id, direction),
561
586
  },
562
587
  })}
563
- >{direction === -1 ? "Send backward" : "Bring forward"}</button>
588
+ >
589
+ <VectorActionIcon action={direction === -1 ? "backward" : "forward"} />
590
+ {direction === -1 ? "Back" : "Forward"}
591
+ </button>
564
592
  ))}
565
593
  <button
566
594
  type="button"
595
+ className="vector-delete-action"
596
+ aria-label="Delete element"
597
+ title="Delete element"
567
598
  onClick={() => {
568
599
  onComponent({
569
600
  ...component,
@@ -574,7 +605,10 @@ export function InspectorPanel({
574
605
  });
575
606
  onSelectVectorElement(undefined);
576
607
  }}
577
- >Delete element</button>
608
+ >
609
+ <VectorActionIcon action="delete" />
610
+ Delete
611
+ </button>
578
612
  </div>
579
613
  {(vectorElement.kind === "text" || vectorElement.kind === "tspan") && (
580
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
+ }
@@ -2,6 +2,7 @@ import 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";
5
+ import { BuildOrb } from "./BuildOrb.tsx";
5
6
 
6
7
  const dockItems: {
7
8
  kind: CompositionComponent["kind"];
@@ -65,6 +66,7 @@ export function WorkspaceChrome({
65
66
  onPresent,
66
67
  fileStatus,
67
68
  building,
69
+ buildState,
68
70
  onBuild,
69
71
  onSelectTool,
70
72
  onComponentTool,
@@ -84,6 +86,7 @@ export function WorkspaceChrome({
84
86
  onPresent: () => void;
85
87
  fileStatus?: "loading" | "saved" | "saving" | "conflict" | "error";
86
88
  building: boolean;
89
+ buildState?: "ready" | "working";
87
90
  onBuild?: () => void;
88
91
  onSelectTool: () => void;
89
92
  onComponentTool: (kind: CompositionComponent["kind"]) => void;
@@ -149,12 +152,11 @@ export function WorkspaceChrome({
149
152
  <path d="m7 14 3.5-4 3 3 2-2 2.5 3M12 16v5m-3-3 3 3 3-3" />
150
153
  </svg>
151
154
  </button>
152
- <button type="button" onClick={onPresent} disabled={building}>
155
+ <button type="button" className="icon-only" aria-label="Present" title="Present" onClick={onPresent} disabled={building}>
153
156
  <svg viewBox="0 0 24 24" aria-hidden="true">
154
157
  <rect x="3" y="4" width="18" height="12" rx="2" />
155
158
  <path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
156
159
  </svg>
157
- Present
158
160
  </button>
159
161
  {onBuild && (
160
162
  <button
@@ -164,10 +166,8 @@ export function WorkspaceChrome({
164
166
  aria-busy={building}
165
167
  onClick={onBuild}
166
168
  >
167
- <svg viewBox="0 0 24 24" aria-hidden="true">
168
- <path d="m5 19 10-10 3 3L8 22Zm10-10 2-2 3 3-2 2M5 3v6M2 6h6m10-5v4m-2-2h4" />
169
- </svg>
170
- <span>{building ? "Building…" : "Build it"}</span>
169
+ <BuildOrb active={building} />
170
+ <span className="build-label">{buildState === "ready" ? "Request ready" : buildState === "working" ? "Agent working" : "Build it"}</span>
171
171
  </button>
172
172
  )}
173
173
  </div>
@@ -1,5 +1,5 @@
1
1
  {
2
- "schema": "konpeki-composition/v14",
2
+ "schema": "konpeki-composition/v1",
3
3
  "title": "Parcel Relay — migrated React deck",
4
4
  "theme": {
5
5
  "id": "plex",
@@ -48,7 +48,7 @@ export async function exportPagePNG(
48
48
  });
49
49
  clone
50
50
  .querySelectorAll(
51
- ".resize-handle, .guide, .vector-handles, .inline-intent-editor",
51
+ ".resize-handle, .guide, .vector-handles, .inline-intent-editor, .revision-pin",
52
52
  )
53
53
  .forEach((node) => node.remove());
54
54
  const rect = source.getBoundingClientRect();
@@ -56,6 +56,9 @@ export async function exportPagePNG(
56
56
  clone.style.height = `${rect.height}px`;
57
57
  clone.style.margin = "0";
58
58
  clone.style.overflow = "hidden";
59
+ // Loading feedback belongs to the editor, never to the exported artwork.
60
+ clone.style.filter = "none";
61
+ clone.style.opacity = "1";
59
62
  const fontRules = Array.from(document.styleSheets).flatMap((sheet) =>
60
63
  Array.from(sheet.cssRules).filter(
61
64
  (rule): rule is CSSFontFaceRule => rule instanceof CSSFontFaceRule,
@@ -1,9 +1,11 @@
1
1
  import type { CompositionDocument } from "../../composition/types.ts";
2
+ import type { ReviewState, ReviewTarget } from "./review.ts";
2
3
 
3
4
  export type FileSessionDocument = {
4
5
  document: CompositionDocument;
5
6
  revision: string;
6
7
  name: string;
8
+ review: ReviewState;
7
9
  };
8
10
 
9
11
  export class FileSessionError extends Error {
@@ -65,10 +67,21 @@ export function sendBuildRequest(
65
67
  instruction: string;
66
68
  slideId: string;
67
69
  componentId?: string;
70
+ elementId?: string;
68
71
  },
69
72
  ) {
70
- return request<{ ok: true }>(token, "/build", {
73
+ return request<ReviewState>(token, "/build", {
71
74
  method: "POST",
72
75
  body: JSON.stringify(requestBody),
73
76
  });
74
77
  }
78
+
79
+ export function addNote(token: string, target: ReviewTarget, text: string) {
80
+ return request<ReviewState>(token, "/notes", { method: "POST", body: JSON.stringify({ ...target, text }) });
81
+ }
82
+ export function removeNote(token: string, id: string) {
83
+ return request<ReviewState>(token, "/notes", { method: "DELETE", body: JSON.stringify({ id }) });
84
+ }
85
+ export function cancelRequest(token: string, id: string) {
86
+ return request<ReviewState>(token, "/cancel", { method: "POST", body: JSON.stringify({ id }) });
87
+ }