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,42 @@
1
+ import { validateComposition } from "../../composition/validate.ts";
2
+ import { parseEditableSvg } from "../../composition/vector.ts";
3
+ import { initialDraft, type Draft } from "./model.ts";
4
+ import migratedReactPage from "./examples/react-page-migration.json" with { type: "json" };
5
+ import introducingKonpeki from "../../slides/introducing-konpeki/composition.json" with { type: "json" };
6
+
7
+ const customVisualSource = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 530"><rect width="1080" height="530" rx="28" fill="#F5F7F8"/><text x="54" y="66" font-family="Arial,sans-serif" font-size="24" font-weight="700" fill="#13242C">A shared canvas keeps intent and output together</text><line x1="253" y1="272" x2="407" y2="272" stroke="#007BBB" stroke-width="8"/><path d="M397 258L425 272 397 286Z" fill="#007BBB"/><line x1="672" y1="272" x2="826" y2="272" stroke="#007BBB" stroke-width="8"/><path d="M816 258L844 272 816 286Z" fill="#007BBB"/><g font-family="Arial,sans-serif" text-anchor="middle"><rect x="54" y="152" width="200" height="240" rx="24" fill="#FFFFFF" stroke="#CAD4D8" stroke-width="3"/><circle cx="154" cy="224" r="35" fill="#D9EDF5"/><path d="M135 224h38M154 205v38" stroke="#007BBB" stroke-width="6"/><text x="154" y="302" font-size="25" font-weight="700" fill="#13242C">Draft</text><text x="154" y="338" font-size="18" fill="#52666F">Human places intent</text><rect x="425" y="152" width="248" height="240" rx="24" fill="#007BBB"/><path d="M492 220h112v82H492z" fill="none" stroke="#FFFFFF" stroke-width="6"/><path d="M514 249h67M514 272h45" stroke="#FFFFFF" stroke-width="5"/><text x="549" y="334" font-size="25" font-weight="700" fill="#FFFFFF">Agent renders</text><text x="549" y="367" font-size="18" fill="#E4F2F7">SVG stays inside</text><rect x="844" y="152" width="182" height="240" rx="24" fill="#FFFFFF" stroke="#CAD4D8" stroke-width="3"/><path d="M900 217a45 45 0 1 1-4 62" fill="none" stroke="#007BBB" stroke-width="7"/><path d="M887 274l11 23 18-18" fill="#007BBB"/><text x="935" y="327" font-size="25" font-weight="700" fill="#13242C">Revise</text><text x="935" y="360" font-size="18" fill="#52666F">One document</text></g><text x="54" y="475" font-family="Arial,sans-serif" font-size="17" fill="#52666F">Outer geometry remains editable; vector elements own this interior.</text></svg>`;
8
+
9
+ export function exampleDraft(name: string | null): Draft | undefined {
10
+ if (name === "introducing-konpeki") {
11
+ const validation = validateComposition(introducingKonpeki);
12
+ if (!validation.ok) throw new Error("Invalid Konpeki introduction deck");
13
+ return validation.document;
14
+ }
15
+ if (name === "react-page-migration") {
16
+ const validation = validateComposition(migratedReactPage);
17
+ if (!validation.ok) throw new Error("Invalid migrated React page example");
18
+ return validation.document;
19
+ }
20
+ if (name !== "custom-visual") return undefined;
21
+ const draft = initialDraft();
22
+ draft.title = "Custom visual round-trip";
23
+ draft.slides[0].name = "Agent visual";
24
+ draft.slides[0].components[0].intent = "Editable output, not a flattened render";
25
+ const visual = draft.slides[0].components[1];
26
+ visual.intent = "Draft to agent render to revision workflow.";
27
+ const parsed = parseEditableSvg(customVisualSource);
28
+ visual.customVisual = {
29
+ format: "vector",
30
+ elements: parsed.elements,
31
+ viewBox: parsed.viewBox,
32
+ description: "Draft to agent render to revision workflow",
33
+ fit: "contain",
34
+ };
35
+ draft.slides[0].components[2].intent =
36
+ "Slide structure stays semantic; vector elements remain individually editable.";
37
+ draft.slides[0].components[3].intent =
38
+ "Double-click the diagram, then select a line, shape, path, or text element to revise it.";
39
+ const validation = validateComposition(draft);
40
+ if (!validation.ok) throw new Error("Invalid bundled example composition");
41
+ return validation.document;
42
+ }
@@ -0,0 +1,101 @@
1
+ import type { CanvasSize } from "../../composition/types.ts";
2
+
3
+ function dataURL(blob: Blob): Promise<string> {
4
+ return new Promise((resolve, reject) => {
5
+ const reader = new FileReader();
6
+ reader.onload = () => resolve(String(reader.result));
7
+ reader.onerror = () => reject(new Error("Could not embed export resources."));
8
+ reader.readAsDataURL(blob);
9
+ });
10
+ }
11
+
12
+ // Serialize the actual renderer, not a second approximation of the composition.
13
+ // Resolve computed values (including container units) and embed fonts so the SVG
14
+ // image has no external resources when rasterized by the browser.
15
+ export async function exportPagePNG(
16
+ source: HTMLElement,
17
+ size: CanvasSize,
18
+ ): Promise<Blob> {
19
+ await document.fonts.ready;
20
+ await Promise.all(
21
+ Array.from(source.querySelectorAll("img"), (image) => image.decode()),
22
+ );
23
+ const clone = source.cloneNode(true) as HTMLElement;
24
+ const originals = [source, ...source.querySelectorAll("*")];
25
+ const copies = [clone, ...clone.querySelectorAll("*")];
26
+ originals.forEach((node, index) => {
27
+ const target = copies[index];
28
+ if (!(target instanceof HTMLElement || target instanceof SVGElement)) return;
29
+ const computed = getComputedStyle(node);
30
+ for (const key of computed)
31
+ target.style.setProperty(key, computed.getPropertyValue(key));
32
+ target.style.animation = "none";
33
+ target.style.transition = "none";
34
+ target.style.outline = "none";
35
+ if (node === source || node.classList.contains("slide-component"))
36
+ target.style.boxShadow = "none";
37
+ // Table preview cells use generated bars. Materialize these decorations too.
38
+ if (target instanceof HTMLElement)
39
+ for (const pseudo of ["::before", "::after"]) {
40
+ const decoration = getComputedStyle(node, pseudo);
41
+ if (decoration.content !== '""') continue;
42
+ const span = document.createElement("span");
43
+ for (const key of decoration)
44
+ span.style.setProperty(key, decoration.getPropertyValue(key));
45
+ if (pseudo === "::before") target.prepend(span);
46
+ else target.append(span);
47
+ }
48
+ });
49
+ clone
50
+ .querySelectorAll(
51
+ ".resize-handle, .guide, .vector-handles, .inline-intent-editor",
52
+ )
53
+ .forEach((node) => node.remove());
54
+ const rect = source.getBoundingClientRect();
55
+ clone.style.width = `${rect.width}px`;
56
+ clone.style.height = `${rect.height}px`;
57
+ clone.style.margin = "0";
58
+ clone.style.overflow = "hidden";
59
+ const fontRules = Array.from(document.styleSheets).flatMap((sheet) =>
60
+ Array.from(sheet.cssRules).filter(
61
+ (rule): rule is CSSFontFaceRule => rule instanceof CSSFontFaceRule,
62
+ ),
63
+ );
64
+ const fontCSS = await Promise.all(
65
+ fontRules.map(async (rule) => {
66
+ let css = rule.cssText;
67
+ for (const match of css.matchAll(/url\(["']?([^"')]+)["']?\)/g)) {
68
+ const response = await fetch(
69
+ new URL(match[1], rule.parentStyleSheet?.href ?? document.baseURI),
70
+ );
71
+ if (!response.ok) throw new Error("Could not load export font.");
72
+ css = css.replace(
73
+ match[0],
74
+ `url("${await dataURL(await response.blob())}")`,
75
+ );
76
+ }
77
+ return css;
78
+ }),
79
+ );
80
+ const style = document.createElement("style");
81
+ style.textContent = fontCSS.join("\n");
82
+ clone.prepend(style);
83
+ const markup = new XMLSerializer().serializeToString(clone);
84
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size.width}" height="${size.height}" viewBox="0 0 ${rect.width} ${rect.height}"><foreignObject width="100%" height="100%">${markup}</foreignObject></svg>`;
85
+ const image = new Image();
86
+ image.src = await dataURL(new Blob([svg], { type: "image/svg+xml" }));
87
+ await image.decode();
88
+ const canvas = document.createElement("canvas");
89
+ canvas.width = size.width;
90
+ canvas.height = size.height;
91
+ const context = canvas.getContext("2d");
92
+ if (!context) throw new Error("PNG export is unavailable in this browser.");
93
+ context.drawImage(image, 0, 0);
94
+ return new Promise((resolve, reject) =>
95
+ canvas.toBlob(
96
+ (blob) =>
97
+ blob ? resolve(blob) : reject(new Error("PNG encoding failed.")),
98
+ "image/png",
99
+ ),
100
+ );
101
+ }
@@ -0,0 +1,74 @@
1
+ import type { CompositionDocument } from "../../composition/types.ts";
2
+
3
+ export type FileSessionDocument = {
4
+ document: CompositionDocument;
5
+ revision: string;
6
+ name: string;
7
+ };
8
+
9
+ export class FileSessionError extends Error {
10
+ constructor(
11
+ message: string,
12
+ readonly status: number,
13
+ readonly revision?: string,
14
+ ) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ export function fileSessionToken() {
20
+ return new URLSearchParams(window.location.search).get("session");
21
+ }
22
+
23
+ async function request<T>(
24
+ token: string,
25
+ path = "",
26
+ init?: RequestInit,
27
+ ): Promise<T> {
28
+ const response = await fetch(`/__konpeki/session${path}`, {
29
+ ...init,
30
+ headers: {
31
+ ...init?.headers,
32
+ "x-konpeki-session": token,
33
+ ...(init?.body ? { "content-type": "application/json" } : {}),
34
+ },
35
+ });
36
+ const body = await response.json() as { error?: string; revision?: string } & T;
37
+ if (!response.ok)
38
+ throw new FileSessionError(
39
+ body.error || "The local Konpeki service could not complete the request.",
40
+ response.status,
41
+ body.revision,
42
+ );
43
+ return body;
44
+ }
45
+
46
+ export function loadFileSession(token: string) {
47
+ return request<FileSessionDocument>(token);
48
+ }
49
+
50
+ export function saveFileSession(
51
+ token: string,
52
+ revision: string,
53
+ document: CompositionDocument,
54
+ ) {
55
+ return request<{ revision: string }>(token, "", {
56
+ method: "PUT",
57
+ body: JSON.stringify({ revision, document }),
58
+ });
59
+ }
60
+
61
+ export function sendBuildRequest(
62
+ token: string,
63
+ requestBody: {
64
+ revision: string;
65
+ instruction: string;
66
+ slideId: string;
67
+ componentId?: string;
68
+ },
69
+ ) {
70
+ return request<{ ok: true }>(token, "/build", {
71
+ method: "POST",
72
+ body: JSON.stringify(requestBody),
73
+ });
74
+ }
@@ -0,0 +1,53 @@
1
+ export type History<T> = {
2
+ past: T[];
3
+ present: T;
4
+ future: T[];
5
+ mergeKey?: string;
6
+ };
7
+
8
+ const historyLimit = 100;
9
+
10
+ export function createHistory<T>(present: T): History<T> {
11
+ return { past: [], present, future: [] };
12
+ }
13
+
14
+ export function commitHistory<T>(
15
+ history: History<T>,
16
+ present: T,
17
+ mergeKey?: string,
18
+ ): History<T> {
19
+ if (Object.is(history.present, present)) return history;
20
+ const merging = mergeKey !== undefined && mergeKey === history.mergeKey;
21
+ return {
22
+ past: merging
23
+ ? history.past
24
+ : [...history.past, history.present].slice(-historyLimit),
25
+ present,
26
+ future: [],
27
+ ...(mergeKey ? { mergeKey } : {}),
28
+ };
29
+ }
30
+
31
+ export function finishHistoryEdit<T>(history: History<T>): History<T> {
32
+ return history.mergeKey ? { ...history, mergeKey: undefined } : history;
33
+ }
34
+
35
+ export function undoHistory<T>(history: History<T>): History<T> {
36
+ if (!history.past.length) return finishHistoryEdit(history);
37
+ const present = history.past.at(-1)!;
38
+ return {
39
+ past: history.past.slice(0, -1),
40
+ present,
41
+ future: [history.present, ...history.future],
42
+ };
43
+ }
44
+
45
+ export function redoHistory<T>(history: History<T>): History<T> {
46
+ if (!history.future.length) return finishHistoryEdit(history);
47
+ const [present, ...future] = history.future;
48
+ return {
49
+ past: [...history.past, history.present].slice(-historyLimit),
50
+ present,
51
+ future,
52
+ };
53
+ }
@@ -0,0 +1,188 @@
1
+ import {
2
+ canvasPadding,
3
+ canvasSize,
4
+ type CanvasSize,
5
+ type CompositionComponent,
6
+ type CompositionSlide,
7
+ type Rect,
8
+ } from "../../composition/types.ts";
9
+ import { componentLabels } from "../../composition/document.ts";
10
+
11
+ export * from "../../composition/document.ts";
12
+
13
+ export type Guide = { axis: "x" | "y"; value: number };
14
+
15
+ export function componentInstanceLabel(
16
+ components: CompositionComponent[],
17
+ id: string,
18
+ ) {
19
+ const component = components.find((item) => item.id === id);
20
+ if (!component) return id;
21
+ const peers = components.filter((item) => item.kind === component.kind);
22
+ if (peers.length === 1) return componentLabels[component.kind];
23
+ return `${componentLabels[component.kind]} ${peers.indexOf(component) + 1}`;
24
+ }
25
+
26
+ export function clamp(value: number, minimum: number, maximum: number) {
27
+ return Math.min(maximum, Math.max(minimum, value));
28
+ }
29
+
30
+ function alignmentTargets(
31
+ others: Rect[],
32
+ size: CanvasSize,
33
+ padding: CompositionSlide["innerPadding"] = canvasPadding,
34
+ ) {
35
+ return {
36
+ x: [
37
+ 0,
38
+ padding?.left ?? 0,
39
+ size.width / 2,
40
+ size.width - (padding?.right ?? 0),
41
+ size.width,
42
+ ...others.flatMap((rect) => [
43
+ rect.x,
44
+ rect.x + rect.width / 2,
45
+ rect.x + rect.width,
46
+ ]),
47
+ ],
48
+ y: [
49
+ 0,
50
+ padding?.top ?? 0,
51
+ size.height / 2,
52
+ size.height - (padding?.bottom ?? 0),
53
+ size.height,
54
+ ...others.flatMap((rect) => [
55
+ rect.y,
56
+ rect.y + rect.height / 2,
57
+ rect.y + rect.height,
58
+ ]),
59
+ ],
60
+ };
61
+ }
62
+
63
+ function closest(points: number[], targets: number[], threshold: number) {
64
+ let match: { delta: number; target: number } | undefined;
65
+ for (const point of points)
66
+ for (const target of targets) {
67
+ const delta = target - point;
68
+ if (
69
+ Math.abs(delta) <= threshold &&
70
+ (!match || Math.abs(delta) < Math.abs(match.delta))
71
+ )
72
+ match = { delta, target };
73
+ }
74
+ return match;
75
+ }
76
+
77
+ export function snapRect(
78
+ rect: Rect,
79
+ others: Rect[],
80
+ threshold = 14,
81
+ bounds: CanvasSize = canvasSize,
82
+ padding?: CompositionSlide["innerPadding"],
83
+ ): { rect: Rect; guides: Guide[] } {
84
+ const result = {
85
+ ...rect,
86
+ x: clamp(rect.x, 0, bounds.width - rect.width),
87
+ y: clamp(rect.y, 0, bounds.height - rect.height),
88
+ };
89
+ const targets = alignmentTargets(others, bounds, padding);
90
+ const guides: Guide[] = [];
91
+ for (const [axis, size, limit] of [
92
+ ["x", "width", bounds.width],
93
+ ["y", "height", bounds.height],
94
+ ] as const) {
95
+ const points = [
96
+ result[axis],
97
+ result[axis] + result[size] / 2,
98
+ result[axis] + result[size],
99
+ ];
100
+ const match =
101
+ result[axis] <= threshold
102
+ ? { delta: -result[axis], target: 0 }
103
+ : limit - result[axis] - result[size] <= threshold
104
+ ? {
105
+ delta: limit - result[axis] - result[size],
106
+ target: limit,
107
+ }
108
+ : closest(points, targets[axis], threshold);
109
+ if (
110
+ match &&
111
+ result[axis] + match.delta >= 0 &&
112
+ result[axis] + match.delta + result[size] <= limit
113
+ ) {
114
+ result[axis] += match.delta;
115
+ guides.push({ axis, value: match.target });
116
+ }
117
+ }
118
+ return { rect: result, guides };
119
+ }
120
+
121
+ export function snapResizeRect(
122
+ rect: Rect,
123
+ others: Rect[],
124
+ threshold = 14,
125
+ edges: { left?: boolean; top?: boolean; right?: boolean; bottom?: boolean } = {
126
+ right: true,
127
+ bottom: true,
128
+ },
129
+ bounds: CanvasSize = canvasSize,
130
+ padding?: CompositionSlide["innerPadding"],
131
+ ): { rect: Rect; guides: Guide[] } {
132
+ const right = rect.x + rect.width;
133
+ const bottom = rect.y + rect.height;
134
+ const result = { ...rect };
135
+ if (edges.left) {
136
+ const minimum = Math.min(180, right);
137
+ result.x = clamp(rect.x, 0, right - minimum);
138
+ result.width = right - result.x;
139
+ } else {
140
+ result.width = clamp(
141
+ rect.width,
142
+ Math.min(180, bounds.width - rect.x),
143
+ bounds.width - rect.x,
144
+ );
145
+ }
146
+ if (edges.top) {
147
+ const minimum = Math.min(72, bottom);
148
+ result.y = clamp(rect.y, 0, bottom - minimum);
149
+ result.height = bottom - result.y;
150
+ } else {
151
+ result.height = clamp(
152
+ rect.height,
153
+ Math.min(72, bounds.height - rect.y),
154
+ bounds.height - rect.y,
155
+ );
156
+ }
157
+ const targets = alignmentTargets(others, bounds, padding);
158
+ const guides: Guide[] = [];
159
+ for (const [axis, size, configuredMinimum, limit, leading] of [
160
+ ["x", "width", 180, bounds.width, Boolean(edges.left)],
161
+ ["y", "height", 72, bounds.height, Boolean(edges.top)],
162
+ ] as const) {
163
+ const point = leading ? result[axis] : result[axis] + result[size];
164
+ const minimum = leading
165
+ ? Math.min(configuredMinimum, point + result[size])
166
+ : configuredMinimum;
167
+ const match = closest(
168
+ [point],
169
+ targets[axis].filter((target) =>
170
+ leading
171
+ ? target >= 0 && target <= point + result[size] - minimum
172
+ : target >= result[axis] + minimum && target <= limit,
173
+ ),
174
+ threshold,
175
+ );
176
+ if (match) {
177
+ if (leading) {
178
+ const trailing = result[axis] + result[size];
179
+ result[axis] = match.target;
180
+ result[size] = trailing - match.target;
181
+ } else {
182
+ result[size] = match.target - result[axis];
183
+ }
184
+ guides.push({ axis, value: match.target });
185
+ }
186
+ }
187
+ return { rect: result, guides };
188
+ }
@@ -0,0 +1,24 @@
1
+ import type { CanvasSize, CompositionSlide } from "../../composition/types.ts";
2
+
3
+ export const pagePresets = [
4
+ { name: "Presentation", width: 1920, height: 1080, destination: "presentation" },
5
+ { name: "Square post", width: 1080, height: 1080, destination: "social" },
6
+ { name: "Portrait post", width: 1080, height: 1350, destination: "social" },
7
+ { name: "Link preview / OG", width: 1200, height: 630, destination: "social" },
8
+ { name: "Article header", width: 1600, height: 600, destination: "article" },
9
+ ] as const;
10
+
11
+ export function pageSizeIssue(slide: CompositionSlide, size: CanvasSize): string | undefined {
12
+ if (![size.width, size.height].every((value) => Number.isInteger(value) && value >= 256 && value <= 4096))
13
+ return "Use whole pixels from 256 to 4096 for each dimension.";
14
+ if (slide.components.some(({ preferredRect: r }) => r.x + r.width > size.width || r.y + r.height > size.height))
15
+ return "Existing content would fall outside this page. Move or resize it first, or ask your agent to adapt the composition. Nothing has changed.";
16
+ const p = slide.innerPadding;
17
+ if (p && (p.left + p.right >= size.width || p.top + p.bottom >= size.height))
18
+ return "The existing content margins do not fit this size. Ask your agent to adapt the margins.";
19
+ }
20
+
21
+ export function resizePage(slide: CompositionSlide, size: CanvasSize, destination: CompositionSlide["intendedViewingSize"] = "custom"): CompositionSlide {
22
+ if (pageSizeIssue(slide, size)) return slide;
23
+ return { ...slide, canvas: { width: size.width, height: size.height }, intendedViewingSize: destination };
24
+ }
@@ -0,0 +1,17 @@
1
+ export type PresentationAction = number | "exit" | undefined;
2
+
3
+ export function presentationAction(
4
+ key: string,
5
+ current: number,
6
+ slideCount: number,
7
+ interactiveTarget = false,
8
+ ): PresentationAction {
9
+ if (interactiveTarget) return undefined;
10
+ if (key === "Escape") return "exit";
11
+ if (key === "Home") return 0;
12
+ if (key === "End") return Math.max(0, slideCount - 1);
13
+ if (["ArrowRight", "PageDown", " "].includes(key))
14
+ return Math.min(slideCount - 1, current + 1);
15
+ if (["ArrowLeft", "PageUp"].includes(key)) return Math.max(0, current - 1);
16
+ return undefined;
17
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ initialDraft,
3
+ parseStoredDraft,
4
+ serializeDraft,
5
+ type Draft,
6
+ } from "./model.ts";
7
+ export const storageKey = "konpeki-composer/v1";
8
+ export type LoadedDraft = {
9
+ draft: Draft;
10
+ storageBlocked: boolean;
11
+ error?: string;
12
+ };
13
+ export function loadDraft(): LoadedDraft {
14
+ try {
15
+ const saved = localStorage.getItem(storageKey);
16
+ if (saved === null) return { draft: initialDraft(true), storageBlocked: false };
17
+ const result = parseStoredDraft(saved);
18
+ return result.ok
19
+ ? { draft: result.draft, storageBlocked: false }
20
+ : {
21
+ draft: initialDraft(true),
22
+ storageBlocked: true,
23
+ error:
24
+ "Saved data could not be read. It has not been changed. Reset saved draft to recover.",
25
+ };
26
+ } catch {
27
+ return {
28
+ draft: initialDraft(true),
29
+ storageBlocked: true,
30
+ error:
31
+ "Storage is unavailable. You can still export your work; reset to retry saving.",
32
+ };
33
+ }
34
+ }
35
+ export function persistDraft(draft: Draft) {
36
+ const value = serializeDraft(draft);
37
+ if (!parseStoredDraft(value).ok)
38
+ throw new Error("Invalid draft cannot be saved.");
39
+ localStorage.setItem(storageKey, value);
40
+ }
41
+ export function clearStoredDraft() {
42
+ localStorage.removeItem(storageKey);
43
+ }
@@ -0,0 +1,25 @@
1
+ import {
2
+ resolvePalette,
3
+ type PaletteMode,
4
+ type PaletteName,
5
+ } from "../../design/palettes/index.ts";
6
+ import type { ThemeId } from "../../composition/types.ts";
7
+
8
+ const themeLabels: Record<ThemeId, PaletteName> = {
9
+ plex: "Plex",
10
+ precision: "Precision",
11
+ editorial: "Editorial",
12
+ "blue-cyan": "Blue–cyan",
13
+ "orange-coral": "Orange–coral",
14
+ yellow: "Yellow",
15
+ green: "Green",
16
+ graphite: "Graphite",
17
+ };
18
+
19
+ export function themeLabel(id: ThemeId) {
20
+ return themeLabels[id];
21
+ }
22
+
23
+ export function composerPalette(id: ThemeId, mode: PaletteMode) {
24
+ return resolvePalette(themeLabel(id), mode);
25
+ }