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/.agents/skills/authoring-visuals/SKILL.md +12 -1
- package/README.md +26 -25
- package/SETUP.md +1 -1
- package/composition/README.md +26 -49
- package/composition/compile.ts +1 -1
- package/composition/document.ts +2 -111
- package/composition/schema.json +3 -3
- package/composition/schema.ts +1 -1
- package/composition/types.ts +1 -20
- package/composition/validate.ts +3 -461
- package/composition/visualizations.ts +0 -16
- package/docs/development.md +57 -0
- package/docs/workflow.md +49 -7
- package/package.json +3 -1
- package/runtime/konpeki.mjs +182 -460
- package/slides/introducing-konpeki/composition.json +1740 -1735
- package/src/app/App.tsx +112 -54
- package/src/components/BuildOrb.tsx +40 -0
- package/src/components/Canvas.tsx +17 -0
- package/src/components/InspectorPanel.tsx +41 -8
- package/src/components/LeftPanel.tsx +16 -3
- package/src/components/RevisionNotes.tsx +56 -0
- package/src/components/WorkspaceChrome.tsx +11 -9
- package/src/lib/examples/react-page-migration.json +1 -1
- package/src/lib/export-png.ts +4 -1
- package/src/lib/file-session.ts +14 -1
- package/src/lib/review.ts +26 -0
- package/src/lib/use-file-session.ts +84 -19
- package/src/styles/base.css +16 -0
- package/src/styles/canvas.css +43 -12
- package/src/styles/chrome.css +106 -8
- package/src/styles/feedback.css +45 -25
- package/src/styles/left-panel.css +48 -7
- package/src/styles/right-panel.css +62 -20
- package/src/styles/shell.css +70 -16
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 {
|
|
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
|
-
|
|
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),
|
|
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,
|
|
234
|
+
}, [draft, presenting, selected, vectorSelection, fileSession.building]);
|
|
235
|
+
function showNotice(message: string, tone: "neutral" | "error" = "neutral") {
|
|
218
236
|
setToastClosing(false);
|
|
219
|
-
setNotice({ message,
|
|
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."
|
|
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."
|
|
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 =
|
|
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
|
-
|
|
431
|
-
|
|
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."
|
|
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={
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
>
|
|
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
|
-
>
|
|
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
|
-
<
|
|
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
|
+
}
|