omegon 0.6.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 (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Excalidraw Element Factories
3
+ *
4
+ * Vendored and merged from @swiftlysingh/excalidraw-cli@1.1.0:
5
+ * - src/factory/element-factory.ts (base element creation)
6
+ * - src/factory/node-factory.ts (shape creation)
7
+ * - src/factory/connection-factory.ts (arrow creation)
8
+ * - src/factory/text-factory.ts (text creation)
9
+ *
10
+ * Changes from upstream:
11
+ * - Merged 4 files into 1 for simpler vendoring
12
+ * - Replaced nanoid with crypto.randomUUID()
13
+ * - Added semantic palette integration (optional `semantic` param)
14
+ * - Added document builder (createDocument)
15
+ * - Added validation (validateDocument)
16
+ * - Added layout helpers (fanOut, timeline, grid)
17
+ * - Changed default roughness from 1 to 0 (clean/modern)
18
+ *
19
+ * See UPSTREAM.md for sync guide.
20
+ */
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import {
24
+ DEFAULT_ELEMENT_STYLE,
25
+ DEFAULT_APP_STATE,
26
+ SEMANTIC_COLORS,
27
+ TEXT_COLORS,
28
+ FONT_FAMILIES,
29
+ type ElementBase,
30
+ type ExcalidrawElement,
31
+ type ExcalidrawFile,
32
+ type RectangleElement,
33
+ type DiamondElement,
34
+ type EllipseElement,
35
+ type TextElement,
36
+ type ArrowElement,
37
+ type LineElement,
38
+ type BoundElement,
39
+ type ArrowBinding,
40
+ type Arrowhead,
41
+ type FillStyle,
42
+ type StrokeStyle,
43
+ type TextAlign,
44
+ type VerticalAlign,
45
+ type Roundness,
46
+ type SemanticPurpose,
47
+ type TextLevel,
48
+ } from "./types.ts";
49
+
50
+ // Re-export types for consumers
51
+ export type {
52
+ ExcalidrawElement,
53
+ ExcalidrawFile,
54
+ RectangleElement,
55
+ DiamondElement,
56
+ EllipseElement,
57
+ TextElement,
58
+ ArrowElement,
59
+ LineElement,
60
+ SemanticPurpose,
61
+ TextLevel,
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Internal helpers (from upstream element-factory.ts)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ let indexCounter = 0;
69
+
70
+ function generateIndex(): string {
71
+ indexCounter++;
72
+ let idx = indexCounter;
73
+ let result = "";
74
+ while (idx > 0) {
75
+ idx--;
76
+ result = String.fromCharCode(97 + (idx % 26)) + result;
77
+ idx = Math.floor(idx / 26);
78
+ }
79
+ return "a" + result;
80
+ }
81
+
82
+ function generateSeed(): number {
83
+ return Math.floor(Math.random() * 2147483647);
84
+ }
85
+
86
+ /** Reset index counter — useful for tests or fresh documents. */
87
+ export function resetIndexCounter(): void {
88
+ indexCounter = 0;
89
+ }
90
+
91
+ /** Generate a short element ID. Uses crypto.randomUUID() instead of nanoid. */
92
+ function newId(): string {
93
+ return randomUUID().replace(/-/g, "").slice(0, 21);
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Base element factory (from upstream element-factory.ts)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function createBaseElement(
101
+ type: ElementBase["type"],
102
+ x: number,
103
+ y: number,
104
+ width: number,
105
+ height: number,
106
+ options?: Partial<ElementBase>,
107
+ ): ElementBase {
108
+ return {
109
+ id: options?.id || newId(),
110
+ type,
111
+ x,
112
+ y,
113
+ width,
114
+ height,
115
+ angle: options?.angle ?? 0,
116
+ strokeColor: options?.strokeColor ?? DEFAULT_ELEMENT_STYLE.strokeColor,
117
+ backgroundColor: options?.backgroundColor ?? DEFAULT_ELEMENT_STYLE.backgroundColor,
118
+ fillStyle: options?.fillStyle ?? DEFAULT_ELEMENT_STYLE.fillStyle,
119
+ strokeWidth: options?.strokeWidth ?? DEFAULT_ELEMENT_STYLE.strokeWidth,
120
+ strokeStyle: options?.strokeStyle ?? DEFAULT_ELEMENT_STYLE.strokeStyle,
121
+ roughness: options?.roughness ?? DEFAULT_ELEMENT_STYLE.roughness,
122
+ opacity: options?.opacity ?? DEFAULT_ELEMENT_STYLE.opacity,
123
+ groupIds: options?.groupIds ?? [],
124
+ frameId: options?.frameId ?? null,
125
+ index: options?.index ?? generateIndex(),
126
+ roundness: options?.roundness ?? null,
127
+ seed: options?.seed ?? generateSeed(),
128
+ version: options?.version ?? 1,
129
+ versionNonce: options?.versionNonce ?? generateSeed(),
130
+ isDeleted: options?.isDeleted ?? false,
131
+ boundElements: options?.boundElements ?? null,
132
+ updated: options?.updated ?? Date.now(),
133
+ link: options?.link ?? null,
134
+ locked: options?.locked ?? false,
135
+ };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Approximate text measurement (from upstream text-factory.ts)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const DEFAULT_FONT_SIZE = 16;
143
+ const DEFAULT_FONT_FAMILY = FONT_FAMILIES.Cascadia; // 3 — monospace
144
+ const DEFAULT_LINE_HEIGHT = 1.25;
145
+
146
+ function measureTextApprox(
147
+ text: string,
148
+ fontSize: number,
149
+ ): { width: number; height: number } {
150
+ const lines = text.split("\n");
151
+ const maxLen = Math.max(...lines.map((l) => l.length));
152
+ // Approximate char width for monospace at given font size
153
+ const charWidth = fontSize * 0.6;
154
+ const lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
155
+ return {
156
+ width: maxLen * charWidth,
157
+ height: lines.length * lineHeight,
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Semantic palette helpers (omegon addition)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Approximate relative luminance of a hex color (sRGB → linear → ITU-R BT.709).
167
+ * Returns 0.0 (black) to 1.0 (white). Used to pick text color for readability.
168
+ */
169
+ function luminance(hex: string): number {
170
+ const h = hex.replace("#", "");
171
+ if (h.length < 6) return 1; // fallback to "light" for invalid/short values
172
+ const r = parseInt(h.slice(0, 2), 16) / 255;
173
+ const g = parseInt(h.slice(2, 4), 16) / 255;
174
+ const b = parseInt(h.slice(4, 6), 16) / 255;
175
+ // sRGB to linear
176
+ const toLinear = (c: number) => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
177
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
178
+ }
179
+
180
+ /**
181
+ * Pick a readable text color for the given background.
182
+ * Transparent backgrounds use the stroke color; filled backgrounds
183
+ * use white or dark gray depending on fill luminance.
184
+ */
185
+ function textColorForBackground(bg: string, stroke?: string): string {
186
+ if (bg === "transparent") {
187
+ return stroke ?? DEFAULT_ELEMENT_STYLE.strokeColor;
188
+ }
189
+ return luminance(bg) < 0.4 ? TEXT_COLORS.onDark : TEXT_COLORS.onLight;
190
+ }
191
+
192
+ /**
193
+ * Extract only ElementBase-compatible properties from semantic colors.
194
+ * Does NOT spread the full options object — prevents leaking non-ElementBase
195
+ * keys (label, semantic, labelFontSize, etc.) into the returned partial.
196
+ */
197
+ function applySemanticColors(
198
+ options: ShapeOptions,
199
+ semantic?: SemanticPurpose,
200
+ ): { backgroundColor?: string; strokeColor?: string } {
201
+ const colors = semantic ? SEMANTIC_COLORS[semantic] : undefined;
202
+ return {
203
+ backgroundColor: options.backgroundColor ?? colors?.fill,
204
+ strokeColor: options.strokeColor ?? colors?.stroke,
205
+ };
206
+ }
207
+
208
+ // ===================================================================
209
+ // PUBLIC API — Shape Factories
210
+ // ===================================================================
211
+
212
+ export interface ShapeOptions {
213
+ id?: string;
214
+ semantic?: SemanticPurpose;
215
+ label?: string;
216
+ labelFontSize?: number;
217
+ strokeColor?: string;
218
+ backgroundColor?: string;
219
+ fillStyle?: FillStyle;
220
+ strokeWidth?: number;
221
+ strokeStyle?: StrokeStyle;
222
+ roughness?: number;
223
+ groupIds?: string[];
224
+ }
225
+
226
+ /**
227
+ * Create a rectangle element, optionally with centered label text.
228
+ * Returns [rectangle] or [rectangle, textElement] if label is provided.
229
+ */
230
+ export function rect(
231
+ x: number, y: number, w: number, h: number,
232
+ opts: ShapeOptions = {},
233
+ ): ExcalidrawElement[] {
234
+ const colors = applySemanticColors(opts, opts.semantic);
235
+ const id = opts.id || newId();
236
+ const boundElements: BoundElement[] = [];
237
+
238
+ const elements: ExcalidrawElement[] = [];
239
+
240
+ if (opts.label) {
241
+ const textId = newId();
242
+ boundElements.push({ id: textId, type: "text" });
243
+
244
+ const fontSize = opts.labelFontSize ?? DEFAULT_FONT_SIZE;
245
+ const dims = measureTextApprox(opts.label, fontSize);
246
+
247
+ elements.push({
248
+ ...createBaseElement("text", x + (w - dims.width) / 2, y + (h - dims.height) / 2, dims.width, dims.height, {
249
+ id: textId,
250
+ strokeColor: textColorForBackground(
251
+ colors.backgroundColor ?? DEFAULT_ELEMENT_STYLE.backgroundColor,
252
+ colors.strokeColor,
253
+ ),
254
+ }),
255
+ type: "text",
256
+ text: opts.label,
257
+ fontSize,
258
+ fontFamily: DEFAULT_FONT_FAMILY,
259
+ textAlign: "center" as TextAlign,
260
+ verticalAlign: "middle" as VerticalAlign,
261
+ containerId: id,
262
+ originalText: opts.label,
263
+ autoResize: true,
264
+ lineHeight: DEFAULT_LINE_HEIGHT,
265
+ } as TextElement);
266
+ }
267
+
268
+ // Rectangle goes first (container before content for binding)
269
+ elements.unshift({
270
+ ...createBaseElement("rectangle", x, y, w, h, {
271
+ id,
272
+ roundness: { type: 3 },
273
+ boundElements: boundElements.length > 0 ? boundElements : null,
274
+ fillStyle: opts.fillStyle,
275
+ strokeWidth: opts.strokeWidth,
276
+ strokeStyle: opts.strokeStyle,
277
+ roughness: opts.roughness,
278
+ groupIds: opts.groupIds,
279
+ ...colors,
280
+ }),
281
+ type: "rectangle",
282
+ } as RectangleElement);
283
+
284
+ return elements;
285
+ }
286
+
287
+ /**
288
+ * Create a diamond element, optionally with centered label text.
289
+ */
290
+ export function diamond(
291
+ x: number, y: number, w: number, h: number,
292
+ opts: ShapeOptions = {},
293
+ ): ExcalidrawElement[] {
294
+ const colors = applySemanticColors(opts, opts.semantic);
295
+ const id = opts.id || newId();
296
+ const boundElements: BoundElement[] = [];
297
+ const elements: ExcalidrawElement[] = [];
298
+
299
+ if (opts.label) {
300
+ const textId = newId();
301
+ boundElements.push({ id: textId, type: "text" });
302
+ const fontSize = opts.labelFontSize ?? DEFAULT_FONT_SIZE;
303
+ const dims = measureTextApprox(opts.label, fontSize);
304
+ elements.push({
305
+ ...createBaseElement("text", x + (w - dims.width) / 2, y + (h - dims.height) / 2, dims.width, dims.height, {
306
+ id: textId,
307
+ strokeColor: textColorForBackground(
308
+ colors.backgroundColor ?? DEFAULT_ELEMENT_STYLE.backgroundColor,
309
+ colors.strokeColor,
310
+ ),
311
+ }),
312
+ type: "text",
313
+ text: opts.label, fontSize, fontFamily: DEFAULT_FONT_FAMILY,
314
+ textAlign: "center" as TextAlign, verticalAlign: "middle" as VerticalAlign,
315
+ containerId: id, originalText: opts.label, autoResize: true, lineHeight: DEFAULT_LINE_HEIGHT,
316
+ } as TextElement);
317
+ }
318
+
319
+ elements.unshift({
320
+ ...createBaseElement("diamond", x, y, w, h, {
321
+ id,
322
+ roundness: { type: 2 },
323
+ boundElements: boundElements.length > 0 ? boundElements : null,
324
+ fillStyle: opts.fillStyle,
325
+ strokeWidth: opts.strokeWidth,
326
+ strokeStyle: opts.strokeStyle,
327
+ roughness: opts.roughness,
328
+ groupIds: opts.groupIds,
329
+ ...colors,
330
+ }),
331
+ type: "diamond",
332
+ } as DiamondElement);
333
+
334
+ return elements;
335
+ }
336
+
337
+ /**
338
+ * Create an ellipse element, optionally with centered label text.
339
+ */
340
+ export function ellipse(
341
+ x: number, y: number, w: number, h: number,
342
+ opts: ShapeOptions = {},
343
+ ): ExcalidrawElement[] {
344
+ const colors = applySemanticColors(opts, opts.semantic);
345
+ const id = opts.id || newId();
346
+ const boundElements: BoundElement[] = [];
347
+ const elements: ExcalidrawElement[] = [];
348
+
349
+ if (opts.label) {
350
+ const textId = newId();
351
+ boundElements.push({ id: textId, type: "text" });
352
+ const fontSize = opts.labelFontSize ?? DEFAULT_FONT_SIZE;
353
+ const dims = measureTextApprox(opts.label, fontSize);
354
+ elements.push({
355
+ ...createBaseElement("text", x + (w - dims.width) / 2, y + (h - dims.height) / 2, dims.width, dims.height, {
356
+ id: textId,
357
+ strokeColor: textColorForBackground(
358
+ colors.backgroundColor ?? DEFAULT_ELEMENT_STYLE.backgroundColor,
359
+ colors.strokeColor,
360
+ ),
361
+ }),
362
+ type: "text",
363
+ text: opts.label, fontSize, fontFamily: DEFAULT_FONT_FAMILY,
364
+ textAlign: "center" as TextAlign, verticalAlign: "middle" as VerticalAlign,
365
+ containerId: id, originalText: opts.label, autoResize: true, lineHeight: DEFAULT_LINE_HEIGHT,
366
+ } as TextElement);
367
+ }
368
+
369
+ elements.unshift({
370
+ ...createBaseElement("ellipse", x, y, w, h, {
371
+ id,
372
+ roundness: null,
373
+ boundElements: boundElements.length > 0 ? boundElements : null,
374
+ fillStyle: opts.fillStyle,
375
+ strokeWidth: opts.strokeWidth,
376
+ strokeStyle: opts.strokeStyle,
377
+ roughness: opts.roughness,
378
+ groupIds: opts.groupIds,
379
+ ...colors,
380
+ }),
381
+ type: "ellipse",
382
+ } as EllipseElement);
383
+
384
+ return elements;
385
+ }
386
+
387
+ /**
388
+ * Create a small marker dot (10-12px filled circle).
389
+ * Returns `[EllipseElement]` for consistency with other shape factories.
390
+ */
391
+ export function dot(
392
+ x: number, y: number,
393
+ opts: { id?: string; semantic?: SemanticPurpose; size?: number } = {},
394
+ ): ExcalidrawElement[] {
395
+ const size = opts.size ?? 12;
396
+ const colors = opts.semantic ? SEMANTIC_COLORS[opts.semantic] : SEMANTIC_COLORS.primary;
397
+ return [{
398
+ ...createBaseElement("ellipse", x - size / 2, y - size / 2, size, size, {
399
+ id: opts.id,
400
+ strokeColor: colors.stroke,
401
+ backgroundColor: colors.fill,
402
+ strokeWidth: 1,
403
+ }),
404
+ type: "ellipse",
405
+ } as EllipseElement];
406
+ }
407
+
408
+ // ===================================================================
409
+ // PUBLIC API — Text
410
+ // ===================================================================
411
+
412
+ export interface TextOptions {
413
+ id?: string;
414
+ level?: TextLevel;
415
+ fontSize?: number;
416
+ fontFamily?: number;
417
+ textAlign?: TextAlign;
418
+ verticalAlign?: VerticalAlign;
419
+ strokeColor?: string;
420
+ }
421
+
422
+ /**
423
+ * Create a free-floating text element (not bound to any container).
424
+ */
425
+ export function text(
426
+ x: number, y: number,
427
+ content: string,
428
+ opts: TextOptions = {},
429
+ ): TextElement {
430
+ const fontSize = opts.fontSize ?? (
431
+ opts.level === "title" ? 28 :
432
+ opts.level === "subtitle" ? 20 : DEFAULT_FONT_SIZE
433
+ );
434
+ const dims = measureTextApprox(content, fontSize);
435
+ const color = opts.strokeColor ?? (opts.level ? TEXT_COLORS[opts.level] : TEXT_COLORS.body);
436
+
437
+ return {
438
+ ...createBaseElement("text", x, y, dims.width, dims.height, {
439
+ id: opts.id,
440
+ strokeColor: color,
441
+ }),
442
+ type: "text",
443
+ text: content,
444
+ fontSize,
445
+ fontFamily: opts.fontFamily ?? DEFAULT_FONT_FAMILY,
446
+ textAlign: opts.textAlign ?? "left",
447
+ verticalAlign: opts.verticalAlign ?? "top",
448
+ containerId: null,
449
+ originalText: content,
450
+ autoResize: true,
451
+ lineHeight: DEFAULT_LINE_HEIGHT,
452
+ } as TextElement;
453
+ }
454
+
455
+ // ===================================================================
456
+ // PUBLIC API — Connectors
457
+ // ===================================================================
458
+
459
+ export interface ArrowOptions {
460
+ id?: string;
461
+ semantic?: SemanticPurpose;
462
+ strokeColor?: string;
463
+ strokeWidth?: number;
464
+ strokeStyle?: StrokeStyle;
465
+ startArrowhead?: Arrowhead;
466
+ endArrowhead?: Arrowhead;
467
+ label?: string;
468
+ /** Intermediate waypoints relative to start (x,y). Start [0,0] and end are auto-added. */
469
+ waypoints?: Array<[number, number]>;
470
+ }
471
+
472
+ /**
473
+ * Create an arrow connecting two points.
474
+ * If `fromId`/`toId` are provided, creates bindings to those elements.
475
+ */
476
+ export function arrow(
477
+ x1: number, y1: number, x2: number, y2: number,
478
+ opts: ArrowOptions & { fromId?: string; toId?: string } = {},
479
+ ): ExcalidrawElement[] {
480
+ const id = opts.id || newId();
481
+ const dx = x2 - x1;
482
+ const dy = y2 - y1;
483
+ const points: Array<[number, number]> = opts.waypoints
484
+ ? [[0, 0], ...opts.waypoints, [dx, dy]]
485
+ : [[0, 0], [dx, dy]];
486
+
487
+ const colors = opts.semantic ? SEMANTIC_COLORS[opts.semantic] : undefined;
488
+ const strokeColor = opts.strokeColor ?? colors?.stroke ?? DEFAULT_ELEMENT_STYLE.strokeColor;
489
+
490
+ const boundElements: BoundElement[] = [];
491
+ const elements: ExcalidrawElement[] = [];
492
+
493
+ // Arrow label
494
+ if (opts.label) {
495
+ const textId = newId();
496
+ boundElements.push({ id: textId, type: "text" });
497
+ const fontSize = DEFAULT_FONT_SIZE;
498
+ const dims = measureTextApprox(opts.label, fontSize);
499
+ const midX = x1 + dx / 2 - dims.width / 2;
500
+ const midY = y1 + dy / 2 - dims.height / 2;
501
+ elements.push({
502
+ ...createBaseElement("text", midX, midY, dims.width, dims.height, {
503
+ id: textId,
504
+ strokeColor: TEXT_COLORS.body,
505
+ }),
506
+ type: "text",
507
+ text: opts.label, fontSize, fontFamily: DEFAULT_FONT_FAMILY,
508
+ textAlign: "center" as TextAlign, verticalAlign: "middle" as VerticalAlign,
509
+ containerId: id, originalText: opts.label, autoResize: true, lineHeight: DEFAULT_LINE_HEIGHT,
510
+ } as TextElement);
511
+ }
512
+
513
+ let minX = 0, maxX = 0, minY = 0, maxY = 0;
514
+ for (const [px, py] of points) {
515
+ minX = Math.min(minX, px);
516
+ maxX = Math.max(maxX, px);
517
+ minY = Math.min(minY, py);
518
+ maxY = Math.max(maxY, py);
519
+ }
520
+
521
+ const startBinding: ArrowBinding | null = opts.fromId
522
+ ? { elementId: opts.fromId, mode: "orbit", fixedPoint: [0.5, 0.5] }
523
+ : null;
524
+ const endBinding: ArrowBinding | null = opts.toId
525
+ ? { elementId: opts.toId, mode: "orbit", fixedPoint: [0.5, 0.5] }
526
+ : null;
527
+
528
+ const arrowEl: ArrowElement = {
529
+ ...createBaseElement("arrow", x1, y1, maxX - minX, maxY - minY, {
530
+ id,
531
+ strokeColor,
532
+ strokeWidth: opts.strokeWidth,
533
+ strokeStyle: opts.strokeStyle,
534
+ roundness: { type: 2 },
535
+ boundElements: boundElements.length > 0 ? boundElements : null,
536
+ }),
537
+ type: "arrow",
538
+ points,
539
+ lastCommittedPoint: null,
540
+ startBinding,
541
+ endBinding,
542
+ startArrowhead: opts.startArrowhead ?? null,
543
+ endArrowhead: opts.endArrowhead ?? "arrow",
544
+ elbowed: false,
545
+ } as ArrowElement;
546
+
547
+ elements.unshift(arrowEl);
548
+ return elements;
549
+ }
550
+
551
+ /**
552
+ * Create a structural line (not an arrow — no arrowheads).
553
+ */
554
+ export function line(
555
+ points: Array<[number, number]>,
556
+ opts: {
557
+ id?: string;
558
+ strokeColor?: string;
559
+ strokeWidth?: number;
560
+ strokeStyle?: StrokeStyle;
561
+ } = {},
562
+ ): LineElement {
563
+ if (points.length < 2) throw new Error("Line requires at least 2 points");
564
+ const [x, y] = points[0];
565
+ const relativePoints = points.map(([px, py]) => [px - x, py - y] as [number, number]);
566
+
567
+ let minX = 0, maxX = 0, minY = 0, maxY = 0;
568
+ for (const [px, py] of relativePoints) {
569
+ minX = Math.min(minX, px);
570
+ maxX = Math.max(maxX, px);
571
+ minY = Math.min(minY, py);
572
+ maxY = Math.max(maxY, py);
573
+ }
574
+
575
+ return {
576
+ ...createBaseElement("line", x, y, maxX - minX, maxY - minY, {
577
+ id: opts.id,
578
+ strokeColor: opts.strokeColor ?? "#64748b",
579
+ strokeWidth: opts.strokeWidth ?? 2,
580
+ strokeStyle: opts.strokeStyle,
581
+ }),
582
+ type: "line",
583
+ points: relativePoints,
584
+ lastCommittedPoint: null,
585
+ startBinding: null,
586
+ endBinding: null,
587
+ startArrowhead: null,
588
+ endArrowhead: null,
589
+ } as LineElement;
590
+ }
591
+
592
+ // ===================================================================
593
+ // PUBLIC API — Binding Wiring
594
+ // ===================================================================
595
+
596
+ /**
597
+ * Wire an arrow to source/target elements, updating boundElements on both ends.
598
+ * Mutates the elements array in-place.
599
+ */
600
+ export function bindArrow(
601
+ elements: ExcalidrawElement[],
602
+ arrowId: string,
603
+ startId: string,
604
+ endId: string,
605
+ ): void {
606
+ const arrowEl = elements.find((e) => e.id === arrowId) as ArrowElement | undefined;
607
+ const startEl = elements.find((e) => e.id === startId);
608
+ const endEl = elements.find((e) => e.id === endId);
609
+
610
+ if (!arrowEl || arrowEl.type !== "arrow") throw new Error(`Arrow '${arrowId}' not found`);
611
+ if (!startEl) throw new Error(`Start element '${startId}' not found`);
612
+ if (!endEl) throw new Error(`End element '${endId}' not found`);
613
+
614
+ // Set bindings on arrow
615
+ arrowEl.startBinding = { elementId: startId, mode: "orbit", fixedPoint: [0.5, 0.5] };
616
+ arrowEl.endBinding = { elementId: endId, mode: "orbit", fixedPoint: [0.5, 0.5] };
617
+
618
+ // Add arrow to both elements' boundElements
619
+ const binding: BoundElement = { id: arrowId, type: "arrow" };
620
+
621
+ if (!startEl.boundElements) (startEl as any).boundElements = [];
622
+ if (!(startEl.boundElements as BoundElement[]).some((b) => b.id === arrowId)) {
623
+ (startEl.boundElements as BoundElement[]).push(binding);
624
+ }
625
+
626
+ if (!endEl.boundElements) (endEl as any).boundElements = [];
627
+ if (!(endEl.boundElements as BoundElement[]).some((b) => b.id === arrowId)) {
628
+ (endEl.boundElements as BoundElement[]).push(binding);
629
+ }
630
+ }
631
+
632
+ // ===================================================================
633
+ // PUBLIC API — Document Builder
634
+ // ===================================================================
635
+
636
+ /**
637
+ * Wrap elements in a complete .excalidraw document.
638
+ * Resets the index counter so the next document starts fresh.
639
+ */
640
+ export function createDocument(
641
+ elements: ExcalidrawElement[],
642
+ opts: { background?: string } = {},
643
+ ): ExcalidrawFile {
644
+ resetIndexCounter();
645
+ return {
646
+ type: "excalidraw",
647
+ version: 2,
648
+ source: "https://excalidraw.com",
649
+ elements,
650
+ appState: {
651
+ ...DEFAULT_APP_STATE,
652
+ viewBackgroundColor: opts.background ?? DEFAULT_APP_STATE.viewBackgroundColor,
653
+ },
654
+ files: {},
655
+ };
656
+ }
657
+
658
+ // ===================================================================
659
+ // PUBLIC API — Validation
660
+ // ===================================================================
661
+
662
+ /**
663
+ * Validate an ExcalidrawFile, returning a list of errors (empty = valid).
664
+ */
665
+ export function validateDocument(doc: ExcalidrawFile): string[] {
666
+ const errors: string[] = [];
667
+
668
+ if (doc.type !== "excalidraw") errors.push(`Expected type 'excalidraw', got '${doc.type}'`);
669
+ if (doc.version !== 2) errors.push(`Expected version 2, got ${doc.version}`);
670
+ if (!Array.isArray(doc.elements)) errors.push("'elements' must be an array");
671
+ if (doc.elements.length === 0) errors.push("'elements' array is empty");
672
+
673
+ const ids = new Set<string>();
674
+ for (const el of doc.elements) {
675
+ if (!el.id) errors.push(`Element missing 'id'`);
676
+ if (ids.has(el.id)) errors.push(`Duplicate element id: '${el.id}'`);
677
+ ids.add(el.id);
678
+ }
679
+
680
+ // Check binding references
681
+ for (const el of doc.elements) {
682
+ if (el.type === "arrow") {
683
+ const a = el as ArrowElement;
684
+ if (a.startBinding && !ids.has(a.startBinding.elementId)) {
685
+ errors.push(`Arrow '${el.id}' startBinding references missing element '${a.startBinding.elementId}'`);
686
+ }
687
+ if (a.endBinding && !ids.has(a.endBinding.elementId)) {
688
+ errors.push(`Arrow '${el.id}' endBinding references missing element '${a.endBinding.elementId}'`);
689
+ }
690
+ }
691
+ if (el.type === "text") {
692
+ const t = el as TextElement;
693
+ if (t.containerId && !ids.has(t.containerId)) {
694
+ errors.push(`Text '${el.id}' containerId references missing element '${t.containerId}'`);
695
+ }
696
+ }
697
+ if (el.boundElements) {
698
+ for (const b of el.boundElements) {
699
+ if (!ids.has(b.id)) {
700
+ errors.push(`Element '${el.id}' boundElements references missing element '${b.id}'`);
701
+ }
702
+ }
703
+ }
704
+ }
705
+
706
+ return errors;
707
+ }
708
+
709
+ // ===================================================================
710
+ // PUBLIC API — Layout Helpers
711
+ // ===================================================================
712
+
713
+ export type Point = [number, number];
714
+
715
+ /**
716
+ * Generate points for a fan-out pattern (one center → many targets).
717
+ * Returns target positions arranged in an arc.
718
+ */
719
+ export function fanOut(
720
+ center: Point, count: number, radius: number,
721
+ opts: { arc?: number; startAngle?: number } = {},
722
+ ): Point[] {
723
+ const arc = opts.arc ?? Math.PI; // 180° default
724
+ const startAngle = opts.startAngle ?? -arc / 2;
725
+ const step = count > 1 ? arc / (count - 1) : 0;
726
+
727
+ return Array.from({ length: count }, (_, i) => {
728
+ const angle = startAngle + step * i;
729
+ return [
730
+ center[0] + Math.cos(angle) * radius,
731
+ center[1] + Math.sin(angle) * radius,
732
+ ] as Point;
733
+ });
734
+ }
735
+
736
+ /**
737
+ * Generate evenly-spaced points along a line (for timelines).
738
+ */
739
+ export function timeline(
740
+ start: Point, count: number, spacing: number,
741
+ direction: "horizontal" | "vertical" = "vertical",
742
+ ): Point[] {
743
+ return Array.from({ length: count }, (_, i) =>
744
+ direction === "vertical"
745
+ ? [start[0], start[1] + i * spacing] as Point
746
+ : [start[0] + i * spacing, start[1]] as Point,
747
+ );
748
+ }
749
+
750
+ /**
751
+ * Generate grid positions (row-major order).
752
+ */
753
+ export function grid(
754
+ origin: Point, cols: number, rows: number,
755
+ cellW: number, cellH: number,
756
+ ): Point[] {
757
+ const points: Point[] = [];
758
+ for (let r = 0; r < rows; r++) {
759
+ for (let c = 0; c < cols; c++) {
760
+ points.push([origin[0] + c * cellW, origin[1] + r * cellH]);
761
+ }
762
+ }
763
+ return points;
764
+ }