@1agh/maude 0.24.0 → 0.26.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 (84) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dependencies.json +30 -2
  7. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  8. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  9. package/plugins/design/dev-server/api.ts +15 -1
  10. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  12. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  13. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  14. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  15. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  16. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  17. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  18. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  19. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  20. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  21. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  22. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  23. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  24. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  25. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  26. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  27. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  28. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  29. package/plugins/design/dev-server/client/app.jsx +1084 -417
  30. package/plugins/design/dev-server/config.schema.json +10 -0
  31. package/plugins/design/dev-server/context.ts +9 -0
  32. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  33. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  34. package/plugins/design/dev-server/draw/brush.ts +639 -0
  35. package/plugins/design/dev-server/draw/composition.ts +229 -0
  36. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  37. package/plugins/design/dev-server/draw/index.ts +28 -0
  38. package/plugins/design/dev-server/draw/layout.ts +260 -0
  39. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  40. package/plugins/design/dev-server/draw/palette.ts +417 -0
  41. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  42. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  43. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  44. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  45. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  46. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  47. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  48. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  49. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  50. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  51. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  52. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  53. package/plugins/design/dev-server/exporters/html.ts +4 -1
  54. package/plugins/design/dev-server/exporters/index.ts +4 -1
  55. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  56. package/plugins/design/dev-server/exporters/png.ts +21 -2
  57. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  58. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  59. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  60. package/plugins/design/dev-server/http.ts +40 -9
  61. package/plugins/design/dev-server/input-router.tsx +9 -2
  62. package/plugins/design/dev-server/sync/index.ts +73 -17
  63. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  64. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  65. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  66. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  67. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  68. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  69. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  70. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  71. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  72. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  73. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  74. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  75. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  76. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  77. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  78. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  79. package/plugins/design/dev-server/tsconfig.json +8 -1
  80. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  81. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  82. package/plugins/design/templates/_shell.html +36 -9
  83. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  84. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
@@ -0,0 +1,643 @@
1
+ /**
2
+ * @file draw/primitives.ts — Phase 25 geometry-engine primitive layer
3
+ * @scope plugins/design/dev-server/draw/primitives.ts
4
+ * @purpose The `DrawPrimitive` union + grid-snapped constructors + transform
5
+ * composition + viewBox presets. This is the "intent" layer the
6
+ * draw-agent emits: the LLM names shapes and where they go; the
7
+ * constructors here produce exact, structured primitives that the
8
+ * serializer (`serialize.ts`) turns into BOTH an SVG string and JSX
9
+ * from the SAME source — so on-disk and on-canvas forms can never
10
+ * drift. This generalizes `canvas-arrowheads.ts`'s `SvgPrimitive`
11
+ * reducer pattern to the full drawing vocabulary.
12
+ *
13
+ * Back-compat note: this is a superset of the arrow `SvgPrimitive`
14
+ * (line / path / polyline / polygon / circle) extended with rect,
15
+ * ellipse, text, group, use, symbol + a shared `DrawStyle`.
16
+ *
17
+ * DEPENDENCY RULE (DDR-067): this module imports NOTHING from react
18
+ * or any `.tsx`. JSX rendering lives ONLY in `serialize.ts#toJsx`,
19
+ * which produces a JSX *string*, never a React element. A `.ts`
20
+ * root in a re-export cycle with a react `.tsx` breaks
21
+ * @types/react's global `JSX` namespace under this tsconfig's
22
+ * `types: ["bun-types"]` — keep the dependency one-way.
23
+ */
24
+
25
+ /** A 2-D point in viewBox user units. */
26
+ export interface Point {
27
+ x: number;
28
+ y: number;
29
+ }
30
+
31
+ /**
32
+ * Presentation attributes shared by every drawable primitive. Geometry lives on
33
+ * the primitive itself; everything paint-related lives here so a single style
34
+ * shape feeds every element kind. Names are canonical (camelCase); the
35
+ * serializer maps them to `stroke-width` (SVG) vs `strokeWidth` (JSX).
36
+ *
37
+ * `dash` accepts `true` (a sensible default dash pattern), a number array
38
+ * (explicit `stroke-dasharray`), or is omitted for a solid stroke — mirroring
39
+ * the arrow `dash: boolean` field while allowing richer patterns.
40
+ */
41
+ export interface DrawStyle {
42
+ /** Fill paint. Omit to let the serializer default to `currentColor`. */
43
+ fill?: string;
44
+ /** `'none'` for an unfilled outline shape. */
45
+ stroke?: string;
46
+ strokeWidth?: number;
47
+ strokeLinecap?: 'butt' | 'round' | 'square';
48
+ strokeLinejoin?: 'miter' | 'round' | 'bevel';
49
+ /** Element-wide opacity (0–1). */
50
+ opacity?: number;
51
+ fillOpacity?: number;
52
+ strokeOpacity?: number;
53
+ /** Solid (omitted/false), default dashes (true), or explicit pattern. */
54
+ dash?: boolean | number[];
55
+ /** Optional id (for `<use href>` targets, testing hooks, a11y). */
56
+ id?: string;
57
+ /** `url(#id)` of a {@link filter} (blur / shadow / grain / glow). */
58
+ filter?: string;
59
+ /** `url(#id)` of a {@link mask} (vignette / scrim / fade overlay). */
60
+ mask?: string;
61
+ /** `url(#id)` of a {@link clipPath}. */
62
+ clipPath?: string;
63
+ /** CSS compositing — `'multiply' | 'screen' | 'overlay' | 'soft-light' | …`. */
64
+ mixBlendMode?: string;
65
+ }
66
+
67
+ /** `gradientUnits` mode. `objectBoundingBox` (default) uses 0–1 coords. */
68
+ export type GradientUnits = 'objectBoundingBox' | 'userSpaceOnUse';
69
+
70
+ /** One color stop of a gradient. `offset` 0–1; optional per-stop opacity. */
71
+ export interface GradientStop {
72
+ offset: number;
73
+ color: string;
74
+ opacity?: number;
75
+ }
76
+
77
+ /**
78
+ * A single SVG filter-primitive node (`feGaussianBlur`, `feTurbulence`,
79
+ * `feDropShadow`, `feColorMatrix`, `feMerge` + `feMergeNode`, …). Open-ended by
80
+ * design — the filter sub-language has dozens of elements, so this is a generic
81
+ * `{ tag, attrs, children }` rather than an enumerated union. Attr keys are
82
+ * camelCase (`stdDeviation`, `baseFrequency`, `floodColor`); the serializer
83
+ * maps the few kebab ones (`flood-color`) per dialect.
84
+ */
85
+ export interface FePrimitive {
86
+ fe: string;
87
+ attrs?: Record<string, string | number>;
88
+ children?: FePrimitive[];
89
+ }
90
+
91
+ /** Text-specific attributes layered on top of {@link DrawStyle}. */
92
+ export interface TextAttrs {
93
+ fontSize?: number;
94
+ fontFamily?: string;
95
+ fontWeight?: number | string;
96
+ textAnchor?: 'start' | 'middle' | 'end';
97
+ dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-after-edge';
98
+ letterSpacing?: number;
99
+ }
100
+
101
+ /**
102
+ * The full drawing vocabulary. Every variant carries `el` as its discriminant
103
+ * (matching the `SvgPrimitive` convention) plus geometry, plus an inlined
104
+ * {@link DrawStyle}. Containers (`group`, `defs`, `symbol`) nest children.
105
+ */
106
+ export type DrawPrimitive =
107
+ | ({
108
+ el: 'rect';
109
+ x: number;
110
+ y: number;
111
+ width: number;
112
+ height: number;
113
+ rx?: number;
114
+ ry?: number;
115
+ } & DrawStyle)
116
+ | ({ el: 'circle'; cx: number; cy: number; r: number } & DrawStyle)
117
+ | ({ el: 'ellipse'; cx: number; cy: number; rx: number; ry: number } & DrawStyle)
118
+ | ({ el: 'line'; x1: number; y1: number; x2: number; y2: number } & DrawStyle)
119
+ | ({ el: 'polyline'; points: Point[] } & DrawStyle)
120
+ | ({ el: 'polygon'; points: Point[] } & DrawStyle)
121
+ | ({ el: 'path'; d: string } & DrawStyle)
122
+ | ({ el: 'text'; x: number; y: number; content: string } & TextAttrs & DrawStyle)
123
+ | ({ el: 'group'; children: DrawPrimitive[]; transform?: string } & Pick<
124
+ DrawStyle,
125
+ 'opacity' | 'id' | 'filter' | 'mask' | 'clipPath' | 'mixBlendMode'
126
+ >)
127
+ | { el: 'defs'; children: DrawPrimitive[] }
128
+ | { el: 'symbol'; id: string; children: DrawPrimitive[]; viewBox?: string }
129
+ | ({
130
+ el: 'use';
131
+ href: string;
132
+ x?: number;
133
+ y?: number;
134
+ width?: number;
135
+ height?: number;
136
+ transform?: string;
137
+ } & DrawStyle)
138
+ | {
139
+ el: 'linearGradient';
140
+ id: string;
141
+ stops: GradientStop[];
142
+ x1?: number;
143
+ y1?: number;
144
+ x2?: number;
145
+ y2?: number;
146
+ gradientUnits?: GradientUnits;
147
+ }
148
+ | {
149
+ el: 'radialGradient';
150
+ id: string;
151
+ stops: GradientStop[];
152
+ cx?: number;
153
+ cy?: number;
154
+ r?: number;
155
+ fx?: number;
156
+ fy?: number;
157
+ gradientUnits?: GradientUnits;
158
+ }
159
+ | {
160
+ el: 'filter';
161
+ id: string;
162
+ prims: FePrimitive[];
163
+ x?: string | number;
164
+ y?: string | number;
165
+ width?: string | number;
166
+ height?: string | number;
167
+ colorInterpolationFilters?: 'sRGB' | 'linearRGB';
168
+ }
169
+ | {
170
+ el: 'pattern';
171
+ id: string;
172
+ width: number;
173
+ height: number;
174
+ children: DrawPrimitive[];
175
+ patternUnits?: GradientUnits;
176
+ patternTransform?: string;
177
+ }
178
+ | { el: 'mask'; id: string; children: DrawPrimitive[] }
179
+ | { el: 'clipPath'; id: string; children: DrawPrimitive[] };
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+ // Grid snapping
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Snap a coordinate to a grid. `grid <= 0` (the default) is a no-op so optical
187
+ * adjustments survive; pass `grid: 1` for pixel-snapping, `4`/`8` for the
188
+ * spacing scale. Rounds half-up, deterministic (no `Math.random`).
189
+ */
190
+ export function snap(value: number, grid = 0): number {
191
+ if (!grid || grid <= 0) return value;
192
+ return Math.round(value / grid) * grid;
193
+ }
194
+
195
+ function snapPoint(p: Point, grid: number): Point {
196
+ return { x: snap(p.x, grid), y: snap(p.y, grid) };
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // Constructors — each accepts geometry + style + an optional `grid` to snap.
201
+ // The `grid` field is stripped from the emitted primitive (it's a build-time
202
+ // instruction, not an SVG attribute).
203
+ // ─────────────────────────────────────────────────────────────────────────────
204
+
205
+ type WithGrid<T> = T & { grid?: number };
206
+
207
+ function styleOf<T extends DrawStyle>(o: T): DrawStyle {
208
+ const s: DrawStyle = {};
209
+ if (o.fill !== undefined) s.fill = o.fill;
210
+ if (o.stroke !== undefined) s.stroke = o.stroke;
211
+ if (o.strokeWidth !== undefined) s.strokeWidth = o.strokeWidth;
212
+ if (o.strokeLinecap !== undefined) s.strokeLinecap = o.strokeLinecap;
213
+ if (o.strokeLinejoin !== undefined) s.strokeLinejoin = o.strokeLinejoin;
214
+ if (o.opacity !== undefined) s.opacity = o.opacity;
215
+ if (o.fillOpacity !== undefined) s.fillOpacity = o.fillOpacity;
216
+ if (o.strokeOpacity !== undefined) s.strokeOpacity = o.strokeOpacity;
217
+ if (o.dash !== undefined) s.dash = o.dash;
218
+ if (o.id !== undefined) s.id = o.id;
219
+ if (o.filter !== undefined) s.filter = o.filter;
220
+ if (o.mask !== undefined) s.mask = o.mask;
221
+ if (o.clipPath !== undefined) s.clipPath = o.clipPath;
222
+ if (o.mixBlendMode !== undefined) s.mixBlendMode = o.mixBlendMode;
223
+ return s;
224
+ }
225
+
226
+ export function rect(
227
+ o: WithGrid<
228
+ { x: number; y: number; width: number; height: number; rx?: number; ry?: number } & DrawStyle
229
+ >
230
+ ): DrawPrimitive {
231
+ const g = o.grid ?? 0;
232
+ const out: DrawPrimitive = {
233
+ el: 'rect',
234
+ x: snap(o.x, g),
235
+ y: snap(o.y, g),
236
+ width: snap(o.width, g),
237
+ height: snap(o.height, g),
238
+ ...styleOf(o),
239
+ };
240
+ if (o.rx !== undefined) (out as { rx?: number }).rx = snap(o.rx, g);
241
+ if (o.ry !== undefined) (out as { ry?: number }).ry = snap(o.ry, g);
242
+ return out;
243
+ }
244
+
245
+ export function circle(
246
+ o: WithGrid<{ cx: number; cy: number; r: number } & DrawStyle>
247
+ ): DrawPrimitive {
248
+ const g = o.grid ?? 0;
249
+ return { el: 'circle', cx: snap(o.cx, g), cy: snap(o.cy, g), r: snap(o.r, g), ...styleOf(o) };
250
+ }
251
+
252
+ export function ellipse(
253
+ o: WithGrid<{ cx: number; cy: number; rx: number; ry: number } & DrawStyle>
254
+ ): DrawPrimitive {
255
+ const g = o.grid ?? 0;
256
+ return {
257
+ el: 'ellipse',
258
+ cx: snap(o.cx, g),
259
+ cy: snap(o.cy, g),
260
+ rx: snap(o.rx, g),
261
+ ry: snap(o.ry, g),
262
+ ...styleOf(o),
263
+ };
264
+ }
265
+
266
+ export function line(
267
+ o: WithGrid<{ x1: number; y1: number; x2: number; y2: number } & DrawStyle>
268
+ ): DrawPrimitive {
269
+ const g = o.grid ?? 0;
270
+ return {
271
+ el: 'line',
272
+ x1: snap(o.x1, g),
273
+ y1: snap(o.y1, g),
274
+ x2: snap(o.x2, g),
275
+ y2: snap(o.y2, g),
276
+ ...styleOf(o),
277
+ };
278
+ }
279
+
280
+ export function polyline(o: WithGrid<{ points: Point[] } & DrawStyle>): DrawPrimitive {
281
+ const g = o.grid ?? 0;
282
+ return { el: 'polyline', points: o.points.map((p) => snapPoint(p, g)), ...styleOf(o) };
283
+ }
284
+
285
+ export function polygon(o: WithGrid<{ points: Point[] } & DrawStyle>): DrawPrimitive {
286
+ const g = o.grid ?? 0;
287
+ return { el: 'polygon', points: o.points.map((p) => snapPoint(p, g)), ...styleOf(o) };
288
+ }
289
+
290
+ export function path(o: { d: string } & DrawStyle): DrawPrimitive {
291
+ return { el: 'path', d: o.d, ...styleOf(o) };
292
+ }
293
+
294
+ export function text(
295
+ o: WithGrid<{ x: number; y: number; content: string } & TextAttrs & DrawStyle>
296
+ ): DrawPrimitive {
297
+ const g = o.grid ?? 0;
298
+ const t: DrawPrimitive = {
299
+ el: 'text',
300
+ x: snap(o.x, g),
301
+ y: snap(o.y, g),
302
+ content: o.content,
303
+ ...styleOf(o),
304
+ };
305
+ const attrs = t as TextAttrs;
306
+ if (o.fontSize !== undefined) attrs.fontSize = o.fontSize;
307
+ if (o.fontFamily !== undefined) attrs.fontFamily = o.fontFamily;
308
+ if (o.fontWeight !== undefined) attrs.fontWeight = o.fontWeight;
309
+ if (o.textAnchor !== undefined) attrs.textAnchor = o.textAnchor;
310
+ if (o.dominantBaseline !== undefined) attrs.dominantBaseline = o.dominantBaseline;
311
+ if (o.letterSpacing !== undefined) attrs.letterSpacing = o.letterSpacing;
312
+ return t;
313
+ }
314
+
315
+ export function group(
316
+ children: DrawPrimitive[],
317
+ o: {
318
+ transform?: string;
319
+ opacity?: number;
320
+ id?: string;
321
+ filter?: string;
322
+ mask?: string;
323
+ clipPath?: string;
324
+ mixBlendMode?: string;
325
+ } = {}
326
+ ): DrawPrimitive {
327
+ const out = { el: 'group' as const, children } as DrawPrimitive & {
328
+ transform?: string;
329
+ opacity?: number;
330
+ id?: string;
331
+ filter?: string;
332
+ mask?: string;
333
+ clipPath?: string;
334
+ mixBlendMode?: string;
335
+ };
336
+ if (o.transform !== undefined) out.transform = o.transform;
337
+ if (o.opacity !== undefined) out.opacity = o.opacity;
338
+ if (o.id !== undefined) out.id = o.id;
339
+ if (o.filter !== undefined) out.filter = o.filter;
340
+ if (o.mask !== undefined) out.mask = o.mask;
341
+ if (o.clipPath !== undefined) out.clipPath = o.clipPath;
342
+ if (o.mixBlendMode !== undefined) out.mixBlendMode = o.mixBlendMode;
343
+ return out;
344
+ }
345
+
346
+ export function defs(children: DrawPrimitive[]): DrawPrimitive {
347
+ return { el: 'defs', children };
348
+ }
349
+
350
+ export function symbol(id: string, children: DrawPrimitive[], viewBox?: string): DrawPrimitive {
351
+ const out: DrawPrimitive = { el: 'symbol', id, children };
352
+ if (viewBox !== undefined) (out as { viewBox?: string }).viewBox = viewBox;
353
+ return out;
354
+ }
355
+
356
+ export function use(
357
+ o: {
358
+ href: string;
359
+ x?: number;
360
+ y?: number;
361
+ width?: number;
362
+ height?: number;
363
+ transform?: string;
364
+ } & DrawStyle
365
+ ): DrawPrimitive {
366
+ const out = { el: 'use' as const, href: o.href, ...styleOf(o) } as DrawPrimitive & {
367
+ x?: number;
368
+ y?: number;
369
+ width?: number;
370
+ height?: number;
371
+ transform?: string;
372
+ };
373
+ if (o.x !== undefined) out.x = o.x;
374
+ if (o.y !== undefined) out.y = o.y;
375
+ if (o.width !== undefined) out.width = o.width;
376
+ if (o.height !== undefined) out.height = o.height;
377
+ if (o.transform !== undefined) out.transform = o.transform;
378
+ return out;
379
+ }
380
+
381
+ /**
382
+ * A linear gradient definition (place inside `defs(...)` and reference with
383
+ * `fill: 'url(#id)'`). Coords default to a top→bottom sweep in objectBoundingBox
384
+ * space — exactly what a sky/backdrop wants.
385
+ */
386
+ export function linearGradient(o: {
387
+ id: string;
388
+ stops: GradientStop[];
389
+ x1?: number;
390
+ y1?: number;
391
+ x2?: number;
392
+ y2?: number;
393
+ gradientUnits?: GradientUnits;
394
+ }): DrawPrimitive {
395
+ const g = { el: 'linearGradient' as const, id: o.id, stops: o.stops };
396
+ const out = g as DrawPrimitive & {
397
+ x1?: number;
398
+ y1?: number;
399
+ x2?: number;
400
+ y2?: number;
401
+ gradientUnits?: GradientUnits;
402
+ };
403
+ if (o.x1 !== undefined) out.x1 = o.x1;
404
+ if (o.y1 !== undefined) out.y1 = o.y1;
405
+ if (o.x2 !== undefined) out.x2 = o.x2;
406
+ if (o.y2 !== undefined) out.y2 = o.y2;
407
+ if (o.gradientUnits !== undefined) out.gradientUnits = o.gradientUnits;
408
+ return out;
409
+ }
410
+
411
+ /** A radial gradient definition (e.g. a sun/glow). Reference with `fill: 'url(#id)'`. */
412
+ export function radialGradient(o: {
413
+ id: string;
414
+ stops: GradientStop[];
415
+ cx?: number;
416
+ cy?: number;
417
+ r?: number;
418
+ fx?: number;
419
+ fy?: number;
420
+ gradientUnits?: GradientUnits;
421
+ }): DrawPrimitive {
422
+ const g = { el: 'radialGradient' as const, id: o.id, stops: o.stops };
423
+ const out = g as DrawPrimitive & {
424
+ cx?: number;
425
+ cy?: number;
426
+ r?: number;
427
+ fx?: number;
428
+ fy?: number;
429
+ gradientUnits?: GradientUnits;
430
+ };
431
+ if (o.cx !== undefined) out.cx = o.cx;
432
+ if (o.cy !== undefined) out.cy = o.cy;
433
+ if (o.r !== undefined) out.r = o.r;
434
+ if (o.fx !== undefined) out.fx = o.fx;
435
+ if (o.fy !== undefined) out.fy = o.fy;
436
+ if (o.gradientUnits !== undefined) out.gradientUnits = o.gradientUnits;
437
+ return out;
438
+ }
439
+
440
+ // ─────────────────────────────────────────────────────────────────────────────
441
+ // Filters / patterns / masks / clips — the rest of the legit design toolkit.
442
+ // Place each inside `defs(...)` and reference it from a style field:
443
+ // filter → `filter: 'url(#id)'` · pattern → `fill: 'url(#id)'`
444
+ // mask → `mask: 'url(#id)'` · clipPath → `clipPath: 'url(#id)'`
445
+ // ─────────────────────────────────────────────────────────────────────────────
446
+
447
+ /** A generic filter primitive — `fe('feGaussianBlur', { stdDeviation: 4 })`. */
448
+ export function fe(
449
+ tag: string,
450
+ attrs?: Record<string, string | number>,
451
+ children?: FePrimitive[]
452
+ ): FePrimitive {
453
+ const out: FePrimitive = { fe: tag };
454
+ if (attrs) out.attrs = attrs;
455
+ if (children) out.children = children;
456
+ return out;
457
+ }
458
+
459
+ /** A `<filter>` def assembled from filter primitives (build them with {@link fe}). */
460
+ export function filter(
461
+ id: string,
462
+ prims: FePrimitive[],
463
+ region?: {
464
+ x?: string | number;
465
+ y?: string | number;
466
+ width?: string | number;
467
+ height?: string | number;
468
+ colorInterpolationFilters?: 'sRGB' | 'linearRGB';
469
+ }
470
+ ): DrawPrimitive {
471
+ const out = { el: 'filter' as const, id, prims } as DrawPrimitive & {
472
+ x?: string | number;
473
+ y?: string | number;
474
+ width?: string | number;
475
+ height?: string | number;
476
+ colorInterpolationFilters?: 'sRGB' | 'linearRGB';
477
+ };
478
+ if (region?.x !== undefined) out.x = region.x;
479
+ if (region?.y !== undefined) out.y = region.y;
480
+ if (region?.width !== undefined) out.width = region.width;
481
+ if (region?.height !== undefined) out.height = region.height;
482
+ if (region?.colorInterpolationFilters !== undefined)
483
+ out.colorInterpolationFilters = region.colorInterpolationFilters;
484
+ return out;
485
+ }
486
+
487
+ /** Convenience: a Gaussian-blur filter. */
488
+ export function blurFilter(id: string, stdDeviation: number): DrawPrimitive {
489
+ return filter(id, [fe('feGaussianBlur', { in: 'SourceGraphic', stdDeviation })]);
490
+ }
491
+
492
+ /** Convenience: a soft drop-shadow filter (a generous filter region by default). */
493
+ export function dropShadowFilter(
494
+ id: string,
495
+ o: { dx?: number; dy?: number; blur?: number; color?: string; opacity?: number } = {}
496
+ ): DrawPrimitive {
497
+ const attrs: Record<string, string | number> = {
498
+ dx: o.dx ?? 0,
499
+ dy: o.dy ?? 2,
500
+ stdDeviation: o.blur ?? 3,
501
+ floodColor: o.color ?? '#000000',
502
+ floodOpacity: o.opacity ?? 0.3,
503
+ };
504
+ return filter(id, [fe('feDropShadow', attrs)], {
505
+ x: '-30%',
506
+ y: '-30%',
507
+ width: '160%',
508
+ height: '160%',
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Convenience: a film-grain / noise overlay filter (feTurbulence → desaturate →
514
+ * fade). Apply to a full-bleed rect with a low opacity + `mix-blend-mode` for a
515
+ * tasteful textured overlay.
516
+ */
517
+ export function grainFilter(
518
+ id: string,
519
+ o: { frequency?: number; octaves?: number; opacity?: number } = {}
520
+ ): DrawPrimitive {
521
+ const freq = o.frequency ?? 0.9;
522
+ const oct = o.octaves ?? 2;
523
+ const op = o.opacity ?? 0.5;
524
+ return filter(id, [
525
+ fe('feTurbulence', {
526
+ type: 'fractalNoise',
527
+ baseFrequency: freq,
528
+ numOctaves: oct,
529
+ stitchTiles: 'stitch',
530
+ result: 'noise',
531
+ }),
532
+ fe('feColorMatrix', { in: 'noise', type: 'saturate', values: 0 }),
533
+ fe('feComponentTransfer', undefined, [fe('feFuncA', { type: 'linear', slope: op })]),
534
+ ]);
535
+ }
536
+
537
+ /** A tiled `<pattern>` def. Build the tile from any primitives (dots, stripes, grid). */
538
+ export function pattern(o: {
539
+ id: string;
540
+ width: number;
541
+ height: number;
542
+ children: DrawPrimitive[];
543
+ patternUnits?: GradientUnits;
544
+ patternTransform?: string;
545
+ }): DrawPrimitive {
546
+ const out = {
547
+ el: 'pattern' as const,
548
+ id: o.id,
549
+ width: o.width,
550
+ height: o.height,
551
+ children: o.children,
552
+ } as DrawPrimitive & { patternUnits?: GradientUnits; patternTransform?: string };
553
+ if (o.patternUnits !== undefined) out.patternUnits = o.patternUnits;
554
+ if (o.patternTransform !== undefined) out.patternTransform = o.patternTransform;
555
+ return out;
556
+ }
557
+
558
+ /** A `<mask>` def (white shows, black hides) — vignettes, edge fades, scrims. */
559
+ export function mask(id: string, children: DrawPrimitive[]): DrawPrimitive {
560
+ return { el: 'mask', id, children };
561
+ }
562
+
563
+ /** A `<clipPath>` def. */
564
+ export function clipPath(id: string, children: DrawPrimitive[]): DrawPrimitive {
565
+ return { el: 'clipPath', id, children };
566
+ }
567
+
568
+ // ─────────────────────────────────────────────────────────────────────────────
569
+ // Transform composition
570
+ // ─────────────────────────────────────────────────────────────────────────────
571
+
572
+ export interface PlaceOpts {
573
+ x?: number;
574
+ y?: number;
575
+ scale?: number;
576
+ /** Degrees, clockwise (SVG convention). */
577
+ rotate?: number;
578
+ /** Rotation pivot (defaults to the local origin). */
579
+ originX?: number;
580
+ originY?: number;
581
+ }
582
+
583
+ /** Round transform numbers to avoid `0.30000000000000004`-style noise. */
584
+ function fmt(n: number): string {
585
+ return Number.isInteger(n) ? String(n) : String(Math.round(n * 1e4) / 1e4);
586
+ }
587
+
588
+ /**
589
+ * Build an SVG `transform` string in the canonical order
590
+ * `translate → rotate → scale`. Each part is emitted only when it's non-identity
591
+ * so trivial placements stay clean. Deterministic; no floating noise.
592
+ */
593
+ export function transformString(o: PlaceOpts): string {
594
+ const parts: string[] = [];
595
+ const x = o.x ?? 0;
596
+ const y = o.y ?? 0;
597
+ if (x !== 0 || y !== 0) parts.push(`translate(${fmt(x)} ${fmt(y)})`);
598
+ if (o.rotate) {
599
+ if (o.originX !== undefined || o.originY !== undefined) {
600
+ parts.push(`rotate(${fmt(o.rotate)} ${fmt(o.originX ?? 0)} ${fmt(o.originY ?? 0)})`);
601
+ } else {
602
+ parts.push(`rotate(${fmt(o.rotate)})`);
603
+ }
604
+ }
605
+ const s = o.scale ?? 1;
606
+ if (s !== 1) parts.push(`scale(${fmt(s)})`);
607
+ return parts.join(' ');
608
+ }
609
+
610
+ /**
611
+ * Compose a sub-drawing by wrapping it in a `<g transform=…>`. The single
612
+ * highest-leverage composition primitive: parts are authored once at the origin
613
+ * and *placed* — no manual coordinate arithmetic, which is exactly the LLM
614
+ * failure mode (coordinate drift) the engine removes.
615
+ */
616
+ export function place(part: DrawPrimitive[], opts: PlaceOpts): DrawPrimitive {
617
+ return group(part, { transform: transformString(opts) });
618
+ }
619
+
620
+ // ─────────────────────────────────────────────────────────────────────────────
621
+ // viewBox presets
622
+ // ─────────────────────────────────────────────────────────────────────────────
623
+
624
+ /** A canonical square viewBox `0 0 n n`. */
625
+ export function squareViewBox(n: number): string {
626
+ return `0 0 ${n} ${n}`;
627
+ }
628
+
629
+ /** An arbitrary `0 0 w h` viewBox. */
630
+ export function boxViewBox(w: number, h: number): string {
631
+ return `0 0 ${w} ${h}`;
632
+ }
633
+
634
+ /**
635
+ * Common viewBox presets. `icon` = the 24-grid Material/Lucide standard;
636
+ * `iconLg` = 48-grid; `logo` = 64-grid lockup; `social` = wide marketing card.
637
+ */
638
+ export const VIEWBOX = {
639
+ icon: squareViewBox(24),
640
+ iconLg: squareViewBox(48),
641
+ logo: squareViewBox(64),
642
+ social: boxViewBox(1200, 630),
643
+ } as const;