@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -12,6 +12,7 @@ import { tmpdir } from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
14
  import JSZip from 'jszip';
15
+ import { CSS_DPI } from '../print/units.ts';
15
16
  import { exportShimPath, runShim } from './_runtime.ts';
16
17
  import {
17
18
  canvasShellUrl,
@@ -27,7 +28,8 @@ import type { Target } from './scope.ts';
27
28
  const PNG_PLAYWRIGHT = exportShimPath('_png-playwright.mjs');
28
29
 
29
30
  interface CaptureOptions {
30
- scale?: 1 | 2 | 3;
31
+ /** Fully-resolved Playwright deviceScaleFactor — see resolveDeviceScale. */
32
+ scale?: number;
31
33
  timeoutSec?: number;
32
34
  }
33
35
 
@@ -42,6 +44,44 @@ export function clampScale(raw: unknown): 1 | 2 | 3 {
42
44
  return 2;
43
45
  }
44
46
 
47
+ /** feature-2-print-artboards T4 — supported DPI presets (Adobe-conventional
48
+ * print ladder). Exported for unit coverage. */
49
+ export const DPI_PRESETS = [96, 150, 300, 600] as const;
50
+ export type DpiPreset = (typeof DPI_PRESETS)[number];
51
+
52
+ /** Coerce an arbitrary `options.dpi` to the nearest supported preset, or
53
+ * `undefined` when absent/invalid (caller then falls back to `scale`). */
54
+ export function clampDpi(raw: unknown): DpiPreset | undefined {
55
+ if (raw === undefined || raw === null) return undefined;
56
+ const n = Number(raw);
57
+ if (!Number.isFinite(n)) return undefined;
58
+ let best: DpiPreset = DPI_PRESETS[0];
59
+ let bestDelta = Math.abs(n - best);
60
+ for (const preset of DPI_PRESETS) {
61
+ const delta = Math.abs(n - preset);
62
+ if (delta < bestDelta) {
63
+ best = preset;
64
+ bestDelta = delta;
65
+ }
66
+ }
67
+ return best;
68
+ }
69
+
70
+ /**
71
+ * The Playwright `deviceScaleFactor` for a capture — `dpi` (physical print
72
+ * resolution) wins when present, else the legacy 1×/2×/3× UI preset.
73
+ * `dpi/96` because authoring is at CSS px @96dpi (print/units.ts CSS_DPI) —
74
+ * this is the ONE place outside that module allowed to divide by 96 (a
75
+ * device-scale-factor derivation, not an mm→px conversion, so it's exempt
76
+ * from the T1 single-source `25.4` lint guard, but stays in lockstep with
77
+ * CSS_DPI by importing it rather than hardcoding 96 again).
78
+ */
79
+ export function resolveDeviceScale(options: ExportOptions): number {
80
+ const dpi = clampDpi(options.dpi);
81
+ if (dpi !== undefined) return dpi / CSS_DPI;
82
+ return clampScale(options.scale);
83
+ }
84
+
45
85
  async function captureElement(
46
86
  target: Extract<Target, { kind: 'element' }>,
47
87
  ctx: ExportContext,
@@ -112,8 +152,10 @@ export async function run(
112
152
  const captureOpts: CaptureOptions = {
113
153
  // Default 2× — a single-scale PNG was uselessly small (item 1). The dialog
114
154
  // sends an explicit scale; this default covers direct API / curl callers.
115
- // Clamped to the 1–3 preset range; the shim re-clamps deviceScaleFactor ≤ 4.
116
- scale: clampScale(options.scale),
155
+ // feature-2-print-artboards T4 — `options.dpi` (96/150/300/600) wins over
156
+ // the legacy 1–3 preset when present; the shim re-clamps deviceScaleFactor
157
+ // ≤ 8 and guards oversized output (see _png-playwright.mjs).
158
+ scale: resolveDeviceScale(options),
117
159
  timeoutSec: (options.timeoutSec as number | undefined) ?? 8,
118
160
  };
119
161
 
@@ -150,6 +150,10 @@ export interface FootageAnalysis {
150
150
  summary?: string;
151
151
  /** Cross-cutting tags ("rebrand", "exterior", "people"). */
152
152
  tags?: string[];
153
+ /** Optional audio/speech note — gist + language of what is said, filled ONLY when
154
+ * an orchestrator (e.g. /design:video-analyze) hands the analyst a transcript.
155
+ * Absent for the default vision-only path (e.g. /design:reel). */
156
+ speech?: string;
153
157
  }
154
158
 
155
159
  /** An optional graphic overlay laid over a beat (title card / lower-third / logo). */
@@ -437,7 +441,18 @@ export function validateFootageAnalysis(input: unknown): ValidationResult {
437
441
  assertKeys(
438
442
  errors,
439
443
  input,
440
- ['version', 'asset', 'durationSec', 'width', 'height', 'keyframes', 'shots', 'summary', 'tags'],
444
+ [
445
+ 'version',
446
+ 'asset',
447
+ 'durationSec',
448
+ 'width',
449
+ 'height',
450
+ 'keyframes',
451
+ 'shots',
452
+ 'summary',
453
+ 'tags',
454
+ 'speech',
455
+ ],
441
456
  'root'
442
457
  );
443
458
  if ('version' in input && input.version != null && typeof input.version !== 'number')
@@ -449,6 +464,7 @@ export function validateFootageAnalysis(input: unknown): ValidationResult {
449
464
  intGe(errors, input, 'keyframes', 0, 'root');
450
465
  str(errors, input, 'summary', 4000, 'root');
451
466
  strArray(errors, input, 'tags', 64, 'root');
467
+ str(errors, input, 'speech', 4000, 'root');
452
468
 
453
469
  const dur = typeof input.durationSec === 'number' ? input.durationSec : Infinity;
454
470
  if ('shots' in input && input.shots != null) {
@@ -0,0 +1,224 @@
1
+ // generation/gemma-models.ts — managed local Gemma-4 MLX models for the `gemma`
2
+ // tier of `maude design smart-frames` (feature-scene-aware-keyframes). The
3
+ // "one-click Gemma scout" story, mirroring whisper-models.ts — but with three
4
+ // real differences from whisper:
5
+ //
6
+ // 1. Apple-Silicon only. The scout runs through mlx-vlm (a Python package); the
7
+ // model is useless without it. So the download is GATED on `mlxVlmAvailable`.
8
+ // 2. Multi-file HF snapshot, not a single .bin. mlx-vlm resolves a model from the
9
+ // standard HuggingFace hub cache, so we download THERE (via huggingface_hub,
10
+ // a transitive dep of mlx-vlm) rather than a Maude-private cache — otherwise
11
+ // mlx-vlm wouldn't find it. "downloaded" = the snapshot is in the HF cache.
12
+ // 3. The RUNTIME (mlx-vlm itself) is a manual `pip install` the app can't do for
13
+ // you — the Settings card says so and only the MODEL half is one click.
14
+ //
15
+ // This module owns the registry + availability probes + the HF-cache-aware
16
+ // downloaded check + resolve. The download SPAWN lives here (it shells the mlx
17
+ // Python's huggingface_hub); the http route wraps it with progress state, next to
18
+ // the other provider egress.
19
+
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { existsSync, readdirSync, statSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ export interface GemmaModelDescriptor {
26
+ /** Stable id used by the pref + route. */
27
+ id: string;
28
+ /** The mlx-community HF repo (also the `--model` mlx-vlm resolves). */
29
+ repo: string;
30
+ /** Pinned commit SHA — the download fetches THIS revision, never floating `main`,
31
+ * so a poisoned-main / namespace-reuse push can't land arbitrary model files
32
+ * (DDR-183 supply-chain finding; the whisper baseline pins exact file URLs). */
33
+ revision: string;
34
+ label: string;
35
+ /** Approximate on-disk size (consent copy). */
36
+ sizeMB: number;
37
+ note: string;
38
+ }
39
+
40
+ // Curated allowlist of Gemma-4 MLX scout models. `repo` + `revision` are the frozen
41
+ // download target (never user input). e4b is the default (better beats); e2b is the
42
+ // small, fast option for lower-RAM Macs. Revisions pinned 2026-07-16.
43
+ export const GEMMA_MODELS: readonly GemmaModelDescriptor[] = [
44
+ {
45
+ id: 'gemma-4-e4b-it-4bit',
46
+ repo: 'mlx-community/gemma-4-e4b-it-4bit',
47
+ revision: '475b9088d29754a3379866cf5aeb6b41acd313c2',
48
+ label: 'Gemma 4 E4B (4-bit)',
49
+ sizeMB: 3300,
50
+ note: 'The recommended scout — best semantic beats. ~3.3 GB. Needs an Apple-Silicon Mac + mlx-vlm.',
51
+ },
52
+ {
53
+ id: 'gemma-4-e2b-it-4bit',
54
+ repo: 'mlx-community/gemma-4-e2b-it-4bit',
55
+ revision: '238767527555cb75a05732a84dff5d6ba0dd6809',
56
+ label: 'Gemma 4 E2B (4-bit)',
57
+ sizeMB: 1900,
58
+ note: 'Smaller/faster, coarser beats. ~1.9 GB. Good for 16 GB Macs.',
59
+ },
60
+ ];
61
+
62
+ export function getGemmaModel(id: unknown): GemmaModelDescriptor | null {
63
+ return GEMMA_MODELS.find((m) => m.id === id) ?? null;
64
+ }
65
+
66
+ /** The HuggingFace hub cache dir mlx-vlm resolves models from (HF_HOME-aware). */
67
+ export function hfHubDir(): string {
68
+ const hfHome = process.env.HF_HOME;
69
+ if (hfHome && hfHome.length > 0) return join(hfHome, 'hub');
70
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
71
+ return join(base, 'huggingface', 'hub');
72
+ }
73
+
74
+ /** HF snapshot dir name for a repo: `models--<org>--<name>`. */
75
+ function repoCacheDir(repo: string): string {
76
+ return join(hfHubDir(), `models--${repo.replace('/', '--')}`);
77
+ }
78
+
79
+ /** A model counts as downloaded when its HF snapshot dir has a non-empty snapshot. */
80
+ export function gemmaModelDownloaded(m: GemmaModelDescriptor): boolean {
81
+ const snaps = join(repoCacheDir(m.repo), 'snapshots');
82
+ try {
83
+ if (!existsSync(snaps)) return false;
84
+ for (const rev of readdirSync(snaps)) {
85
+ const revDir = join(snaps, rev);
86
+ try {
87
+ if (statSync(revDir).isDirectory() && readdirSync(revDir).length > 0) return true;
88
+ } catch {
89
+ /* skip */
90
+ }
91
+ }
92
+ } catch {
93
+ /* not downloaded */
94
+ }
95
+ return false;
96
+ }
97
+
98
+ export interface GemmaModelStatus extends GemmaModelDescriptor {
99
+ downloaded: boolean;
100
+ }
101
+
102
+ export function listGemmaModels(): GemmaModelStatus[] {
103
+ return GEMMA_MODELS.map((m) => ({ ...m, downloaded: gemmaModelDownloaded(m) }));
104
+ }
105
+
106
+ /** Resolve a Python that can `import mlx_vlm` (honors $MAUDE_MLX_PYTHON), or null. */
107
+ export function resolveMlxPython(): string | null {
108
+ const candidates = [process.env.MAUDE_MLX_PYTHON, 'python3', 'python'].filter(
109
+ Boolean
110
+ ) as string[];
111
+ for (const py of candidates) {
112
+ const r = spawnSync(py, ['-c', 'import mlx_vlm'], { stdio: 'ignore' });
113
+ if (r.status === 0) return py;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ // Availability probes spawn subprocesses (importing mlx_vlm can take hundreds of ms
119
+ // to seconds). The GET /_api/generate/keyframe-model route is un-CSRF-gated (correct
120
+ // for a read), so a cross-origin drive-by could hammer it and stall the single-
121
+ // threaded Bun event loop on synchronous spawns (security-auditor DDR-183 finding).
122
+ // Memoize with a short TTL: DoS-bounded to at most one probe per PROBE_TTL_MS
123
+ // regardless of request rate, while still picking up a mid-session `pip install` /
124
+ // PATH change within the TTL.
125
+ const PROBE_TTL_MS = 30_000;
126
+ const probeCache = new Map<string, { at: number; value: boolean }>();
127
+
128
+ function cachedProbe(key: string, compute: () => boolean): boolean {
129
+ const now = Date.now();
130
+ const hit = probeCache.get(key);
131
+ if (hit && now - hit.at < PROBE_TTL_MS) return hit.value;
132
+ const value = compute();
133
+ probeCache.set(key, { at: now, value });
134
+ return value;
135
+ }
136
+
137
+ export function mlxVlmAvailable(): boolean {
138
+ return cachedProbe('mlx', () => resolveMlxPython() !== null);
139
+ }
140
+
141
+ export function ffmpegAvailable(): boolean {
142
+ return cachedProbe('ffmpeg', () => {
143
+ const finder = process.platform === 'win32' ? 'where' : 'which';
144
+ return (
145
+ spawnSync(finder, ['ffmpeg'], { stdio: 'ignore' }).status === 0 &&
146
+ spawnSync(finder, ['ffprobe'], { stdio: 'ignore' }).status === 0
147
+ );
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Download a Gemma model into the HF hub cache via huggingface_hub (a transitive
153
+ * dep of mlx-vlm, so it's present whenever the scout can actually run). We delegate
154
+ * to Python rather than reimplement multi-file HF snapshot download + LFS/Xet
155
+ * redirect handling in TS — the repo id is frozen (never user input), the runtime
156
+ * is pinned to the mlx Python, and the route is loopback + same-origin only.
157
+ * Progress is coarse (tqdm on stderr → a heartbeat), which the route surfaces as an
158
+ * in-flight state. Rejects if mlx-vlm/Python is absent (you couldn't run the model
159
+ * anyway) or the process exits non-zero.
160
+ */
161
+ export function downloadGemmaModel(
162
+ id: string,
163
+ onProgress: (received: number, total: number) => void,
164
+ signal?: AbortSignal
165
+ ): Promise<void> {
166
+ const m = getGemmaModel(id);
167
+ if (!m) return Promise.reject(new Error(`unknown gemma model: ${id}`));
168
+ const py = resolveMlxPython();
169
+ if (!py)
170
+ return Promise.reject(
171
+ new Error(
172
+ 'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.'
173
+ )
174
+ );
175
+
176
+ // Refuse a steered HF endpoint (mirror/redirect) — the whisper path pins the host;
177
+ // huggingface_hub otherwise honors HF_ENDPOINT/HF_HUB_ENDPOINT. Keep the pull on
178
+ // the canonical hub (DDR-183 supply-chain finding).
179
+ const endpoint = process.env.HF_ENDPOINT || process.env.HF_HUB_ENDPOINT;
180
+ if (endpoint && !/^https:\/\/huggingface\.co\/?$/.test(endpoint))
181
+ return Promise.reject(new Error(`refusing model download: HF endpoint steered to ${endpoint}`));
182
+
183
+ const total = m.sizeMB * 1024 * 1024;
184
+ return new Promise((resolve, reject) => {
185
+ // snapshot_download(repo, revision=<pinned sha>) — repo AND revision are from the
186
+ // frozen registry (never user input), passed as argv (not interpolated into -c).
187
+ const child = spawn(
188
+ py,
189
+ [
190
+ '-c',
191
+ 'import sys; from huggingface_hub import snapshot_download; snapshot_download(sys.argv[1], revision=sys.argv[2])',
192
+ m.repo,
193
+ m.revision,
194
+ ],
195
+ {
196
+ stdio: ['ignore', 'ignore', 'pipe'],
197
+ env: { ...process.env, HF_HUB_DISABLE_TELEMETRY: '1' },
198
+ }
199
+ );
200
+ let received = 0;
201
+ const onAbort = () => child.kill('SIGTERM');
202
+ signal?.addEventListener('abort', onAbort, { once: true });
203
+ // tqdm writes percentage lines to stderr; use them as a coarse heartbeat.
204
+ child.stderr?.on('data', (buf: Buffer) => {
205
+ const s = buf.toString();
206
+ const pct = /(\d{1,3})%/.exec(s);
207
+ if (pct) received = Math.min(total, Math.round((Number(pct[1]) / 100) * total));
208
+ onProgress(received, total);
209
+ });
210
+ child.on('error', (err) => {
211
+ signal?.removeEventListener('abort', onAbort);
212
+ reject(err);
213
+ });
214
+ child.on('close', (code) => {
215
+ signal?.removeEventListener('abort', onAbort);
216
+ if (code === 0 && gemmaModelDownloaded(m)) {
217
+ onProgress(total, total);
218
+ resolve();
219
+ } else {
220
+ reject(new Error(`gemma model download failed (exit ${code})`));
221
+ }
222
+ });
223
+ });
224
+ }
@@ -20,6 +20,16 @@ export function isTranscriptionProvider(v: unknown): v is TranscriptionProvider
20
20
  return typeof v === 'string' && (TRANSCRIPTION_PROVIDERS as readonly string[]).includes(v);
21
21
  }
22
22
 
23
+ /** Scene-aware keyframe engines (feature-scene-aware-keyframes) — the tier the
24
+ * `gemma` scout → `ffmpeg` scene-detect → `blind` Chromium fallback ladder runs
25
+ * in. `auto` self-detects installed deps. */
26
+ export const KEYFRAME_ENGINES = ['auto', 'gemma', 'ffmpeg', 'blind'] as const;
27
+ export type KeyframeEngine = (typeof KEYFRAME_ENGINES)[number];
28
+
29
+ export function isKeyframeEngine(v: unknown): v is KeyframeEngine {
30
+ return typeof v === 'string' && (KEYFRAME_ENGINES as readonly string[]).includes(v);
31
+ }
32
+
23
33
  function configPath(repoRoot: string): string {
24
34
  return join(repoRoot, '.design', 'config.json');
25
35
  }
@@ -80,3 +90,36 @@ export async function writeTranscriptionProvider(
80
90
  await Bun.write(configPath(repoRoot), `${JSON.stringify(next, null, 2)}\n`);
81
91
  return true;
82
92
  }
93
+
94
+ /** The current scene-aware keyframe engine preference, or 'auto' (the default). */
95
+ export function readKeyframeEngine(repoRoot: string): KeyframeEngine {
96
+ const cfg = readConfig(repoRoot);
97
+ const gen = cfg.generation as Record<string, unknown> | undefined;
98
+ const kf = gen?.keyframes as Record<string, unknown> | undefined;
99
+ return isKeyframeEngine(kf?.engine) ? (kf?.engine as KeyframeEngine) : 'auto';
100
+ }
101
+
102
+ /**
103
+ * Persist the keyframe-engine choice into `.design/config.json`, preserving every
104
+ * other field (same additive-merge + fail-closed-on-corrupt discipline as the
105
+ * transcription writer).
106
+ */
107
+ export async function writeKeyframeEngine(repoRoot: string, engine: string): Promise<boolean> {
108
+ if (!isKeyframeEngine(engine)) throw new Error(`invalid keyframe engine: ${engine}`);
109
+ const p = configPath(repoRoot);
110
+ if (existsSync(p)) {
111
+ try {
112
+ JSON.parse(readFileSync(p, 'utf8'));
113
+ } catch {
114
+ throw new Error(
115
+ '.design/config.json is present but not valid JSON — fix it before changing generation prefs (refusing to overwrite it)'
116
+ );
117
+ }
118
+ }
119
+ const cfg = readConfig(repoRoot);
120
+ const gen = (cfg.generation as Record<string, unknown>) ?? {};
121
+ const keyframes = (gen.keyframes as Record<string, unknown>) ?? {};
122
+ const next = { ...cfg, generation: { ...gen, keyframes: { ...keyframes, engine } } };
123
+ await Bun.write(configPath(repoRoot), `${JSON.stringify(next, null, 2)}\n`);
124
+ return true;
125
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file grid-track-handles.ts — feature-3-web-artboards T5 (absorbed
3
+ * feature-grid-track-editor stub).
4
+ * @purpose Pure geometry + value math for the on-canvas grid gutter
5
+ * drag-resize overlay. Framework-free (unit-testable without a
6
+ * DOM), mirroring `spacing-handles.ts`'s shape exactly — this is
7
+ * the CSS-Grid sibling of that flex/box-model module, deferred
8
+ * there in a doc comment ("CSS-Grid gap editing is out of v1
9
+ * scope — grid tracks are a separate follow-up plan").
10
+ *
11
+ * Track SIZING math is a two-layer split, same idiom as the
12
+ * padding/gap overlay: geometry (gutter positions) reads the
13
+ * browser's RESOLVED px sizes (the caller reads
14
+ * `getComputedStyle(el).gridTemplateColumns` — the browser
15
+ * already ran the grid-sizing algorithm, resolving `fr`/`auto`/
16
+ * `%` tracks to concrete px, so this module never re-implements
17
+ * that algorithm); DRAG math re-derives a new AUTHORED value
18
+ * (the JSX-authored unit — px/%/fr/em — read from the element's
19
+ * inline style, since these on-canvas tools only edit inline-
20
+ * style-authored values, same constraint as every other curated
21
+ * knob) from the ratio between the target resolved size and the
22
+ * track's CURRENT resolved size.
23
+ */
24
+
25
+ export type GridTrackUnit = 'px' | '%' | 'fr' | 'em' | 'auto' | 'min-content' | 'max-content';
26
+
27
+ export const GRID_KEYWORD_UNITS: ReadonlySet<GridTrackUnit> = new Set([
28
+ 'auto',
29
+ 'min-content',
30
+ 'max-content',
31
+ ]);
32
+
33
+ export interface GridTrack {
34
+ /** Numeric value; 0 (ignored) for keyword-only units. */
35
+ value: number;
36
+ unit: GridTrackUnit;
37
+ }
38
+
39
+ const NUMERIC_TRACK_RE = /^(-?\d+(?:\.\d+)?)(px|%|fr|em)$/;
40
+
41
+ /**
42
+ * Parse a `grid-template-columns`/`grid-template-rows` value into a track
43
+ * list. Handles a plain space-separated list of numeric (px/%/fr/em) and
44
+ * keyword (auto/min-content/max-content) tracks. Returns `[]` for an empty
45
+ * string OR anything it can't parse token-for-token (e.g. `repeat(...)`,
46
+ * `minmax(...)`, `subgrid`) — the caller treats an empty parse as "no
47
+ * drag-editable tracks", falling back to the Inspector's raw-value field
48
+ * rather than guessing at a lossy round-trip.
49
+ */
50
+ export function parseTrackList(raw: string | null | undefined): GridTrack[] {
51
+ const s = (raw ?? '').trim();
52
+ if (!s) return [];
53
+ const parts = s.split(/\s+/);
54
+ const out: GridTrack[] = [];
55
+ for (const part of parts) {
56
+ if (GRID_KEYWORD_UNITS.has(part as GridTrackUnit)) {
57
+ out.push({ value: 0, unit: part as GridTrackUnit });
58
+ continue;
59
+ }
60
+ const m = NUMERIC_TRACK_RE.exec(part);
61
+ if (!m) return [];
62
+ out.push({ value: Number(m[1]), unit: m[2] as GridTrackUnit });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Serialize a track list back to a `grid-template-columns`/`-rows` value —
68
+ * the inverse of `parseTrackList`. `fr` (and every other unit) round-trips
69
+ * byte-for-byte modulo the `round2` value rounding applied during drag. */
70
+ export function serializeTrackList(tracks: GridTrack[]): string {
71
+ return tracks
72
+ .map((t) => (GRID_KEYWORD_UNITS.has(t.unit) ? t.unit : `${round2(t.value)}${t.unit}`))
73
+ .join(' ');
74
+ }
75
+
76
+ export interface ScreenRect {
77
+ x: number;
78
+ y: number;
79
+ w: number;
80
+ h: number;
81
+ }
82
+
83
+ export interface GutterLine {
84
+ /** Index of the track BEFORE this gutter; track `index + 1` is after it. */
85
+ index: number;
86
+ axis: 'x' | 'y';
87
+ x: number;
88
+ y: number;
89
+ }
90
+
91
+ /**
92
+ * Compute gutter handle positions from a grid container's screen rect, each
93
+ * track's RESOLVED size in screen px (already zoom-scaled — same convention
94
+ * as `computePaddingLines`'s `padding` argument), and the resolved gap
95
+ * between tracks. One gutter per adjacent pair, positioned at the gap's
96
+ * midpoint. Fewer than 2 tracks → no gutters.
97
+ */
98
+ export function computeGutterLines(
99
+ rect: ScreenRect,
100
+ resolvedSizesPx: number[],
101
+ gapPx: number,
102
+ axis: 'col' | 'row'
103
+ ): GutterLine[] {
104
+ if (resolvedSizesPx.length < 2) return [];
105
+ const out: GutterLine[] = [];
106
+ let cum = axis === 'col' ? rect.x : rect.y;
107
+ for (let i = 0; i < resolvedSizesPx.length - 1; i++) {
108
+ cum += resolvedSizesPx[i] as number;
109
+ const mid = cum + gapPx / 2;
110
+ if (axis === 'col') {
111
+ out.push({ index: i, axis: 'x', x: mid, y: rect.y + rect.h / 2 });
112
+ } else {
113
+ out.push({ index: i, axis: 'y', x: rect.x + rect.w / 2, y: mid });
114
+ }
115
+ cum += gapPx;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * New AUTHORED value for one track being dragged, given the screen-px delta
122
+ * (world units — the caller already divides by zoom, same convention as
123
+ * `computePaddingDrag`/`computeGapDrag`... actually this one takes the RAW
124
+ * screen delta + zoom together, dividing internally, to match those two
125
+ * functions' own signature exactly) and the track's CURRENT resolved px size.
126
+ *
127
+ * - `px` / `em` — additive: 1 world px of drag = 1 unit (em is treated as a
128
+ * px-equivalent for drag purposes — a precise em→px conversion would need
129
+ * the element's own font-size; this is a disclosed simplification, same
130
+ * spirit as the rest of this drag lane favoring predictable math over
131
+ * pixel-perfect unit fidelity mid-drag).
132
+ * - `fr` / `%` — proportional SHARE units, not absolute sizes: the new value
133
+ * is the OLD value scaled by (targetResolvedPx / currentResolvedPx) — the
134
+ * same "re-derive the ratio" approach a flex-grow-style drag uses. Clamped
135
+ * to a small positive floor so a track never collapses to (or past) zero
136
+ * share.
137
+ * - keyword tracks (auto/min-content/max-content) have no numeric value —
138
+ * returns the track unchanged; the caller must not offer this as a
139
+ * drag target (see `gutterTrackIndices` — the hook filters keyword-only
140
+ * tracks out of the touched set before calling this).
141
+ */
142
+ export function computeTrackDrag(
143
+ track: GridTrack,
144
+ resolvedPx: number,
145
+ dxScreen: number,
146
+ dyScreen: number,
147
+ zoom: number,
148
+ axis: 'x' | 'y'
149
+ ): number {
150
+ if (GRID_KEYWORD_UNITS.has(track.unit)) return track.value;
151
+ const z = zoom > 0 ? zoom : 1;
152
+ const d = (axis === 'x' ? dxScreen : dyScreen) / z;
153
+ if (track.unit === 'px' || track.unit === 'em') {
154
+ return Math.max(0, round2(track.value + d));
155
+ }
156
+ const targetPx = Math.max(1, resolvedPx + d);
157
+ if (resolvedPx <= 0) return track.value; // no ratio to derive from — no-op
158
+ const ratio = targetPx / resolvedPx;
159
+ const floor = track.unit === '%' ? 1 : 0.1;
160
+ return Math.max(floor, round2(track.value * ratio));
161
+ }
162
+
163
+ /**
164
+ * Which track indices a gutter drag touches, given the Shift modifier —
165
+ * mirrors `paddingSideSet`'s Alt/Shift convention: plain drag touches only
166
+ * the track BEFORE the gutter; Shift touches BOTH neighbors (each driven to
167
+ * its own new value from the SAME cursor delta — "symmetric" in this
168
+ * codebase's established vocabulary means both move together, not an
169
+ * inversely-linked splitter, matching how `paddingSideSet`'s Alt sets both
170
+ * opposite padding sides to the identical dragged value rather than trading
171
+ * space between them).
172
+ */
173
+ export function gutterTrackIndices(gutterIndex: number, shiftKey: boolean): number[] {
174
+ return shiftKey ? [gutterIndex, gutterIndex + 1] : [gutterIndex];
175
+ }
176
+
177
+ function round2(n: number): number {
178
+ return Math.round(n * 100) / 100;
179
+ }
@@ -80,6 +80,15 @@ export interface RegistryItem {
80
80
  light?: Record<string, string>;
81
81
  dark?: Record<string, string>;
82
82
  };
83
+ /**
84
+ * shadcn's generic item-specific-metadata extension point. feature-3-web-
85
+ * artboards T6 — carries the resolved artboard `kind` (`digital` | `print`
86
+ * | `web` | `video` | `mixed`) so a consumer (or a future re-import) knows
87
+ * what authoring contract produced this drop without re-parsing the TSX.
88
+ */
89
+ meta?: {
90
+ kind: string;
91
+ };
83
92
  }
84
93
 
85
94
  export interface EmitOptions {
@@ -103,6 +112,31 @@ export interface EmitOptions {
103
112
  designRoot?: string;
104
113
  }
105
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // feature-3-web-artboards T6 — thread the artboard kind into the emitted
117
+ // registry-item's `meta` (a first-class shadcn registry-item.json extension
118
+ // point for item-specific metadata, not a maude-invented field). A regex scan
119
+ // is deliberate here (not an AST walk like stripDataCdId) — this only needs
120
+ // the `kind="…"` string values, never a node reference to edit.
121
+
122
+ /**
123
+ * Resolve a single `kind` value for the whole canvas file, from its
124
+ * `<DCArtboard kind="…">` occurrences. A canvas with zero explicit `kind`
125
+ * attributes resolves to `'digital'` (DCArtboard's own implicit default). A
126
+ * canvas whose artboards carry more than one distinct explicit kind (e.g. a
127
+ * multi-breakpoint web canvas assembled via "Duplicate at width…" next to an
128
+ * unrelated screen) resolves to `'mixed'` rather than picking one arbitrarily.
129
+ */
130
+ export function resolveCanvasKind(rawSource: string): string {
131
+ const kinds = new Set<string>();
132
+ for (const m of rawSource.matchAll(/<DCArtboard\b[^>]*\bkind="([a-z]+)"/g)) {
133
+ if (m[1]) kinds.add(m[1]);
134
+ }
135
+ if (kinds.size === 0) return 'digital';
136
+ if (kinds.size === 1) return [...kinds][0] as string;
137
+ return 'mixed';
138
+ }
139
+
106
140
  // ---------------------------------------------------------------------------
107
141
  // Strip data-cd-id from source — the inverse of canvas-pipeline.ts pass 1.
108
142
 
@@ -668,6 +702,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
668
702
  registryDependencies,
669
703
  files,
670
704
  ...(cssVars ? { cssVars } : {}),
705
+ meta: { kind: resolveCanvasKind(rawTsx) },
671
706
  };
672
707
 
673
708
  // Drop undefined keys for a clean JSON.