konpeki 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,30 @@
1
1
  # Public brief excerpt
2
2
 
3
- This file retains the original creation request and its authoring context.
4
- Implementation debugging and product-development follow-ups are omitted; this
5
- is not the complete conversation record.
3
+ Public requests and authoring decisions; debugging and product-development
4
+ follow-ups are omitted. This is not the complete conversation.
6
5
 
7
6
  > build a pre to introduce konpeki
8
7
 
9
- ## Authoring record
8
+ ## Creation
10
9
 
11
- - Requested mode: unspecified. Resolved mode: default.
12
- - Explicit visual preferences: none supplied.
13
- - Interpretation: “pre” means presentation. Build a six-slide English introduction
14
- for people and coding-agent users who have not used Konpeki.
15
- - Direction: white, IBM Plex Sans, dark ink and Konpeki blue; restrained diagrams,
16
- clear hierarchy, no invented metrics or unsupported export claims.
17
- - Sources: the repository's documented capabilities;
18
- see SOURCE.md for concrete references and qualifications.
19
- - Deliverable: composition JSON, browser presentation, reviewed captures.
10
+ - Interpreted “pre” as a six-page English introduction for first-time users.
11
+ - No mode or visual preferences supplied; chose default mode, white, IBM Plex
12
+ Sans, dark ink and Konpeki blue, with restrained diagrams and clear hierarchy.
13
+ - Use documented capabilities, not invented metrics or export claims.
14
+ Deliver editable JSON, browser presentation and reviewed captures.
20
15
 
21
- ## Public revision request
16
+ ## Revision
22
17
 
23
18
  > review the example composition and revise if needed
24
19
 
25
- ### Adaptation record
26
-
27
- - Requested mode: unspecified. Resolved mode: retain default.
28
- - Scope: review the introduction against the current skill-led creation,
29
- file-backed revision and browser-only playground workflows.
30
- - Position Konpeki as a creator of editable visuals, not primarily a slide
31
- explainer. Show a creation brief without a mandatory init or canvas-drafting step;
32
- keep the canvas-first intent / Build it workflow as an alternative.
33
- - Distinguish browser-local playground storage from an agent-opened file session.
34
- Notes and Build it need the latter; Copy prompt resumes an idle agent.
35
- - Replace contributor setup commands with skill installation and current user
36
- prerequisites. Do not imply that every agent uses the same slash command.
37
- - Preserve the six pages, existing Precision/Paper appearance, stable IDs and
38
- unrelated geometry. Precision uses Noto Sans; the IBM Plex direction above
39
- records the original creation, not the current theme.
40
- - Refresh the editable JSON, authoring source, source notes and reviewed captures.
20
+ - Show creation from a brief, with init optional. Canvas-first intent / Build it
21
+ remains an alternative; Konpeki is not primarily a slide explainer.
22
+ - Separate browser-local storage from agent-opened files. Notes and Build it
23
+ require a file session; Copy prompt resumes an idle agent.
24
+ - Use skill installation and user prerequisites, not contributor setup or
25
+ assumptions that every agent supports the same slash command.
26
+ - Retain default mode, six pages, stable IDs, unrelated geometry and the existing
27
+ Precision Paper appearance. Its Noto Sans differs from the original font choice.
28
+ - Refresh JSON, authoring source, source notes and captures.
29
+
30
+ See [SOURCE.md](SOURCE.md) for capability evidence and artwork provenance.
@@ -1,76 +1,44 @@
1
1
  # Introducing Konpeki
2
2
 
3
- A six-page English introduction for first-time users. Defaults to Precision on paper
4
- with theme-linked colors and fonts; deck appearance can switch palettes and
5
- Paper/Night without repainting vector elements. Uses the default authoring mode.
6
-
7
- 1. **Meet Konpeki** — create editable visuals with your coding agent.
8
- 2. **Create and revise together** — brief, generate, edit and revise the same file.
9
- 3. **Five semantic components** — Text block, Diagram, Chart, Image and Table.
10
- 4. **Keep the detail editable** — direct editing and targeted agent revision notes.
11
- 5. **Browser or agent editor** — local playground versus file-backed editing.
12
- 6. **Try a real brief** — install the skill and give a one-sentence creation request.
3
+ A six-page introduction covering creation, shared editing, components, revision
4
+ notes, browser/file sessions and a first brief. Uses default authoring mode and
5
+ Precision Paper with theme-linked colors and fonts.
13
6
 
14
7
  ## Open and revise
15
8
 
16
- For repository development, run `mise exec -- pnpm dev` from the root and open
17
- `?example=introducing-konpeki` in the application. A static build serves the same
18
- example after `mise exec -- pnpm build`. Select **Present** for viewing.
9
+ Open the [playground example](https://vcfgdev.github.io/konpeki/?example=introducing-konpeki)
10
+ and select **Present**. Edits save to a separate browser-local copy; use
11
+ **Browser → Download JSON** to keep them. **Reset example** restores the bundled
12
+ version, so download your work first. Browser storage is not a backup or agent sync.
19
13
 
20
- Edits autosave to this example's separate browser-local working copy when storage
21
- is available. **Browser → Download JSON** keeps a portable copy; **Import JSON**
22
- and **Start blank** replace only this working copy. **Reset example** restores
23
- the bundled version. Download existing work before resetting, including when
24
- loading an updated example. Browser storage is not a backup and does not sync
25
- with an agent's file. The playground has no connected agent, Notes or Build it.
14
+ For agent revisions, follow [setup](../../SETUP.md) and open a copy of
15
+ [composition.json](composition.json) in a file-backed preview. Notes, **Build it**
16
+ and **Copy prompt** work there, not in the standalone playground. See the
17
+ [canvas workflow](../../docs/workflow.md) for handoff details.
26
18
 
27
- For agent-backed editing, follow [SETUP.md](../../SETUP.md) to install the complete
28
- skill, then ask your agent to open a downloaded or copied composition file.
29
- Browser and agent revisions use that same file in its preview. Continue feedback
30
- in agent chat, or use Notes / Build it and Copy prompt as described in the
31
- [canvas workflow](../../docs/workflow.md). A creation brief selects generate;
32
- init is optional, not a required first step.
19
+ The JSON is the editable source: double-click text to edit it; diagrams contain
20
+ editable vector elements. [author.ts](author.ts) reconstructs the original deck:
33
21
 
34
- [composition.json](composition.json) is the authoritative editable deck. Each
35
- page contains semantic components. Ordinary text uses native content and typography
36
- fields; diagrams and custom specimens keep editable vector elements. Double-click
37
- ordinary text to edit it directly; Content intent remains separate agent guidance.
38
- This is not a second React deck runtime or a collection of page images.
22
+ ```sh
23
+ mise exec -- node slides/introducing-konpeki/author.ts
24
+ ```
39
25
 
40
- [author.ts](author.ts) records the reviewed drawing instructions. Running
41
- `mise exec -- node slides/introducing-konpeki/author.ts` validates and regenerates the JSON;
42
- it **replaces manual canvas revisions**, so do not rerun it over revised work.
43
- See [the public brief excerpt](PROMPT.md) and [source facts / provenance](SOURCE.md).
26
+ Run from the repository root. **This overwrites canvas edits.**
27
+ [Brief](PROMPT.md) · [Sources](SOURCE.md) · [Reviewed screenshots](screenshots/)
44
28
 
45
29
  ## Review
46
30
 
47
- - `mise exec -- pnpm check`, `mise exec -- pnpm test` (106 tests),
48
- `mise exec -- pnpm build`, composition validation and `git diff --check`: passed.
49
- The build retains its existing large-chunk advisory.
50
- - Inspected all six canvas pages in Precision Paper, Precision Night and Editorial
51
- Paper at 1920×1080 and 1024×768, DPR2 (36 states). No clipped text or overlapping
52
- labels. Revised the file/playground wording and kept the revision arrow returning
53
- from Revise to Edit, not to a new brief.
54
- - `review.mjs` checked text bounds after font loading and each authored vector
55
- viewport against its component rectangle within 0.5 screen pixels at both sizes.
56
- With a dev or preview server and `agent-browser` available, run:
31
+ With a dev/preview server and `agent-browser`, run from the repository root:
57
32
 
58
- ```sh
59
- mise exec -- node slides/introducing-konpeki/review.mjs http://localhost:4318 /tmp/konpeki-intro Precision paper
60
- ```
33
+ ```sh
34
+ mise exec -- node slides/introducing-konpeki/review.mjs <preview-url> /tmp/konpeki-intro Precision paper
35
+ ```
61
36
 
62
- Repeat with `Precision night` and `Editorial paper`; inspect captures after
63
- the assertions pass. The script defaults to Plex if no palette is supplied and
64
- explicitly selects its legacy font pairing. An optional final typography ID
65
- tests independent combinations, such as `Green paper plex-serif` or
66
- `Plex night noto-sans`; both passed all six pages at both sizes after separation.
67
- - Precision Paper's accessibility tree exposes page copy, visual descriptions
68
- and the chart's illustrative values. Home / ArrowRight navigation reached all
69
- six pages; Noto Sans weights 400, 500 and 600 loaded. Text on the white canvas
70
- measures 15.59:1 (ink), 6.91:1 (muted) and 9.54:1 (accent).
71
- - Final reviewed presentation captures: [screenshots](screenshots/).
72
- - Chart values are illustrative; diagrams are explanatory artwork, not screenshots.
73
- - Native agent-client installation, PDF/PPTX, mobile, reduced motion, focus styling
74
- and cross-application fidelity were not verified by this content review.
37
+ The script checks text bounds and vector alignment at 1920×1080 and 1024×768,
38
+ DPR2. Repeat with `Precision night` and `Editorial paper`, then inspect captures.
39
+ An optional final font ID overrides the palette's default, e.g. `Green paper plex-serif`.
40
+ These 36 states passed the original visual review, alongside validation, typecheck,
41
+ tests and build. Browser review does not verify agent-client installation,
42
+ PDF/PPTX, mobile, reduced motion, focus styling or cross-application fidelity.
75
43
 
76
44
  ![Title slide](screenshots/page-1.png)
@@ -1,26 +1,19 @@
1
1
  # Source facts and provenance
2
2
 
3
- This is a factual introduction to Konpeki, not a fictional customer case study.
3
+ Product claims come from repository documentation, not a customer case study.
4
4
 
5
5
  | Claim | Repository evidence |
6
6
  | --- | --- |
7
- | Covers, social graphics, charts, diagrams and presentations share an editable canvas | [Product introduction](../../README.md); [authoring guidance](../../AUTHORING.md) |
8
- | Install the complete skill; a creation brief selects generate, with init optional | [Setup](../../SETUP.md); [canvas workflow](../../docs/workflow.md) |
9
- | In a file-backed preview, valid browser and agent changes update the same composition JSON | [File-backed workflow](../../docs/workflow.md#file-backed-editing-with-an-agent) |
10
- | Five top-level kinds: Text block, Diagram, Chart, Image, Table; stable editable vector IDs | [Composition contract](../../composition/README.md); [types](../../composition/types.ts) |
11
- | Direct text, shape and layout editing with undo/redo | [Product capabilities](../../README.md); [composition operations](../../composition/document.ts) |
12
- | Diagram, Chart and Table previews are structural drafts; real source data is needed for finished artwork | [Canvas workflow](../../docs/workflow.md#documents-and-exports); [factual-fidelity rules](../../AUTHORING.md#requirements) |
13
- | Revision notes and Build it belong to file-backed sessions; Copy prompt resumes an idle agent | [Review handoff](../../docs/workflow.md#file-backed-editing-with-an-agent) |
14
- | Playground examples save independent browser-local copies; import/download JSON, start blank, reset, PNG and Present are available | [Demo hosting](../../docs/development.md#development-server-and-demo-hosting); [storage implementation](../../src/lib/storage.ts); [browser controls](../../src/components/WorkspaceChrome.tsx) |
15
- | Playground files do not sync with an agent session; there is no connected AI or cloud storage | [Demo hosting](../../docs/development.md#development-server-and-demo-hosting) |
16
- | Agent workflow requires Node.js 24+, npm, file/command access and a browser; contributor pnpm setup is separate | [User setup](../../SETUP.md); [development setup](../../docs/development.md) |
7
+ | Covers, social graphics, charts, diagrams and presentations share one canvas | [Product](../../README.md); [authoring](../../AUTHORING.md) |
8
+ | Skill-led creation; init optional; Node.js 24+, npm, file/command access and a browser required | [User setup](../../SETUP.md) |
9
+ | Browser and agent edit the same JSON; file sessions support notes, Build it and Copy prompt | [File-backed workflow](../../docs/workflow.md#file-backed-editing-with-an-agent) |
10
+ | Text block, Diagram, Chart, Image and Table; editable vectors with stable IDs | [Contract](../../composition/README.md); [types](../../composition/types.ts) |
11
+ | Direct text, shape and layout editing with undo/redo | [Operations](../../composition/document.ts) |
12
+ | Diagram, Chart and Table defaults are structural drafts; finished artwork needs source data | [Exports](../../docs/workflow.md#documents-and-exports); [fidelity rules](../../AUTHORING.md#requirements) |
13
+ | Playground: independent local copies, JSON import/download, blank/reset, PNG and Present; no agent sync, connected AI or cloud storage | [Hosting](../../docs/development.md#development-server-and-demo-hosting); [storage](../../src/lib/storage.ts); [controls](../../src/components/WorkspaceChrome.tsx) |
17
14
 
18
- The architecture drawings, vector selection example and component specimens are
19
- original explanatory artwork for this deck, not product screenshots. The tiny
20
- chart uses explicitly labeled illustrative values, not measured product data.
21
- The product-announcement prompt is an example brief, not a customer result.
22
- No third-party image assets or customer claims are used. The retained Precision
23
- theme uses Noto Sans from the repository's Fontsource dependency; theme-linked
24
- text follows the selected palette's font roles. No PDF/PPTX or cross-application
25
- fidelity is claimed. This deck does not establish native agent-client installation
26
- compatibility or an official hosted playground URL.
15
+ Drawings are original explanatory artwork, not screenshots. Chart values and
16
+ the announcement brief are illustrative, not product measurements or customer
17
+ results. No third-party images are used. Precision defaults to bundled Noto Sans;
18
+ text follows the selected font roles. See the [review scope](README.md#review)
19
+ for verification limits.
package/src/app/App.tsx CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  type RightPanelView,
45
45
  } from "../components/RightPanel.tsx";
46
46
  import { WorkspaceChrome } from "../components/WorkspaceChrome.tsx";
47
+ import { FeedbackNotice } from "../components/ui.tsx";
47
48
  import { exampleDraft } from "../lib/examples.ts";
48
49
  import { exportPagePNG } from "../lib/export-png.ts";
49
50
  import { fileSessionToken } from "../lib/file-session.ts";
@@ -85,12 +86,13 @@ export function App() {
85
86
  const validation = useMemo(() => validateComposition(draft), [draft]);
86
87
  const [savedDraft, setSavedDraft] = useState<Draft>();
87
88
  const [blocked, setBlocked] = useState(loaded.storageBlocked);
89
+ const [requiresReset, setRequiresReset] = useState(loaded.storageBlocked);
88
90
  const [error, setError] = useState(loaded.error ?? "");
89
91
  const [notice, setNotice] = useState<{
90
92
  message: string;
91
93
  tone: "neutral" | "error";
92
94
  }>();
93
- const [toastClosing, setToastClosing] = useState(false);
95
+ const [toastPaused, setToastPaused] = useState(false);
94
96
  const [leftCollapsed, setLeftCollapsed] = useState(false);
95
97
  const [leftView, setLeftView] = useState<LeftPanelView>("pages");
96
98
  const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -125,6 +127,7 @@ export function App() {
125
127
  onNotice: showNotice,
126
128
  onError: setError,
127
129
  });
130
+ const fileLocked = loaded.fileSession && (!fileSession.ready || fileSession.opening);
128
131
  useEffect(() => {
129
132
  if (!focusAfterHistory.current) return;
130
133
  focusAfterHistory.current = false;
@@ -160,18 +163,10 @@ export function App() {
160
163
  return () => clearTimeout(timer);
161
164
  }, [draft, validation, blocked, loaded.exampleName, loaded.fileSession]);
162
165
  useEffect(() => {
163
- if (!notice) return;
164
- const timer = window.setTimeout(() => setToastClosing(true), 3000);
166
+ if (!notice || notice.tone === "error" || toastPaused) return;
167
+ const timer = window.setTimeout(() => setNotice(undefined), 3000);
165
168
  return () => window.clearTimeout(timer);
166
- }, [notice]);
167
- useEffect(() => {
168
- if (!toastClosing) return;
169
- const timer = window.setTimeout(() => {
170
- setNotice(undefined);
171
- setToastClosing(false);
172
- }, 280);
173
- return () => window.clearTimeout(timer);
174
- }, [toastClosing]);
169
+ }, [notice, toastPaused]);
175
170
  useEffect(() => {
176
171
  const narrow = window.matchMedia("(max-width: 1200px)");
177
172
  const compact = window.matchMedia("(max-width: 900px)");
@@ -189,7 +184,7 @@ export function App() {
189
184
  }, []);
190
185
  useEffect(() => {
191
186
  function keydown(event: KeyboardEvent) {
192
- if (presenting || fileSession.building) return;
187
+ if (presenting || fileLocked || fileSession.building) return;
193
188
  const modifier = event.metaKey || event.ctrlKey;
194
189
  const target = event.target;
195
190
  const editable =
@@ -246,10 +241,18 @@ export function App() {
246
241
  }
247
242
  window.addEventListener("keydown", keydown);
248
243
  return () => window.removeEventListener("keydown", keydown);
249
- }, [draft, presenting, selected, vectorSelection, fileSession.building]);
244
+ }, [draft, presenting, selected, vectorSelection, fileSession.building, fileLocked]);
250
245
  function showNotice(message: string, tone: "neutral" | "error" = "neutral") {
251
- setToastClosing(false);
252
- setNotice({ message: message.replace(/\.$/, ""), tone });
246
+ if (!message) setToastPaused(false);
247
+ setNotice(message ? { message: message.replace(/\.$/, ""), tone } : undefined);
248
+ }
249
+ function changeLeftPanel(collapsed: boolean) {
250
+ setLeftCollapsed(collapsed);
251
+ if (!collapsed && window.matchMedia("(max-width: 900px)").matches) setRightCollapsed(true);
252
+ }
253
+ function changeRightPanel(collapsed: boolean) {
254
+ setRightCollapsed(collapsed);
255
+ if (!collapsed && window.matchMedia("(max-width: 900px)").matches) setLeftCollapsed(true);
253
256
  }
254
257
  function updateDraft(
255
258
  next: Draft,
@@ -454,7 +457,7 @@ export function App() {
454
457
  const page = draft.slides.find(s => s.id === target.slideId);
455
458
  if (!page) return;
456
459
  selectSlide(page.id);
457
- setLeftCollapsed(false);
460
+ changeLeftPanel(false);
458
461
  setLeftView("notes");
459
462
  const component = page.components.find(c => c.id === target.componentId);
460
463
  if (component) {
@@ -474,7 +477,7 @@ export function App() {
474
477
  const requested = await fileSession.build(instruction, slide.id, selected, vectorSelection?.componentId === selected ? vectorSelection?.elementId : undefined);
475
478
  if (requested) {
476
479
  setNotice(undefined);
477
- setToastClosing(false);
480
+ setToastPaused(false);
478
481
  }
479
482
  } catch (requestError) {
480
483
  showNotice(requestError instanceof Error ? requestError.message : "Could not send the build request.", "error");
@@ -582,6 +585,7 @@ export function App() {
582
585
  createHistory({ draft: next, activeSlideId: next.slides[0].id }),
583
586
  );
584
587
  setBlocked(false);
588
+ setRequiresReset(false);
585
589
  setError("");
586
590
  setTitleError(false);
587
591
  showNotice(loaded.exampleName ? "Example reset." : "Saved draft reset.");
@@ -597,6 +601,11 @@ export function App() {
597
601
  : savedDraft !== draft
598
602
  ? "Saving changes in this browser…"
599
603
  : "Your editable composition is saved only in this browser.";
604
+ const recoveryMessage = fileSession.opening
605
+ ? "Opening the file… Editing is paused."
606
+ : !validation.ok
607
+ ? `Changes are not saved: ${validation.issues[0]?.path || "document"} ${validation.issues[0]?.message || "is invalid"}. Undo the edit or correct this value.`
608
+ : error;
600
609
  const selectedComponent = slide.components.find((c) => c.id === selected);
601
610
  const buildState = fileSession.building
602
611
  ? fileSession.review.request?.status === "working" ? "working" : "ready"
@@ -609,25 +618,36 @@ export function App() {
609
618
  <a className="skip-link" href="#canvas-stage">
610
619
  Skip to canvas
611
620
  </a>
612
- {error && (
613
- <div className="recovery" role="alert">
614
- <span>{error}</span>
615
- <button type="button" onClick={reset}>
616
- {loaded.exampleName ? "Reset example" : "Reset saved draft"}
617
- </button>
618
- </div>
619
- )}
621
+ <FeedbackNotice kind="recovery">
622
+ {recoveryMessage ? <>
623
+ <span>{recoveryMessage}</span>
624
+ <div className="recovery-actions">
625
+ {!validation.ok ? <button type="button" onClick={() => setHistory(current => undoHistory(current))}>Undo edit</button> : <>
626
+ {(!loaded.fileSession || fileSession.ready) && <button type="button" onClick={downloadJSON}>Download JSON</button>}
627
+ {loaded.fileSession ? fileSession.status === "conflict" ? (
628
+ <button type="button" onClick={() => {
629
+ if (window.confirm("Replace your browser edits with the current file? Download JSON first to keep a copy.")) void fileSession.reload();
630
+ }}>Load file version</button>
631
+ ) : <button type="button" disabled={fileSession.opening} onClick={() => { void fileSession.retry(); }}>{fileSession.opening ? "Opening…" : fileSession.ready ? "Retry save" : "Retry open"}</button> : <>
632
+ {!requiresReset && <button type="button" onClick={() => { setBlocked(false); setError(""); }}>Retry save</button>}
633
+ <button type="button" onClick={reset}>{loaded.exampleName ? "Reset example" : "Reset saved draft"}</button>
634
+ </>}
635
+ </>}
636
+ </div>
637
+ </> : null}
638
+ </FeedbackNotice>
620
639
  <div
621
640
  className={`workspace ${fileSession.building ? "is-building" : ""} ${leftCollapsed ? "left-collapsed" : ""} ${rightCollapsed ? "right-collapsed" : ""}`}
622
641
  aria-hidden={presenting || undefined}
623
- inert={presenting || undefined}
642
+ inert={presenting || fileLocked || undefined}
643
+ aria-busy={fileSession.opening || undefined}
624
644
  onDragOver={(event) => {
625
645
  if (!event.dataTransfer.types.includes("Files")) return;
626
646
  event.preventDefault();
627
647
  event.dataTransfer.dropEffect = "copy";
628
648
  }}
629
649
  onDrop={(event) => {
630
- if (fileSession.building) {
650
+ if (fileLocked || fileSession.building) {
631
651
  event.preventDefault();
632
652
  return;
633
653
  }
@@ -639,7 +659,7 @@ export function App() {
639
659
  >
640
660
  <WorkspaceChrome
641
661
  leftCollapsed={leftCollapsed}
642
- onToggleLeftPanel={() => setLeftCollapsed(!leftCollapsed)}
662
+ onToggleLeftPanel={() => changeLeftPanel(!leftCollapsed)}
643
663
  title={draft.title}
644
664
  titleRef={title}
645
665
  titleError={titleError}
@@ -679,6 +699,7 @@ export function App() {
679
699
  onAdd={fileSession.addNote}
680
700
  onRemove={fileSession.removeNote}
681
701
  onSelect={selectNoteTarget}
702
+ onNotice={(message) => showNotice(message, "error")}
682
703
  />}
683
704
  activeSlideId={slide.id}
684
705
  collapsed={leftCollapsed}
@@ -703,7 +724,7 @@ export function App() {
703
724
  setVectorSelection(selection);
704
725
  setRightView("settings");
705
726
  if (edit) {
706
- setRightCollapsed(false);
727
+ changeRightPanel(false);
707
728
  setVectorEditRequest((request) => request + 1);
708
729
  }
709
730
  } else setVectorSelection(undefined);
@@ -752,12 +773,19 @@ export function App() {
752
773
  slide={slide}
753
774
  draft={draft}
754
775
  onView={setRightView}
755
- onCollapsedChange={setRightCollapsed}
776
+ onCollapsedChange={changeRightPanel}
756
777
  onSelectSlide={() => selectComponent()}
757
778
  onSelectComponent={selectComponent}
758
779
  onSelectVectorElement={(elementId) =>
759
780
  selected && setVectorSelection({ componentId: selected, elementId })
760
781
  }
782
+ onSelectOverflow={(componentId, elementId) => {
783
+ selectComponent(componentId);
784
+ setVectorSelection(elementId ? { componentId, elementId } : undefined);
785
+ setRightView("settings");
786
+ changeRightPanel(false);
787
+ if (elementId) setVectorEditRequest(request => request + 1);
788
+ }}
761
789
  onReorderPaintOrder={updatePaintOrder}
762
790
  onComponent={updateComponent}
763
791
  onSlide={(nextSlide, mergeKey) =>
@@ -808,13 +836,10 @@ export function App() {
808
836
  onExit={() => setPresenting(false)}
809
837
  />
810
838
  )}
811
- <div
812
- className={`toast ${notice ? "visible" : ""} ${notice?.tone === "error" ? "error" : ""} ${toastClosing ? "closing" : ""}`}
813
- >
814
- <span role={notice?.tone === "error" ? "alert" : "status"} aria-live={notice?.tone === "error" ? "assertive" : "polite"}>
815
- {notice?.message}
816
- </span>
817
- </div>
839
+ <FeedbackNotice kind="toast" tone={notice?.tone} onPauseChange={setToastPaused}
840
+ onDismiss={() => { setNotice(undefined); setToastPaused(false); }}>
841
+ {notice?.message}
842
+ </FeedbackNotice>
818
843
  </>
819
844
  );
820
845
  }
@@ -791,8 +791,10 @@ export function Canvas({
791
791
  if (
792
792
  event.target instanceof Element &&
793
793
  !event.target.closest(".canvas")
794
- )
794
+ ) {
795
+ event.currentTarget.focus({ preventScroll: true });
795
796
  onSelect();
797
+ }
796
798
  }}
797
799
  >
798
800
  <div className="slide-wrap">
@@ -802,6 +804,7 @@ export function Canvas({
802
804
  aria-label="Page name"
803
805
  autoFocus
804
806
  value={nextSlideName}
807
+ onPointerDown={(event) => event.stopPropagation()}
805
808
  onChange={(event) => setNextSlideName(event.target.value)}
806
809
  onBlur={() => {
807
810
  const name = nextSlideName.trim();
@@ -850,7 +853,10 @@ export function Canvas({
850
853
  style={canvasStyle}
851
854
  aria-label="Page canvas"
852
855
  onPointerDown={(e) => {
853
- if (interactive && e.target === e.currentTarget) onSelect();
856
+ if (interactive && e.target === e.currentTarget) {
857
+ e.currentTarget.closest<HTMLElement>(".stage")?.focus({ preventScroll: true });
858
+ onSelect();
859
+ }
854
860
  }}
855
861
  onPointerMove={interactive ? move : undefined}
856
862
  onPointerUp={interactive ? () => end() : undefined}
@@ -34,10 +34,9 @@ import {
34
34
  import { composerPalette, themeLabel } from "../lib/theme.ts";
35
35
  import { getTheme, typographyLabels } from "../../design/themes/index.ts";
36
36
  import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
37
- import { Field, Select, humanize } from "./ui.tsx";
37
+ import { Field, NumberField, Select, humanize } from "./ui.tsx";
38
38
  import { moveVectorElement, removeVectorElement } from "../../composition/vector.ts";
39
39
  import { tokensForAttribute } from "../../composition/theme-tokens.ts";
40
- import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
41
40
  import { PageSizePicker } from "./PageSizePicker.tsx";
42
41
 
43
42
  const appearanceLabels: Record<string, string> = {
@@ -410,7 +409,6 @@ export function InspectorPanel({
410
409
  })
411
410
  }
412
411
  />
413
- <VectorOverflowWarning slide={slide} theme={draft.theme} />
414
412
  <h2 className="slide-settings-heading">Page numbering</h2>
415
413
  <fieldset className="page-number-picker">
416
414
  <SegmentedControl
@@ -477,9 +475,9 @@ export function InspectorPanel({
477
475
  if (key === "x") next.x = clamp(value, 0, slide.canvas.width - rect.width);
478
476
  if (key === "y") next.y = clamp(value, 0, slide.canvas.height - rect.height);
479
477
  if (key === "width")
480
- next.width = clamp(value, Math.min(180, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
478
+ next.width = clamp(value, Math.min(180, rect.width, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
481
479
  if (key === "height")
482
- next.height = clamp(value, Math.min(72, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
480
+ next.height = clamp(value, Math.min(72, rect.height, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
483
481
  onComponent(
484
482
  { ...component!, preferredRect: next },
485
483
  `geometry:${component!.id}`,
@@ -504,18 +502,17 @@ export function InspectorPanel({
504
502
  <span>{componentLabel ?? componentLabels[component.kind]}</span>
505
503
  </h2>
506
504
  {component.kind === "text-block" && !component.customVisual && <>
507
- <VectorOverflowWarning slide={slide} theme={draft.theme} />
508
505
  <Field label="Text"><textarea name="text-content" value={component.content ?? ""}
509
506
  onChange={(event) => onComponent({ ...component, content: event.target.value }, `content:${component.id}`)} onBlur={onEditEnd} /></Field>
510
507
  <div className="text-typography-fields">
511
- <Field label="Font size"><input type="number" min={8} max={240} value={component.textStyle?.size ?? 36}
512
- onChange={(event) => { const size = event.target.valueAsNumber; if (size >= 8 && size <= 240) onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`); }} onBlur={onEditEnd} /></Field>
508
+ <NumberField label="Font size" min={8} max={240} value={component.textStyle?.size ?? 36}
509
+ onCommit={size => onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`)} onEditEnd={onEditEnd} />
513
510
  <Select label="Text color" value={component.textStyle?.color ?? "ink"} options={["ink", "muted", "accent"]}
514
511
  onChange={(color) => onComponent({ ...component, textStyle: { ...component.textStyle, color: color as "ink" | "muted" | "accent" } })} />
515
512
  <Select label="Font weight" value={String(component.textStyle?.weight ?? 400)} options={["400", "500", "600"]}
516
513
  onChange={(weight) => onComponent({ ...component, textStyle: { ...component.textStyle, weight: Number(weight) as 400 | 500 | 600 } })} />
517
- <Field label="Line height"><input type="number" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
518
- onChange={(event) => { const lineHeight = event.target.valueAsNumber; if (lineHeight >= 1 && lineHeight <= 3) onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`); }} onBlur={onEditEnd} /></Field>
514
+ <NumberField label="Line height" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
515
+ onCommit={lineHeight => onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`)} onEditEnd={onEditEnd} />
519
516
  </div>
520
517
  </>}
521
518
  <Field label={component.kind === "diagram" || component.kind === "chart" ? "What should this explain?" : "Content intent"}>
@@ -555,23 +552,10 @@ export function InspectorPanel({
555
552
  <legend>Position & size</legend>
556
553
  <div className="geometry">
557
554
  {(["x", "y", "width", "height"] as const).map((key) => (
558
- <Field key={key} label={humanize(key)}>
559
- <span className="parameter-input">
560
- <input
561
- name={key}
562
- autoComplete="off"
563
- type="number"
564
- step="1"
565
- value={Math.round(rect[key])}
566
- onChange={(e) => {
567
- if (e.target.value !== "")
568
- geometry(key, e.target.valueAsNumber);
569
- }}
570
- onBlur={onEditEnd}
571
- />
572
- <span className="parameter-unit" aria-hidden="true">px</span>
573
- </span>
574
- </Field>
555
+ <NumberField key={key} name={key} label={humanize(key)} unit="px" value={rect[key]}
556
+ min={key === "width" ? Math.min(180, rect.width, slide.canvas.width - rect.x) : key === "height" ? Math.min(72, rect.height, slide.canvas.height - rect.y) : 0}
557
+ max={key === "x" ? slide.canvas.width - rect.width : key === "y" ? slide.canvas.height - rect.height : key === "width" ? slide.canvas.width - rect.x : slide.canvas.height - rect.y}
558
+ onCommit={value => geometry(key, value)} onEditEnd={onEditEnd} />
575
559
  ))}
576
560
  </div>
577
561
  </fieldset>
@@ -3,7 +3,7 @@ import type { CompositionDocument } from "../../composition/types.ts";
3
3
  import { componentInstanceLabel } from "../lib/model.ts";
4
4
  import { activeRequest, type ReviewState, type ReviewTarget } from "../lib/review.ts";
5
5
 
6
- export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect }: {
6
+ export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect, onNotice }: {
7
7
  document: CompositionDocument;
8
8
  target: ReviewTarget;
9
9
  review: ReviewState;
@@ -11,10 +11,10 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
11
11
  onAdd: (target: ReviewTarget, text: string) => Promise<void>;
12
12
  onRemove: (id: string) => Promise<void>;
13
13
  onSelect: (target: ReviewTarget) => void;
14
+ onNotice: (message: string) => void;
14
15
  }) {
15
16
  const [drafts, setDrafts] = useState<Record<string, string>>({});
16
17
  const [saving, setSaving] = useState(false);
17
- const [error, setError] = useState("");
18
18
  const key = JSON.stringify(target);
19
19
  const text = drafts[key] ?? "";
20
20
  function label(value: ReviewTarget) {
@@ -28,16 +28,16 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
28
28
  return `${slide.name} · ${name}${value.elementId ? ` · ${element ? value.elementId : "Deleted element"}` : ""}`;
29
29
  }
30
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"); }
31
+ setSaving(true);
32
+ try { await action(); onNotice(""); }
33
+ catch (error) { onNotice(error instanceof Error ? error.message : "Could not save revision note"); }
34
34
  finally { setSaving(false); }
35
35
  }
36
36
  const pending = review.notes.filter(n => !n.resolved);
37
37
  return <section className="revision-notes" aria-label="Revision notes">
38
38
  <form onSubmit={event => { event.preventDefault(); void run(async () => {
39
39
  await onAdd(target, text);
40
- setDrafts(current => ({ ...current, [key]: "" }));
40
+ setDrafts(current => ({ ...current, [key]: current[key] === text ? "" : current[key] }));
41
41
  }); }}>
42
42
  <label htmlFor="revision-note">Request a change</label>
43
43
  <p className="revision-note-scope">{label(target)}</p>
@@ -45,7 +45,6 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
45
45
  onChange={event => setDrafts(current => ({ ...current, [key]: event.target.value }))} />
46
46
  <div className="revision-note-submit"><button type="submit" disabled={disabled || saving || !text.trim()}>{saving ? "Saving…" : "Add note"}</button></div>
47
47
  </form>
48
- {error && <p role="alert" className="revision-note-error">{error}</p>}
49
48
  <p className="revision-note-count">{pending.length} pending</p>
50
49
  {pending.length > 0 && <ol className="revision-note-list">{pending.map((note, i) => <li key={note.id} id={`note-${note.id}`}>
51
50
  <button type="button" className="revision-note-target" onClick={() => onSelect(note)}><span>{i + 1}</span>{label(note)}</button>