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/.agents/skills/authoring-visuals/SKILL.md +12 -1
- package/README.md +4 -4
- 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 +4 -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 +92 -18
- package/src/components/BuildOrb.tsx +40 -0
- package/src/components/Canvas.tsx +17 -0
- package/src/components/InspectorPanel.tsx +37 -3
- package/src/components/LeftPanel.tsx +16 -3
- package/src/components/RevisionNotes.tsx +56 -0
- package/src/components/WorkspaceChrome.tsx +6 -6
- 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 +49 -13
- package/src/styles/base.css +16 -0
- package/src/styles/canvas.css +43 -12
- package/src/styles/chrome.css +78 -28
- package/src/styles/feedback.css +19 -1
- package/src/styles/left-panel.css +48 -7
- package/src/styles/right-panel.css +62 -20
- package/src/styles/shell.css +41 -26
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,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 =
|
|
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-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
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
|
-
>
|
|
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
|
-
>
|
|
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
|
-
<
|
|
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
|
-
<
|
|
168
|
-
|
|
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>
|
package/src/lib/export-png.ts
CHANGED
|
@@ -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,
|
package/src/lib/file-session.ts
CHANGED
|
@@ -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<
|
|
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
|
+
}
|