konpeki 0.1.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.
Files changed (121) hide show
  1. package/.agents/skills/authoring-visuals/SKILL.md +82 -0
  2. package/AGENTS.md +33 -0
  3. package/AUTHORING.md +233 -0
  4. package/LICENSE +201 -0
  5. package/README.md +70 -0
  6. package/SETUP.md +86 -0
  7. package/composition/README.md +166 -0
  8. package/composition/compile.ts +227 -0
  9. package/composition/document.ts +709 -0
  10. package/composition/schema.json +2992 -0
  11. package/composition/schema.ts +435 -0
  12. package/composition/theme-tokens.ts +16 -0
  13. package/composition/types.ts +286 -0
  14. package/composition/validate.ts +684 -0
  15. package/composition/vector.ts +143 -0
  16. package/composition/visualizations.ts +335 -0
  17. package/design/README.md +17 -0
  18. package/design/palettes/README.md +14 -0
  19. package/design/palettes/base.ts +14 -0
  20. package/design/palettes/candidates.ts +19 -0
  21. package/design/palettes/index.ts +78 -0
  22. package/design/review/color-theme.md +44 -0
  23. package/design/review/layout.md +16 -0
  24. package/design/review/text.md +18 -0
  25. package/design/review/typography.md +15 -0
  26. package/design/review/visuals.md +31 -0
  27. package/design/semantic-patterns.md +43 -0
  28. package/design/themes/README.md +22 -0
  29. package/design/themes/index.ts +13 -0
  30. package/design/visual-languages/technical-product.md +17 -0
  31. package/design/visual-review.md +88 -0
  32. package/docs/development.md +140 -0
  33. package/docs/workflow.md +61 -0
  34. package/index.html +17 -0
  35. package/lib/assets.d.ts +8 -0
  36. package/lib/charts.ts +18 -0
  37. package/lib/contrast.ts +16 -0
  38. package/lib/layouts.ts +50 -0
  39. package/lib/slide.tsx +42 -0
  40. package/lib/taste.ts +17 -0
  41. package/lib/text.tsx +89 -0
  42. package/lib/typeface.ts +44 -0
  43. package/package.json +85 -0
  44. package/runtime/konpeki.mjs +1685 -0
  45. package/scripts/migrate-react-page.ts +120 -0
  46. package/slides/README.md +153 -0
  47. package/slides/architecture/PROMPT.md +31 -0
  48. package/slides/architecture/index.tsx +102 -0
  49. package/slides/article-brief/PROMPT.md +35 -0
  50. package/slides/article-brief/index.tsx +71 -0
  51. package/slides/bar-chart/PROMPT.md +39 -0
  52. package/slides/bar-chart/index.tsx +97 -0
  53. package/slides/comparison/PROMPT.md +29 -0
  54. package/slides/comparison/index.tsx +95 -0
  55. package/slides/decision-memo/PROMPT.md +34 -0
  56. package/slides/decision-memo/index.tsx +85 -0
  57. package/slides/delivery-plan/PROMPT.md +45 -0
  58. package/slides/delivery-plan/index.tsx +105 -0
  59. package/slides/experiment/PROMPT.md +44 -0
  60. package/slides/experiment/index.tsx +127 -0
  61. package/slides/incident-workflow/PROMPT.md +57 -0
  62. package/slides/incident-workflow/index.tsx +78 -0
  63. package/slides/introducing-konpeki/PROMPT.md +19 -0
  64. package/slides/introducing-konpeki/README.md +54 -0
  65. package/slides/introducing-konpeki/SOURCE.md +20 -0
  66. package/slides/introducing-konpeki/author.ts +163 -0
  67. package/slides/introducing-konpeki/composition.json +3265 -0
  68. package/slides/line-chart/PROMPT.md +40 -0
  69. package/slides/line-chart/index.tsx +72 -0
  70. package/slides/migration/PROMPT.md +38 -0
  71. package/slides/migration/index.tsx +89 -0
  72. package/slides/og-images/PROMPT.md +21 -0
  73. package/slides/og-images/index.tsx +76 -0
  74. package/slides/product-introduction/PROMPT.md +24 -0
  75. package/slides/product-introduction/index.tsx +105 -0
  76. package/slides/research-brief/PROMPT.md +40 -0
  77. package/slides/research-brief/index.tsx +104 -0
  78. package/slides/results-explanation/PROMPT.md +32 -0
  79. package/slides/results-explanation/index.tsx +96 -0
  80. package/slides/retrospective/PROMPT.md +43 -0
  81. package/slides/retrospective/index.tsx +105 -0
  82. package/slides/sankey/PROMPT.md +11 -0
  83. package/slides/sankey/index.tsx +93 -0
  84. package/slides/teaching/PROMPT.md +45 -0
  85. package/slides/teaching/index.tsx +124 -0
  86. package/slides/vertical-bar-charts/PROMPT.md +13 -0
  87. package/slides/vertical-bar-charts/index.tsx +97 -0
  88. package/src/app/App.tsx +694 -0
  89. package/src/assets/konpeki-mark.png +0 -0
  90. package/src/components/Canvas.tsx +1168 -0
  91. package/src/components/DiagramTypeIcon.tsx +78 -0
  92. package/src/components/InspectorPanel.tsx +687 -0
  93. package/src/components/LeftPanel.tsx +107 -0
  94. package/src/components/PageSizePicker.tsx +30 -0
  95. package/src/components/Presentation.tsx +105 -0
  96. package/src/components/RightPanel.tsx +201 -0
  97. package/src/components/VectorOverflowWarning.tsx +46 -0
  98. package/src/components/WorkspaceChrome.tsx +199 -0
  99. package/src/components/ui.tsx +53 -0
  100. package/src/lib/examples/react-page-migration.json +1295 -0
  101. package/src/lib/examples.ts +42 -0
  102. package/src/lib/export-png.ts +101 -0
  103. package/src/lib/file-session.ts +74 -0
  104. package/src/lib/history.ts +53 -0
  105. package/src/lib/model.ts +188 -0
  106. package/src/lib/page-size.ts +24 -0
  107. package/src/lib/presentation.ts +17 -0
  108. package/src/lib/storage.ts +43 -0
  109. package/src/lib/theme.ts +25 -0
  110. package/src/lib/use-file-session.ts +162 -0
  111. package/src/main.tsx +26 -0
  112. package/src/styles/base.css +105 -0
  113. package/src/styles/canvas.css +268 -0
  114. package/src/styles/chrome.css +214 -0
  115. package/src/styles/component-previews.css +386 -0
  116. package/src/styles/feedback.css +71 -0
  117. package/src/styles/left-panel.css +125 -0
  118. package/src/styles/presentation.css +72 -0
  119. package/src/styles/right-panel.css +1172 -0
  120. package/src/styles/shell.css +247 -0
  121. package/vite.config.ts +6 -0
@@ -0,0 +1,107 @@
1
+ import type { CSSProperties } from "react";
2
+ import type {
3
+ CompositionSlide,
4
+ ThemeMode,
5
+ } from "../../composition/types.ts";
6
+ import type { Draft } from "../lib/model.ts";
7
+
8
+ function ThumbnailCanvas({
9
+ slide,
10
+ themeMode,
11
+ pageIndex,
12
+ pageTotal,
13
+ }: {
14
+ slide: CompositionSlide;
15
+ themeMode: ThemeMode;
16
+ pageIndex: number;
17
+ pageTotal: number;
18
+ }) {
19
+ return (
20
+ <span className={`slide-thumbnail-canvas ${themeMode}`} style={{ aspectRatio: slide.canvas.width / slide.canvas.height }}>
21
+ {slide.components.map((component) => {
22
+ const rect = component.preferredRect;
23
+ const style = {
24
+ left: `${rect.x * 100 / slide.canvas.width}%`,
25
+ top: `${rect.y * 100 / slide.canvas.height}%`,
26
+ width: `${rect.width * 100 / slide.canvas.width}%`,
27
+ height: `${rect.height * 100 / slide.canvas.height}%`,
28
+ } as CSSProperties;
29
+ return (
30
+ <span
31
+ key={component.id}
32
+ className={`thumbnail-component kind-${component.kind} ${component.kind === "text-block" ? `role-${component.appearance.role}` : ""}`}
33
+ style={style}
34
+ />
35
+ );
36
+ })}
37
+ {slide.pageNumber?.style !== "none" && (
38
+ <span className="thumbnail-page-number">
39
+ {String(pageIndex).padStart(2, "0")}
40
+ {slide.pageNumber?.style === "01/02"
41
+ ? `/${String(pageTotal).padStart(2, "0")}`
42
+ : ""}
43
+ </span>
44
+ )}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ export function LeftPanel({
50
+ draft,
51
+ activeSlideId,
52
+ collapsed,
53
+ onSelectSlide,
54
+ onAddSlide,
55
+ onRemoveSlide,
56
+ }: {
57
+ draft: Draft;
58
+ activeSlideId: string;
59
+ collapsed: boolean;
60
+ onSelectSlide: (id: string) => void;
61
+ onAddSlide: () => void;
62
+ onRemoveSlide: (id: string) => void;
63
+ }) {
64
+ return (
65
+ <aside className={`left-panel${collapsed ? " collapsed" : ""}`} aria-label="Workspace panel" inert={collapsed}>
66
+ <div className="panel-toolbar">
67
+ <span className="panel-heading">Pages</span>
68
+ </div>
69
+ <div className="slide-list">
70
+ {draft.slides.map((item, index) => (
71
+ <div key={item.id} className="slide-thumbnail-item">
72
+ <button
73
+ type="button"
74
+ className={`slide-thumbnail ${item.id === activeSlideId ? "selected" : ""}`}
75
+ aria-current={item.id === activeSlideId ? "page" : undefined}
76
+ onClick={() => onSelectSlide(item.id)}
77
+ >
78
+ <ThumbnailCanvas
79
+ slide={item}
80
+ themeMode={draft.theme?.mode ?? "paper"}
81
+ pageIndex={index + 1}
82
+ pageTotal={draft.slides.length}
83
+ />
84
+ <span>{item.name}</span>
85
+ </button>
86
+ <button
87
+ type="button"
88
+ className="remove-slide"
89
+ aria-label={`Remove ${item.name}`}
90
+ onClick={() => onRemoveSlide(item.id)}
91
+ >
92
+ <span className="panel-close-icon" aria-hidden="true" />
93
+ </button>
94
+ </div>
95
+ ))}
96
+ <button
97
+ type="button"
98
+ className="add-slide"
99
+ aria-label="Add page"
100
+ onClick={onAddSlide}
101
+ >
102
+ <span aria-hidden="true">+</span>
103
+ </button>
104
+ </div>
105
+ </aside>
106
+ );
107
+ }
@@ -0,0 +1,30 @@
1
+ import type { CompositionSlide } from "../../composition/types.ts";
2
+ import { pagePresets, pageSizeIssue, resizePage } from "../lib/page-size.ts";
3
+
4
+ export function PageSizePicker({ slide, onChange }: { slide: CompositionSlide; onChange: (slide: CompositionSlide) => void }) {
5
+ const preset = pagePresets.find((item) => item.width === slide.canvas.width && item.height === slide.canvas.height);
6
+ function apply(w: number, h: number, destination: CompositionSlide["intendedViewingSize"] = "custom") {
7
+ const size = { width: w, height: h };
8
+ if (!pageSizeIssue(slide, size)) {
9
+ onChange(resizePage(slide, size, destination));
10
+ }
11
+ }
12
+ return <fieldset>
13
+ <legend>Size</legend>
14
+ <label className="field">
15
+ <span className="sr-only">Size</span>
16
+ <select name="page-format" value={preset?.name ?? "Imported"} onChange={(event) => {
17
+ const next = pagePresets.find((item) => item.name === event.target.value);
18
+ if (next) apply(next.width, next.height, next.destination);
19
+ }}>
20
+ {!preset && <option value="Imported" disabled>{slide.canvas.width} × {slide.canvas.height} · Imported size</option>}
21
+ {pagePresets.map((item) => {
22
+ const unavailable = Boolean(pageSizeIssue(slide, item));
23
+ return <option key={item.name} value={item.name} disabled={unavailable}>
24
+ {item.name} · {item.width} × {item.height}{unavailable ? " · unavailable" : ""}
25
+ </option>;
26
+ })}
27
+ </select>
28
+ </label>
29
+ </fieldset>;
30
+ }
@@ -0,0 +1,105 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Draft } from "../lib/model.ts";
3
+ import { presentationAction } from "../lib/presentation.ts";
4
+ import { Canvas } from "./Canvas.tsx";
5
+
6
+ const noop = () => {};
7
+
8
+ export function Presentation({
9
+ draft,
10
+ initialSlideId,
11
+ onExit,
12
+ }: {
13
+ draft: Draft;
14
+ initialSlideId: string;
15
+ onExit: () => void;
16
+ }) {
17
+ const [index, setIndex] = useState(() =>
18
+ Math.max(0, draft.slides.findIndex((slide) => slide.id === initialSlideId)),
19
+ );
20
+ const overlay = useRef<HTMLDivElement>(null);
21
+ const slide = draft.slides[index];
22
+
23
+ useEffect(() => {
24
+ overlay.current?.focus();
25
+ }, []);
26
+
27
+ useEffect(() => {
28
+ function keydown(event: KeyboardEvent) {
29
+ const interactiveTarget =
30
+ event.target instanceof Element &&
31
+ Boolean(event.target.closest("button,a,input,textarea,select,[contenteditable=true]"));
32
+ const action = presentationAction(
33
+ event.key,
34
+ index,
35
+ draft.slides.length,
36
+ interactiveTarget,
37
+ );
38
+ if (action === undefined) return;
39
+ event.preventDefault();
40
+ if (action === "exit") onExit();
41
+ else setIndex(action);
42
+ }
43
+ window.addEventListener("keydown", keydown);
44
+ return () => window.removeEventListener("keydown", keydown);
45
+ }, [draft.slides.length, index, onExit]);
46
+
47
+ return (
48
+ <div
49
+ ref={overlay}
50
+ className="presentation"
51
+ role="dialog"
52
+ aria-modal="true"
53
+ aria-label="Presentation mode"
54
+ tabIndex={-1}
55
+ >
56
+ <Canvas
57
+ key={slide.id}
58
+ mode="present"
59
+ draft={draft}
60
+ activeSlideId={slide.id}
61
+ onSelect={noop}
62
+ onSlideName={noop}
63
+ onComponent={noop}
64
+ onEditEnd={noop}
65
+ onAdd={noop}
66
+ onNotice={noop}
67
+ />
68
+ <nav className="presentation-controls" aria-label="Presentation controls">
69
+ <button
70
+ type="button"
71
+ disabled={index === 0}
72
+ aria-label="Previous page"
73
+ onClick={() => setIndex((value) => Math.max(0, value - 1))}
74
+ >
75
+ ←
76
+ </button>
77
+ <span aria-live="polite">
78
+ {index + 1} / {draft.slides.length}
79
+ </span>
80
+ <button
81
+ type="button"
82
+ disabled={index === draft.slides.length - 1}
83
+ aria-label="Next page"
84
+ onClick={() =>
85
+ setIndex((value) => Math.min(draft.slides.length - 1, value + 1))
86
+ }
87
+ >
88
+ →
89
+ </button>
90
+ <button
91
+ type="button"
92
+ onClick={() => {
93
+ if (document.fullscreenElement) void document.exitFullscreen();
94
+ else void overlay.current?.requestFullscreen();
95
+ }}
96
+ >
97
+ Full screen
98
+ </button>
99
+ <button type="button" onClick={onExit}>
100
+ Exit
101
+ </button>
102
+ </nav>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,201 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ CompositionComponent,
4
+ CompositionSlide,
5
+ } from "../../composition/types.ts";
6
+ import { componentInstanceLabel, type Draft } from "../lib/model.ts";
7
+ import { InspectorPanel } from "./InspectorPanel.tsx";
8
+
9
+ export type RightPanelView = "settings" | "layers";
10
+
11
+ function LayersPanel({
12
+ slide,
13
+ selectedComponentId,
14
+ onSelectComponent,
15
+ onReorderPaintOrder,
16
+ }: {
17
+ slide: CompositionSlide;
18
+ selectedComponentId?: string;
19
+ onSelectComponent: (id: string) => void;
20
+ onReorderPaintOrder: (ids: string[]) => void;
21
+ }) {
22
+ const [dragging, setDragging] = useState<string>();
23
+ const layers = [...slide.paintOrder].reverse();
24
+ function moveLayer(id: string, toIndex: number) {
25
+ const next = layers.filter((item) => item !== id);
26
+ next.splice(Math.max(0, Math.min(toIndex, next.length)), 0, id);
27
+ onReorderPaintOrder(next.reverse());
28
+ }
29
+ return (
30
+ <section className="layers-panel" aria-labelledby="layers-heading">
31
+ <div className="layers-heading-row">
32
+ <h2 id="layers-heading">Components & layers</h2>
33
+ <span>Front to back</span>
34
+ </div>
35
+ {layers.length ? (
36
+ <div className="layer-list">
37
+ {layers.map((id, index) => {
38
+ const component = slide.components.find((item) => item.id === id)!;
39
+ const groups = slide.groups
40
+ .filter((group) => group.childIds.includes(id))
41
+ .map((group) => group.label?.trim() || group.id);
42
+ const label = componentInstanceLabel(slide.components, id);
43
+ return (
44
+ <div
45
+ key={id}
46
+ data-layer={id}
47
+ className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
48
+ draggable
49
+ onDragStart={(event) => {
50
+ setDragging(id);
51
+ event.dataTransfer.effectAllowed = "move";
52
+ event.dataTransfer.setData("text/plain", id);
53
+ }}
54
+ onDragEnd={() => setDragging(undefined)}
55
+ onDragOver={(event) => {
56
+ event.preventDefault();
57
+ event.dataTransfer.dropEffect = "move";
58
+ }}
59
+ onDrop={(event) => {
60
+ event.preventDefault();
61
+ const source = dragging || event.dataTransfer.getData("text/plain");
62
+ if (source && source !== id) moveLayer(source, index);
63
+ setDragging(undefined);
64
+ }}
65
+ >
66
+ <span className="layer-drag" aria-hidden="true">⠿</span>
67
+ <button
68
+ type="button"
69
+ className="layer-select"
70
+ onClick={() => onSelectComponent(id)}
71
+ >
72
+ <strong>{label}</strong>
73
+ <span>{groups.length ? groups.join(", ") : "Page root"}</span>
74
+ </button>
75
+ <span className="layer-actions">
76
+ <button
77
+ type="button"
78
+ aria-label={`Move ${label} forward`}
79
+ disabled={index === 0}
80
+ onClick={() => moveLayer(id, index - 1)}
81
+ >↑</button>
82
+ <button
83
+ type="button"
84
+ aria-label={`Move ${label} backward`}
85
+ disabled={index === layers.length - 1}
86
+ onClick={() => moveLayer(id, index + 1)}
87
+ >↓</button>
88
+ </span>
89
+ </div>
90
+ );
91
+ })}
92
+ </div>
93
+ ) : (
94
+ <p className="layers-empty">No components yet.</p>
95
+ )}
96
+ </section>
97
+ );
98
+ }
99
+
100
+ export function RightPanel({
101
+ view,
102
+ collapsed,
103
+ component,
104
+ componentLabel,
105
+ selectedComponentId,
106
+ selectedVectorElementId,
107
+ vectorEditRequest,
108
+ slide,
109
+ draft,
110
+ onView,
111
+ onCollapsedChange,
112
+ onSelectSlide,
113
+ onSelectComponent,
114
+ onSelectVectorElement,
115
+ onReorderPaintOrder,
116
+ onComponent,
117
+ onSlide,
118
+ onDraft,
119
+ onEditEnd,
120
+ }: {
121
+ view: RightPanelView;
122
+ collapsed: boolean;
123
+ component?: CompositionComponent;
124
+ componentLabel?: string;
125
+ selectedComponentId?: string;
126
+ selectedVectorElementId?: string;
127
+ vectorEditRequest?: number;
128
+ slide: CompositionSlide;
129
+ draft: Draft;
130
+ onView: (view: RightPanelView) => void;
131
+ onCollapsedChange: (collapsed: boolean) => void;
132
+ onSelectSlide: () => void;
133
+ onSelectComponent: (id: string) => void;
134
+ onSelectVectorElement: (id?: string) => void;
135
+ onReorderPaintOrder: (ids: string[]) => void;
136
+ onComponent: (component: CompositionComponent, mergeKey?: string) => void;
137
+ onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
138
+ onDraft: (patch: Partial<Draft>, mergeKey?: string) => void;
139
+ onEditEnd: () => void;
140
+ }) {
141
+ return (
142
+ <aside className={`right-panel${collapsed ? " collapsed" : ""}`} aria-label="Inspector panel">
143
+ <div className="panel-toolbar">
144
+ <div className="panel-tabs" aria-label="Right panel view" inert={collapsed}>
145
+ {(["settings", "layers"] as const).map((tab) => (
146
+ <button
147
+ key={tab}
148
+ type="button"
149
+ aria-pressed={view === tab}
150
+ className={view === tab ? "active" : ""}
151
+ onClick={() => onView(tab)}
152
+ >
153
+ {tab === "settings" ? "Settings" : "Layers"}
154
+ </button>
155
+ ))}
156
+ </div>
157
+ <button
158
+ type="button"
159
+ className="panel-toggle"
160
+ aria-label={collapsed ? "Expand right panel" : "Collapse right panel"}
161
+ aria-expanded={!collapsed}
162
+ onClick={() => onCollapsedChange(!collapsed)}
163
+ >
164
+ <svg
165
+ className="panel-collapse-icon"
166
+ viewBox="0 0 24 24"
167
+ aria-hidden="true"
168
+ >
169
+ <rect x="3" y="4" width="18" height="16" rx="2" />
170
+ <path d={collapsed ? "M15 4v16M11 9l-3 3 3 3" : "M15 4v16M8 9l3 3-3 3"} />
171
+ </svg>
172
+ </button>
173
+ </div>
174
+ <div className="right-panel-body" inert={collapsed}>
175
+ {view === "settings" ? (
176
+ <InspectorPanel
177
+ component={component}
178
+ componentLabel={componentLabel}
179
+ slide={slide}
180
+ draft={draft}
181
+ selectedVectorElementId={selectedVectorElementId}
182
+ vectorEditRequest={vectorEditRequest}
183
+ onSelectSlide={onSelectSlide}
184
+ onSelectVectorElement={onSelectVectorElement}
185
+ onComponent={onComponent}
186
+ onSlide={onSlide}
187
+ onDraft={onDraft}
188
+ onEditEnd={onEditEnd}
189
+ />
190
+ ) : (
191
+ <LayersPanel
192
+ slide={slide}
193
+ selectedComponentId={selectedComponentId}
194
+ onSelectComponent={onSelectComponent}
195
+ onReorderPaintOrder={onReorderPaintOrder}
196
+ />
197
+ )}
198
+ </div>
199
+ </aside>
200
+ );
201
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
3
+
4
+ /** Measure rendered text, including nested SVG transforms, after fonts settle. */
5
+ export function VectorOverflowWarning({ slide, theme }: {
6
+ slide: CompositionSlide;
7
+ theme: CompositionDocument["theme"];
8
+ }) {
9
+ const [count, setCount] = useState(0);
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ let frame = 0;
13
+ function measure() {
14
+ frame = requestAnimationFrame(() => {
15
+ if (cancelled) return;
16
+ let overflow = 0;
17
+ document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
18
+ const surface = text.parentElement;
19
+ if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) overflow++;
20
+ });
21
+ document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
22
+ const root = svg.getScreenCTM();
23
+ if (!root) return;
24
+ const view = svg.viewBox.baseVal;
25
+ svg.querySelectorAll<SVGTextElement>("text").forEach((text) => {
26
+ const matrix = text.getScreenCTM();
27
+ if (!matrix) return;
28
+ const box = text.getBBox();
29
+ const transform = root.inverse().multiply(matrix);
30
+ const corners = [[box.x, box.y], [box.x + box.width, box.y],
31
+ [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]];
32
+ if (corners.some(([x, y]) => {
33
+ const p = new DOMPoint(x, y).matrixTransform(transform);
34
+ return p.x < view.x - 1 || p.y < view.y - 1 || p.x > view.x + view.width + 1 || p.y > view.y + view.height + 1;
35
+ })) overflow++;
36
+ });
37
+ });
38
+ setCount(overflow);
39
+ });
40
+ }
41
+ void document.fonts.ready.then(measure);
42
+ document.fonts.addEventListener("loadingdone", measure);
43
+ return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
44
+ }, [slide, theme]);
45
+ return count ? <p role="status" className="field-error">{count} text {count === 1 ? "element exceeds" : "elements exceed"} its component viewport on this slide. Revise the text or geometry; nothing was resized.</p> : null;
46
+ }
@@ -0,0 +1,199 @@
1
+ import type { RefObject } from "react";
2
+ import type { CompositionComponent } from "../../composition/types.ts";
3
+ import { componentLabels } from "../lib/model.ts";
4
+ import mark from "../assets/konpeki-mark.png";
5
+
6
+ const dockItems: {
7
+ kind: CompositionComponent["kind"];
8
+ }[] = [
9
+ { kind: "text-block" },
10
+ { kind: "diagram" },
11
+ { kind: "chart" },
12
+ { kind: "image" },
13
+ { kind: "table" },
14
+ ];
15
+
16
+ function DockIcon({
17
+ kind,
18
+ }: {
19
+ kind: CompositionComponent["kind"] | "select";
20
+ }) {
21
+ return (
22
+ <svg className="dock-glyph" viewBox="0 0 24 24" aria-hidden="true">
23
+ {kind === "select" ? (
24
+ <path d="M5 3.8 18 12l-6.2 1.2L8.6 19Z" />
25
+ ) : kind === "text-block" ? (
26
+ <path d="M5 6h14M5 10h14M5 14h10M5 18h12" />
27
+ ) : kind === "chart" ? (
28
+ <>
29
+ <path d="M5 19V5M5 19h14" />
30
+ <path d="M8 16v-4M12 16V8M16 16v-7" />
31
+ </>
32
+ ) : kind === "diagram" ? (
33
+ <>
34
+ <rect x="3" y="5" width="6" height="5" rx="1" />
35
+ <rect x="15" y="14" width="6" height="5" rx="1" />
36
+ <path d="M9 8h6v8M12 13l3 3 3-3" />
37
+ </>
38
+ ) : kind === "image" ? (
39
+ <>
40
+ <rect x="4" y="5" width="16" height="14" rx="1" />
41
+ <circle cx="15.5" cy="9" r="1.5" />
42
+ <path d="m6 17 4.5-5 3 3 2-2 2.5 4" />
43
+ </>
44
+ ) : kind === "table" ? (
45
+ <>
46
+ <rect x="3" y="5" width="18" height="14" rx="1" />
47
+ <path d="M3 10h18M9 5v14M15 5v14" />
48
+ </>
49
+ ) : (
50
+ <path d="M5 7h14M5 12h10M5 17h14" />
51
+ )}
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ export function WorkspaceChrome({
57
+ title,
58
+ titleRef,
59
+ titleError,
60
+ selectedKind,
61
+ onTitle,
62
+ onEditEnd,
63
+ onExportPNG,
64
+ exporting,
65
+ onPresent,
66
+ fileStatus,
67
+ building,
68
+ onBuild,
69
+ onSelectTool,
70
+ onComponentTool,
71
+ leftCollapsed,
72
+ onToggleLeftPanel,
73
+ }: {
74
+ leftCollapsed: boolean;
75
+ onToggleLeftPanel: () => void;
76
+ title: string;
77
+ titleRef: RefObject<HTMLInputElement | null>;
78
+ titleError: boolean;
79
+ selectedKind?: CompositionComponent["kind"];
80
+ onTitle: (title: string, mergeKey?: string) => void;
81
+ onEditEnd: () => void;
82
+ onExportPNG: () => void;
83
+ exporting: boolean;
84
+ onPresent: () => void;
85
+ fileStatus?: "loading" | "saved" | "saving" | "conflict" | "error";
86
+ building: boolean;
87
+ onBuild?: () => void;
88
+ onSelectTool: () => void;
89
+ onComponentTool: (kind: CompositionComponent["kind"]) => void;
90
+ }) {
91
+ return (
92
+ <>
93
+ <header className="document-island" aria-label="Document controls">
94
+ <img src={mark} alt="Konpeki" />
95
+ <label className="document-title">
96
+ <span className="sr-only">Composition title</span>
97
+ <input
98
+ ref={titleRef}
99
+ aria-invalid={titleError || undefined}
100
+ aria-describedby={titleError ? "title-error" : undefined}
101
+ name="composition-title"
102
+ autoComplete="off"
103
+ value={title}
104
+ onChange={(event) => onTitle(event.target.value, "title")}
105
+ onBlur={onEditEnd}
106
+ />
107
+ </label>
108
+ {titleError && (
109
+ <span className="document-title-error" id="title-error">
110
+ Enter a title.
111
+ </span>
112
+ )}
113
+ {fileStatus && fileStatus !== "saved" && (
114
+ <span
115
+ className={`file-status ${fileStatus}`}
116
+ role="status"
117
+ aria-live="polite"
118
+ title={fileStatus === "conflict"
119
+ ? "The file changed elsewhere while this browser has edits."
120
+ : undefined}
121
+ >
122
+ {fileStatus === "loading" ? "Opening"
123
+ : fileStatus === "saving" ? "Saving"
124
+ : fileStatus === "conflict" ? "Conflict" : "Save error"}
125
+ </span>
126
+ )}
127
+ <button type="button" className="panel-toggle"
128
+ aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
129
+ onClick={onToggleLeftPanel}>
130
+ <svg className="panel-collapse-icon" viewBox="0 0 24 24" aria-hidden="true">
131
+ <rect x="3" y="4" width="18" height="16" rx="2" />
132
+ <path d={leftCollapsed ? "M9 4v16M13 9l3 3-3 3" : "M9 4v16M16 9l-3 3 3 3"} />
133
+ </svg>
134
+ </button>
135
+ </header>
136
+
137
+ <div className="action-island" aria-label="Document actions" inert={leftCollapsed}>
138
+ <button
139
+ type="button"
140
+ className="icon-only"
141
+ aria-label="Export PNG"
142
+ title="Export active page as PNG"
143
+ disabled={exporting}
144
+ onClick={onExportPNG}
145
+ >
146
+ <svg viewBox="0 0 24 24" aria-hidden="true">
147
+ <rect x="4" y="4" width="16" height="12" rx="2" />
148
+ <path d="m7 14 3.5-4 3 3 2-2 2.5 3M12 16v5m-3-3 3 3 3-3" />
149
+ </svg>
150
+ </button>
151
+ <button type="button" onClick={onPresent}>
152
+ <svg viewBox="0 0 24 24" aria-hidden="true">
153
+ <rect x="3" y="4" width="18" height="12" rx="2" />
154
+ <path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
155
+ </svg>
156
+ Present
157
+ </button>
158
+ {onBuild && (
159
+ <button
160
+ type="button"
161
+ className="primary"
162
+ disabled={fileStatus !== "saved" || building}
163
+ onClick={onBuild}
164
+ >
165
+ <svg viewBox="0 0 24 24" aria-hidden="true">
166
+ <path d="m5 19 10-10 3 3L8 22Zm10-10 2-2 3 3-2 2M5 3v6M2 6h6m10-5v4m-2-2h4" />
167
+ </svg>
168
+ {building ? "Building…" : "Build it"}
169
+ </button>
170
+ )}
171
+ </div>
172
+
173
+ <nav className="component-dock" aria-label="Canvas tools">
174
+ <button
175
+ type="button"
176
+ className={!selectedKind ? "active" : ""}
177
+ aria-pressed={!selectedKind}
178
+ onClick={onSelectTool}
179
+ >
180
+ <DockIcon kind="select" />
181
+ <span>Select</span>
182
+ </button>
183
+ {dockItems.map(({ kind }) => (
184
+ <button
185
+ key={kind}
186
+ type="button"
187
+ className={selectedKind === kind ? "active" : ""}
188
+ aria-pressed={selectedKind === kind}
189
+ title={`Add or select ${componentLabels[kind]}`}
190
+ onClick={() => onComponentTool(kind)}
191
+ >
192
+ <DockIcon kind={kind} />
193
+ <span>{componentLabels[kind]}</span>
194
+ </button>
195
+ ))}
196
+ </nav>
197
+ </>
198
+ );
199
+ }