@tekyzinc/gsd-t 2.73.13 → 2.73.19

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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,47 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
- ## [2.73.13] - 2026-04-08
5
+ ## [2.73.19] - 2026-04-09
6
6
 
7
- ### Fixed (review UI — test fixture props for component preview)
8
- - **Preview now passes test fixture props** from design contracts to mounted components. Reads `## Test Fixture` JSON block from contract, strips metadata keys (`__` prefixed), and passes as component props. Components that require data (charts, tables, stat cards) now render with sample data instead of blank.
7
+ ### Added
8
+ - **SVG attribute inspector** SVG elements (`circle`, `path`, `rect`, `line`, `ellipse`, `text`, `g`, `polyline`, `polygon`) now show an "SVG Attributes" property group with all relevant attributes (stroke-width, r, fill, stroke, stroke-dasharray, viewBox, etc.). Attributes are editable inline with `setAttribute()`. Visual flash zones highlight stroke-width (blue), stroke/fill (matching color), dash patterns (amber dashed), and generic attrs (cyan).
9
+ - **SVG permitted value dropdowns** — `stroke-linecap`, `stroke-linejoin`, `text-anchor`, `dominant-baseline` show `<select>` dropdowns with valid SVG values.
10
+
11
+ ### Fixed
12
+ - **Dropdown flash-on-click** — clicking a permitted-value `<select>` dropdown no longer re-triggers `startEdit()`, which was recreating the dropdown and causing it to flash on/off. Added re-entry guard checking for existing `select`/`input` inside the value element.
13
+
14
+ ## [2.73.18] - 2026-04-09
15
+
16
+ ### Added
17
+ - **Permitted value dropdowns** for enum CSS properties — `display`, `flexDirection`, `textAlign`, `alignItems`, `justifyContent`, `fontWeight`, `overflow`, `position` now show a `<select>` dropdown with valid options instead of a free-text input. Select commits on change.
18
+ - **Enhanced visual cue** — generic flashZone fallback now shows a bright blue outline with a computed-value label overlay. All property name clicks flash the element.
19
+ - **More editable properties** — added `overflow`, `position`, `top`, `left`, `boxShadow`, `fontFamily` to the editable set.
20
+
21
+ ## [2.73.17] - 2026-04-08
22
+
23
+ ### Added
24
+ - **Fixture data tree in property inspector** — when a component is selected, the inspector fetches its test fixture data from `/review/api/fixture` and renders it as an expandable tree. Shows columns, rows, segments, and all nested data with color swatches for hex values. Collapsible at every level.
25
+
26
+ ## [2.73.16] - 2026-04-08
27
+
28
+ ### Added
29
+ - **Gallery view** — `/review/gallery?cols=N` renders all queued components in a grid layout, proxied through Vite. Vue error boundaries isolate per-component failures so one broken component doesn't crash the gallery. Gallery button in review UI header toggles between single-component and gallery views.
30
+ - **Fixture unwrapping** — when a contract test fixture wraps props in an array (e.g., `{cards: [{value, label}]}`) but the component expects flat props, the first item is auto-unwrapped. Fixes StatCardWithIcon rendering blank in preview.
31
+
32
+ ## [2.73.15] - 2026-04-08
33
+
34
+ ### Added
35
+ - **Reviewer output logging** — review and fix outputs now saved to `build-logs/` as `{phase}-review-{id}-c{cycle}.log` and `{phase}-fix-{id}-c{cycle}.log`. Enables auditing whether reviewers actually ran Playwright, what issues were found, and what fixes were applied.
36
+
37
+ ### Changed
38
+ - **Default parallelism now all-items** — per-item pipeline runs all items concurrently by default (was sequential). Bottleneck is API latency, not CPU/RAM. Use `--parallel N` to limit if needed.
39
+
40
+ ## [2.73.14] - 2026-04-08
41
+
42
+ ### Fixed (review UI — component preview rendering)
43
+ - **Preview HTML now proxied through Vite** for module resolution. Bare module specifiers (`'vue'`, `'react'`) are transformed by Vite into resolved paths. Previously served static HTML which caused `Failed to resolve module specifier "vue"` error.
44
+ - **Test fixture props extracted from design contracts** — reads `## Test Fixture` JSON block, strips metadata keys, passes as component props. Components now render with sample data.
45
+ - **Playwright-verified** — ChartDonut renders 5-segment donut with center value, sublabel, and percentage labels from contract fixture data.
9
46
 
10
47
  ## [2.73.12] - 2026-04-08
11
48
 
@@ -518,7 +518,7 @@ ${BOLD}Options:${RESET}
518
518
  --skip-measure Skip Playwright measurement (human-review only)
519
519
  --clean Clear all stale artifacts before starting
520
520
  --verbose, -v Show Claude's tool calls and prompts in terminal
521
- --parallel <N> Run N items concurrently (default: 1 = sequential)
521
+ --parallel <N> Run N items concurrently (default: all items in parallel)
522
522
  --help Show this help
523
523
 
524
524
  ${BOLD}Pipeline:${RESET}
@@ -164,7 +164,7 @@ ${BOLD}Options:${RESET}
164
164
  --timeout <sec> Claude timeout per phase in seconds (default: 600)
165
165
  --skip-measure Skip automated measurement (human-review only)
166
166
  --clean Clear all artifacts from previous runs + delete build output
167
- --parallel <N> Run N items concurrently (default: 1, recommended: 3)
167
+ --parallel <N> Run N items concurrently (default: all items in parallel)
168
168
  --verbose, -v Show Claude's tool calls and prompts in terminal
169
169
  --help Show this help
170
170
 
@@ -784,7 +784,7 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
784
784
  // Each item is independent: 1 contract + 1 source = tiny context.
785
785
  // With --parallel N, runs N items concurrently.
786
786
  if (this.wf.buildSingleItemPrompt && this.wf.buildSingleItemReviewPrompt) {
787
- const concurrency = opts.parallel || 1;
787
+ const concurrency = opts.parallel || items.length;
788
788
  if (concurrency > 1) {
789
789
  log(`\n${CYAN} ⚙${RESET} Building and reviewing ${items.length} ${phase} (${concurrency} parallel)...`);
790
790
  } else {
@@ -819,6 +819,12 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
819
819
  const reviewPrompt = this.wf.buildSingleItemReviewPrompt(phase, item, {}, projectDir, { devPort, reviewPort });
820
820
  const reviewResult = await this.spawnClaudeAsync(projectDir, reviewPrompt, perItemTimeout, { label: `${phase}-review-${item.id}-c${cycle}` });
821
821
 
822
+ // Save review output for auditing (was the reviewer thorough? did it use Playwright?)
823
+ fs.writeFileSync(
824
+ path.join(buildLogDir, `${phase}-review-${item.id}-c${cycle}.log`),
825
+ `Exit code: ${reviewResult.exitCode}\nDuration: ${reviewResult.duration}s\n\n--- REVIEW OUTPUT ---\n${reviewResult.output.slice(0, 10000)}`
826
+ );
827
+
822
828
  const isCrash = reviewResult.exitCode !== 0 && reviewResult.duration < 10;
823
829
  const isKilled = [143, 137].includes(reviewResult.exitCode);
824
830
  const isEmptyFail = reviewResult.exitCode !== 0 && !reviewResult.output.trim();
@@ -849,6 +855,10 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
849
855
  : this._defaultAutoFixPrompt(phase, itemIssues);
850
856
  dim(` ${item.componentName}: fixing...`);
851
857
  const fixResult = await this.spawnClaudeAsync(projectDir, fixPrompt, perItemTimeout, { label: `${phase}-fix-${item.id}-c${cycle}` });
858
+ fs.writeFileSync(
859
+ path.join(buildLogDir, `${phase}-fix-${item.id}-c${cycle}.log`),
860
+ `Exit code: ${fixResult.exitCode}\nDuration: ${fixResult.duration}s\n\n--- FIX OUTPUT ---\n${fixResult.output.slice(0, 10000)}`
861
+ );
852
862
  if (fixResult.exitCode === 0) success(` ${item.componentName}: fixed (${fixResult.duration}s)`);
853
863
  else warn(` ${item.componentName}: fix exit ${fixResult.exitCode}`);
854
864
  }
@@ -924,6 +934,14 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
924
934
  const reviewResult = this.spawnClaude(projectDir, reviewPrompt, Math.min(reviewTimeout, perItemTimeout), { label: `${phase}-review-c${autoReviewCycle}-${item.id}` });
925
935
  totalDuration += reviewResult.duration;
926
936
 
937
+ // Save review output for auditing
938
+ const reviewLogDir = path.join(this.getReviewDir(projectDir), "build-logs");
939
+ ensureDir(reviewLogDir);
940
+ fs.writeFileSync(
941
+ path.join(reviewLogDir, `${phase}-review-c${autoReviewCycle}-${item.id}.log`),
942
+ `Exit code: ${reviewResult.exitCode}\nDuration: ${reviewResult.duration}s\n\n--- REVIEW OUTPUT ---\n${reviewResult.output.slice(0, 10000)}`
943
+ );
944
+
927
945
  const isCrash = reviewResult.exitCode !== 0 && reviewResult.duration < 10;
928
946
  const isKilled = [143, 137].includes(reviewResult.exitCode);
929
947
  const isEmptyFail = reviewResult.exitCode !== 0 && !reviewResult.output.trim();
@@ -34,7 +34,7 @@ Pass any of these as `$ARGUMENTS`:
34
34
  | `--timeout <sec>` | Claude timeout per tier in seconds (default: 600) |
35
35
  | `--skip-measure` | Skip Playwright measurement (human-review only) |
36
36
  | `--clean` | Clear all stale artifacts + delete build output before each phase |
37
- | `--parallel <N>` | Run N items concurrently (default: 1, recommended: 3) |
37
+ | `--parallel <N>` | Run N items concurrently (default: all items in parallel) |
38
38
  | `--verbose`, `-v` | Show Claude's tool calls and prompts in terminal |
39
39
 
40
40
  ## Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "2.73.13",
3
+ "version": "2.73.19",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 56 slash commands with headless CI/CD mode, graph-powered code analysis, real-time agent dashboard, execution intelligence, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -333,12 +333,67 @@
333
333
  backgroundColor: "rgba(148, 163, 184, 0.08)",
334
334
  });
335
335
 
336
- // ── Generic fallback ──
336
+ // ── SVG attribute zones (prefixed with "svg:") ──
337
+ } else if (property.startsWith("svg:")) {
338
+ const attr = property.slice(4);
339
+ if (attr === "stroke-width" || attr === "r" || attr === "rx" || attr === "ry") {
340
+ // Dimension attribute — blue outline with measurement label
341
+ addFlashDiv(rect.top - 2, rect.left - 2, rect.width + 4, rect.height + 4, {
342
+ border: "2px solid #3b82f6",
343
+ backgroundColor: "rgba(59, 130, 246, 0.15)",
344
+ borderRadius: "50%",
345
+ });
346
+ const val = el.getAttribute(attr) || "";
347
+ if (val) {
348
+ const lbl = addFlashDiv(rect.top - 18, rect.left, Math.max(val.length * 8, 40), 16, {
349
+ backgroundColor: "#1e293b", borderRadius: "3px",
350
+ fontSize: "10px", color: "#3b82f6", fontFamily: "monospace",
351
+ fontWeight: "600", textAlign: "center", lineHeight: "16px",
352
+ });
353
+ lbl.textContent = val;
354
+ }
355
+ } else if (attr === "stroke" || attr === "fill") {
356
+ // Color attribute — colored border matching the value
357
+ const color = el.getAttribute(attr) || getComputedStyle(el)[attr] || "#888";
358
+ addFlashDiv(rect.top - 3, rect.left - 3, rect.width + 6, rect.height + 6, {
359
+ border: "3px solid " + color,
360
+ backgroundColor: "transparent",
361
+ borderRadius: "2px",
362
+ });
363
+ } else if (attr === "stroke-dasharray" || attr === "stroke-dashoffset") {
364
+ // Dash pattern — dashed outline
365
+ addFlashDiv(rect.top - 2, rect.left - 2, rect.width + 4, rect.height + 4, {
366
+ border: "2px dashed #f59e0b",
367
+ backgroundColor: "rgba(245, 158, 11, 0.1)",
368
+ borderRadius: "2px",
369
+ });
370
+ } else {
371
+ // Generic SVG attr — cyan outline
372
+ addFlashDiv(rect.top - 2, rect.left - 2, rect.width + 4, rect.height + 4, {
373
+ border: "2px solid #06b6d4",
374
+ backgroundColor: "rgba(6, 182, 212, 0.1)",
375
+ borderRadius: "2px",
376
+ });
377
+ }
378
+
379
+ // ── Generic fallback — bright outline + pulse for any unhandled property ──
337
380
  } else {
338
- addFlashDiv(rect.top, rect.left, rect.width, rect.height, {
339
- backgroundColor: "rgba(59, 130, 246, 0.2)",
340
- border: "1px solid rgba(59, 130, 246, 0.4)",
381
+ addFlashDiv(rect.top - 2, rect.left - 2, rect.width + 4, rect.height + 4, {
382
+ backgroundColor: "rgba(59, 130, 246, 0.15)",
383
+ border: "2px solid rgba(59, 130, 246, 0.7)",
384
+ borderRadius: "4px",
341
385
  });
386
+ // Property value label
387
+ const propVal = s.getPropertyValue(property.replace(/([A-Z])/g, "-$1").toLowerCase()) || s[property] || "";
388
+ if (propVal && propVal.length < 30) {
389
+ const lbl = addFlashDiv(rect.top - 20, rect.left, Math.max(propVal.length * 7, 50), 16, {
390
+ backgroundColor: "#1e293b", borderRadius: "3px",
391
+ fontSize: "10px", color: "#60a5fa", fontFamily: "monospace",
392
+ fontWeight: "600", textAlign: "center", lineHeight: "16px",
393
+ padding: "0 4px", whiteSpace: "nowrap",
394
+ });
395
+ lbl.textContent = propVal;
396
+ }
342
397
  }
343
398
 
344
399
  scheduleFlashFade();
@@ -408,6 +463,44 @@
408
463
  };
409
464
  }
410
465
 
466
+ // SVG attribute names to extract per element type
467
+ const SVG_ATTRS = {
468
+ svg: ["viewBox", "width", "height"],
469
+ circle: ["cx", "cy", "r", "stroke", "stroke-width", "fill", "opacity", "stroke-dasharray", "stroke-dashoffset", "transform"],
470
+ ellipse: ["cx", "cy", "rx", "ry", "stroke", "stroke-width", "fill", "opacity"],
471
+ rect: ["x", "y", "width", "height", "rx", "ry", "stroke", "stroke-width", "fill", "opacity"],
472
+ line: ["x1", "y1", "x2", "y2", "stroke", "stroke-width", "opacity"],
473
+ path: ["d", "stroke", "stroke-width", "fill", "opacity", "stroke-linecap", "stroke-linejoin", "transform"],
474
+ text: ["x", "y", "font-size", "font-weight", "fill", "text-anchor", "dominant-baseline"],
475
+ g: ["transform", "opacity", "fill", "stroke"],
476
+ polyline: ["points", "stroke", "stroke-width", "fill"],
477
+ polygon: ["points", "stroke", "stroke-width", "fill"],
478
+ };
479
+
480
+ function extractSvgAttrs(el) {
481
+ const tag = el.tagName.toLowerCase();
482
+ const attrNames = SVG_ATTRS[tag];
483
+ if (!attrNames) return null;
484
+ const attrs = {};
485
+ let hasAny = false;
486
+ for (const name of attrNames) {
487
+ const val = el.getAttribute(name);
488
+ if (val !== null && val !== undefined) {
489
+ attrs[name] = val;
490
+ hasAny = true;
491
+ }
492
+ }
493
+ // Also grab computed stroke/fill from CSS if not in attributes
494
+ if (el instanceof SVGElement) {
495
+ const s = getComputedStyle(el);
496
+ if (!attrs["stroke"] && s.stroke && s.stroke !== "none") { attrs["stroke"] = s.stroke; hasAny = true; }
497
+ if (!attrs["fill"] && s.fill && s.fill !== "none") { attrs["fill"] = s.fill; hasAny = true; }
498
+ if (!attrs["stroke-width"] && s.strokeWidth) { attrs["stroke-width"] = s.strokeWidth; hasAny = true; }
499
+ if (!attrs["opacity"] && s.opacity !== "1") { attrs["opacity"] = s.opacity; hasAny = true; }
500
+ }
501
+ return hasAny ? attrs : null;
502
+ }
503
+
411
504
  function extractBoxModel(el) {
412
505
  const s = getComputedStyle(el);
413
506
  const rect = el.getBoundingClientRect();
@@ -536,6 +629,7 @@
536
629
  path: getElementPath(el),
537
630
  styles: extractStyles(el),
538
631
  boxModel: extractBoxModel(el),
632
+ svgAttrs: extractSvgAttrs(el),
539
633
  tagName: el.tagName.toLowerCase(),
540
634
  className: typeof el.className === "string" ? el.className : "",
541
635
  textContent: (el.textContent || "").trim().substring(0, 100),
@@ -573,6 +667,7 @@
573
667
  path: getElementPath(el),
574
668
  styles: extractStyles(el),
575
669
  boxModel: extractBoxModel(el),
670
+ svgAttrs: extractSvgAttrs(el),
576
671
  tagName: el.tagName.toLowerCase(),
577
672
  className: typeof el.className === "string" ? el.className : "",
578
673
  textContent: (el.textContent || "").trim().substring(0, 100),
@@ -735,6 +830,7 @@
735
830
  value: msg.value,
736
831
  styles: extractStyles(lockedEl),
737
832
  boxModel: extractBoxModel(lockedEl),
833
+ svgAttrs: extractSvgAttrs(lockedEl),
738
834
  propagated: propagatedCount,
739
835
  propagateScope,
740
836
  }, "*");
@@ -760,6 +856,7 @@
760
856
  path: getElementPath(lockedEl),
761
857
  styles: extractStyles(lockedEl),
762
858
  boxModel: extractBoxModel(lockedEl),
859
+ svgAttrs: extractSvgAttrs(lockedEl),
763
860
  }, "*");
764
861
  }
765
862
  break;
@@ -926,6 +1023,7 @@
926
1023
  path: getElementPath(el),
927
1024
  styles: extractStyles(el),
928
1025
  boxModel: extractBoxModel(el),
1026
+ svgAttrs: extractSvgAttrs(el),
929
1027
  tagName: el.tagName.toLowerCase(),
930
1028
  className: typeof el.className === "string" ? el.className : "",
931
1029
  textContent: (el.textContent || "").trim().substring(0, 100),
@@ -933,6 +1031,23 @@
933
1031
  }, "*");
934
1032
  }
935
1033
  break;
1034
+
1035
+ case "gsdt-set-svg-attr":
1036
+ // Apply an SVG attribute change to the locked element
1037
+ if (lockedEl && msg.attribute && msg.value !== undefined) {
1038
+ lockedEl.setAttribute(msg.attribute, msg.value);
1039
+ positionOverlay(lockedEl);
1040
+ window.parent.postMessage({
1041
+ type: "gsdt-style-updated",
1042
+ path: getElementPath(lockedEl),
1043
+ property: "svg:" + msg.attribute,
1044
+ value: msg.value,
1045
+ styles: extractStyles(lockedEl),
1046
+ boxModel: extractBoxModel(lockedEl),
1047
+ svgAttrs: extractSvgAttrs(lockedEl),
1048
+ }, "*");
1049
+ }
1050
+ break;
936
1051
  }
937
1052
  });
938
1053
 
@@ -69,6 +69,23 @@ function extractFixtureFromContract(componentPath) {
69
69
  for (const [k, v] of Object.entries(fixture)) {
70
70
  if (!k.startsWith("__")) props[k] = v;
71
71
  }
72
+ // Check if component expects different props than fixture provides.
73
+ // If fixture has a single array-of-objects key (e.g., "cards": [{value, label}]),
74
+ // and the component file's defineProps doesn't reference that key,
75
+ // unwrap the first item as individual props.
76
+ const propKeys = Object.keys(props);
77
+ if (propKeys.length === 1) {
78
+ const val = props[propKeys[0]];
79
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === "object") {
80
+ try {
81
+ const compSource = fs.readFileSync(path.join(PROJECT_DIR, componentPath), "utf8");
82
+ // If the component doesn't reference this array key in defineProps, unwrap first item
83
+ if (!compSource.includes(propKeys[0])) {
84
+ return val[0];
85
+ }
86
+ } catch { /* can't read component, use fixture as-is */ }
87
+ }
88
+ }
72
89
  return props;
73
90
  } catch { return null; }
74
91
  }
@@ -107,7 +124,6 @@ function generatePreviewHtml(componentPath) {
107
124
  <meta charset="UTF-8">
108
125
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
126
  <title>Preview: ${path.basename(componentPath)}</title>
110
- <script type="module" src="/@vite/client"></script>
111
127
  ${linkTags}
112
128
  <style>
113
129
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -124,6 +140,112 @@ ${linkTags}
124
140
  </html>`;
125
141
  }
126
142
 
143
+ function generateGalleryHtml(queueItems, cols) {
144
+ const linkTags = GLOBAL_STYLES.map(s => ` <link rel="stylesheet" href="/${s}">`).join("\n");
145
+ // Only include items with source paths (actual components)
146
+ const components = queueItems.filter(item => item.sourcePath);
147
+
148
+ const imports = components.map((item, i) => {
149
+ return ` import Comp${i} from '/${item.sourcePath}'`;
150
+ }).join("\n");
151
+
152
+ const propsData = components.map((item) => {
153
+ const fixture = extractFixtureFromContract(item.sourcePath);
154
+ return fixture ? JSON.stringify(fixture) : "{}";
155
+ });
156
+
157
+ let mountScript;
158
+ if (FRAMEWORK === "vue") {
159
+ mountScript = `
160
+ <script type="module">
161
+ import { createApp, h, defineComponent, onErrorCaptured, ref } from 'vue'
162
+ ${imports}
163
+
164
+ const components = [${components.map((_, i) => `Comp${i}`).join(", ")}];
165
+ const names = ${JSON.stringify(components.map(c => c.name || c.id))};
166
+ const allProps = [${propsData.join(", ")}];
167
+
168
+ // Error boundary wrapper — catches render errors per-component
169
+ const SafeCell = defineComponent({
170
+ props: { comp: Object, compProps: Object, label: String },
171
+ setup(props) {
172
+ const error = ref(null)
173
+ onErrorCaptured((err) => { error.value = err.message; return false })
174
+ return () => h('div', { class: 'gallery-cell' }, [
175
+ h('div', { class: 'gallery-label' }, props.label),
176
+ h('div', { class: 'gallery-component' }, [
177
+ error.value
178
+ ? h('div', { style: 'color:#ef4444;font-size:12px;padding:8px' }, '⚠ ' + error.value)
179
+ : h(props.comp, props.compProps)
180
+ ])
181
+ ])
182
+ }
183
+ })
184
+
185
+ const Gallery = defineComponent({
186
+ render() {
187
+ return h('div', { class: 'gallery-grid' },
188
+ components.map((Comp, i) =>
189
+ h(SafeCell, { comp: Comp, compProps: allProps[i], label: names[i], key: i })
190
+ )
191
+ )
192
+ }
193
+ })
194
+ createApp(Gallery).mount('#app')
195
+ </script>`;
196
+ } else if (FRAMEWORK === "react") {
197
+ mountScript = `
198
+ <script type="module">
199
+ import React from 'react'
200
+ import { createRoot } from 'react-dom/client'
201
+ ${imports}
202
+
203
+ const components = [${components.map((_, i) => `Comp${i}`).join(", ")}];
204
+ const names = ${JSON.stringify(components.map(c => c.name || c.id))};
205
+ const allProps = [${propsData.join(", ")}];
206
+
207
+ function Gallery() {
208
+ return React.createElement('div', { className: 'gallery-grid' },
209
+ components.map((Comp, i) =>
210
+ React.createElement('div', { className: 'gallery-cell', key: i },
211
+ React.createElement('div', { className: 'gallery-label' }, names[i]),
212
+ React.createElement('div', { className: 'gallery-component' },
213
+ React.createElement(Comp, allProps[i])
214
+ )
215
+ )
216
+ )
217
+ )
218
+ }
219
+ createRoot(document.getElementById('app')).render(React.createElement(Gallery))
220
+ </script>`;
221
+ } else {
222
+ mountScript = `<script type="module">/* unsupported framework */</script>`;
223
+ }
224
+
225
+ return `<!DOCTYPE html>
226
+ <html lang="en">
227
+ <head>
228
+ <meta charset="UTF-8">
229
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
230
+ <title>GSD-T Design Gallery</title>
231
+ ${linkTags}
232
+ <style>
233
+ * { margin: 0; padding: 0; box-sizing: border-box; }
234
+ body { background: #f1f5f9; min-height: 100vh; padding: 24px; font-family: 'Poppins', -apple-system, BlinkMacSystemFont, sans-serif; }
235
+ #app { width: 100%; }
236
+ .gallery-grid { display: grid; grid-template-columns: repeat(${cols}, 1fr); gap: 20px; }
237
+ .gallery-cell { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; overflow: hidden; }
238
+ .gallery-label { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; font-family: monospace; }
239
+ .gallery-component { min-height: 60px; }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div id="app"></div>
244
+ ${mountScript}
245
+ </body>
246
+ </html>`;
247
+ }
248
+
127
249
  // ── Ensure coordination directory ─────────────────────────────────────
128
250
  function ensureDir(dir) {
129
251
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
@@ -361,7 +483,50 @@ const server = http.createServer((req, res) => {
361
483
  return;
362
484
  }
363
485
 
364
- // Component preview mounts a single component in isolation via Vite
486
+ // Galleryall queued components in a grid, proxied through Vite
487
+ if (pathname === "/review/gallery") {
488
+ const queueItems = readQueue();
489
+ const cols = parseInt(parsed.query.cols || "3", 10);
490
+ if (queueItems.length === 0) {
491
+ res.writeHead(200, { "Content-Type": "text/html" });
492
+ res.end("<html><body><h2>No components queued yet</h2></body></html>");
493
+ return;
494
+ }
495
+ const html = generateGalleryHtml(queueItems, cols);
496
+ const previewFile = path.join(PROJECT_DIR, "__gsd-preview.html");
497
+ try { fs.writeFileSync(previewFile, html); } catch { /* ignore */ }
498
+ const proxyOpts = {
499
+ hostname: targetUrl.hostname,
500
+ port: targetUrl.port,
501
+ path: "/__gsd-preview.html",
502
+ method: "GET",
503
+ headers: { ...req.headers, host: `${targetUrl.hostname}:${targetUrl.port}` },
504
+ };
505
+ const proxyReq = http.request(proxyOpts, (proxyRes) => {
506
+ const chunks = [];
507
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
508
+ proxyRes.on("end", () => {
509
+ const transformed = Buffer.concat(chunks).toString("utf8");
510
+ const buf = Buffer.from(transformed, "utf8");
511
+ res.writeHead(proxyRes.statusCode, {
512
+ ...proxyRes.headers,
513
+ "content-length": buf.length,
514
+ "cache-control": "no-cache",
515
+ });
516
+ res.end(buf);
517
+ try { fs.unlinkSync(previewFile); } catch { /* ignore */ }
518
+ });
519
+ });
520
+ proxyReq.on("error", () => {
521
+ res.writeHead(502, { "Content-Type": "text/html" });
522
+ res.end("<h1>Dev server unreachable</h1>");
523
+ try { fs.unlinkSync(previewFile); } catch { /* ignore */ }
524
+ });
525
+ proxyReq.end();
526
+ return;
527
+ }
528
+
529
+ // Component preview — writes temp HTML to project, proxies through Vite for module resolution
365
530
  if (pathname === "/review/preview") {
366
531
  const component = parsed.query.component;
367
532
  if (!component) {
@@ -369,24 +534,44 @@ const server = http.createServer((req, res) => {
369
534
  res.end("Missing ?component= parameter");
370
535
  return;
371
536
  }
372
- // Proxy this HTML through Vite so module imports resolve correctly
537
+ // Write preview HTML to project dir so Vite transforms bare module specifiers (e.g., 'vue' → /node_modules/.vite/deps/vue.js)
538
+ const previewFile = path.join(PROJECT_DIR, "__gsd-preview.html");
373
539
  const html = generatePreviewHtml(component);
374
- // Send to Vite's HTML transform endpoint via proxy
540
+ try { fs.writeFileSync(previewFile, html); } catch { /* ignore */ }
541
+ // Proxy through Vite so it transforms the HTML
375
542
  const proxyOpts = {
376
543
  hostname: targetUrl.hostname,
377
544
  port: targetUrl.port,
378
- path: "/__gsd_preview",
545
+ path: "/__gsd-preview.html",
379
546
  method: "GET",
380
547
  headers: { ...req.headers, host: `${targetUrl.hostname}:${targetUrl.port}` },
381
548
  };
382
- // Vite won't know this path — serve directly but let browser resolve modules from Vite
383
- const buf = Buffer.from(html, "utf8");
384
- res.writeHead(200, {
385
- "Content-Type": "text/html",
386
- "Content-Length": buf.length,
387
- "Cache-Control": "no-cache",
549
+ const proxyReq = http.request(proxyOpts, (proxyRes) => {
550
+ const chunks = [];
551
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
552
+ proxyRes.on("end", () => {
553
+ let transformed = Buffer.concat(chunks).toString("utf8");
554
+ // Inject review overlay if not already present
555
+ if (!transformed.includes("review/inject.js")) {
556
+ transformed = transformed.replace("</body>", '<script src="/review/inject.js"></script>\n</body>');
557
+ }
558
+ const buf = Buffer.from(transformed, "utf8");
559
+ res.writeHead(proxyRes.statusCode, {
560
+ ...proxyRes.headers,
561
+ "content-length": buf.length,
562
+ "cache-control": "no-cache",
563
+ });
564
+ res.end(buf);
565
+ // Clean up temp file
566
+ try { fs.unlinkSync(previewFile); } catch { /* ignore */ }
567
+ });
388
568
  });
389
- res.end(buf);
569
+ proxyReq.on("error", () => {
570
+ res.writeHead(502, { "Content-Type": "text/html" });
571
+ res.end("<h1>Dev server unreachable</h1>");
572
+ try { fs.unlinkSync(previewFile); } catch { /* ignore */ }
573
+ });
574
+ proxyReq.end();
390
575
  return;
391
576
  }
392
577
 
@@ -403,6 +588,14 @@ const server = http.createServer((req, res) => {
403
588
  return;
404
589
  }
405
590
 
591
+ if (pathname === "/review/api/fixture") {
592
+ const component = parsed.query.component;
593
+ const fixture = component ? extractFixtureFromContract(component) : null;
594
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
595
+ res.end(JSON.stringify(fixture || {}));
596
+ return;
597
+ }
598
+
406
599
  if (pathname === "/review/api/feedback" && req.method === "GET") {
407
600
  res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
408
601
  res.end(JSON.stringify(readFeedback()));
@@ -705,6 +705,12 @@
705
705
  <span class="phase-badge" id="phase-badge">Elements</span>
706
706
  </div>
707
707
  <div class="header-right">
708
+ <button class="inspect-toggle" id="gallery-toggle" title="Show all components in a grid" style="margin-right:4px">
709
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
710
+ <rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
711
+ </svg>
712
+ Gallery
713
+ </button>
708
714
  <button class="inspect-toggle" id="inspect-toggle" title="Toggle inspect mode (Ctrl+Shift+I)">
709
715
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
710
716
  <path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/>
@@ -1045,6 +1051,18 @@
1045
1051
  renderAIReview(item.aiReview);
1046
1052
  }
1047
1053
 
1054
+ // Fetch and render fixture data (columns, rows, segments, etc.)
1055
+ if (item.sourcePath) {
1056
+ fetch(`/review/api/fixture?component=${encodeURIComponent(item.sourcePath)}`)
1057
+ .then(r => r.json())
1058
+ .then(fixture => {
1059
+ if (fixture && Object.keys(fixture).length > 0) {
1060
+ renderFixtureTree(fixture);
1061
+ }
1062
+ })
1063
+ .catch(() => {});
1064
+ }
1065
+
1048
1066
  // Hint
1049
1067
  if (item.selector) {
1050
1068
  const hint = document.createElement("div");
@@ -1054,6 +1072,113 @@
1054
1072
  }
1055
1073
  }
1056
1074
 
1075
+ function renderFixtureTree(fixture) {
1076
+ const container = document.createElement("div");
1077
+ container.className = "prop-group";
1078
+ container.innerHTML = `<div class="prop-group-header">▸ Data Props</div>`;
1079
+ const body = document.createElement("div");
1080
+ body.style.cssText = "padding-left:8px;";
1081
+
1082
+ function renderValue(key, val, parent, depth) {
1083
+ if (Array.isArray(val)) {
1084
+ const row = document.createElement("div");
1085
+ row.className = "prop-group-header";
1086
+ row.style.paddingLeft = (depth * 12) + "px";
1087
+ row.style.fontSize = "11px";
1088
+ row.innerHTML = `<span style="color:#93c5fd">▸ ${key}</span> <span style="color:var(--text-dim);font-size:10px">[${val.length}]</span>`;
1089
+ let expanded = depth < 1;
1090
+ const items = document.createElement("div");
1091
+ items.style.display = expanded ? "" : "none";
1092
+ row.addEventListener("click", () => { expanded = !expanded; items.style.display = expanded ? "" : "none"; row.querySelector("span").textContent = (expanded ? "▾ " : "▸ ") + key; });
1093
+ if (expanded) row.querySelector("span").textContent = "▾ " + key;
1094
+ parent.appendChild(row);
1095
+ val.forEach((item, i) => {
1096
+ if (typeof item === "object" && item !== null) {
1097
+ const itemRow = document.createElement("div");
1098
+ itemRow.style.paddingLeft = ((depth + 1) * 12) + "px";
1099
+ itemRow.style.cssText += "font-size:10px;color:var(--text-dim);margin:2px 0;";
1100
+ itemRow.textContent = `[${i}]`;
1101
+ items.appendChild(itemRow);
1102
+ for (const [k, v] of Object.entries(item)) {
1103
+ renderValue(k, v, items, depth + 2);
1104
+ }
1105
+ } else {
1106
+ const itemRow = document.createElement("div");
1107
+ itemRow.className = "prop-row";
1108
+ itemRow.style.paddingLeft = ((depth + 1) * 12) + "px";
1109
+ itemRow.innerHTML = `<span class="prop-name">[${i}]</span><span class="prop-value">${escapeHtml(String(item))}</span>`;
1110
+ items.appendChild(itemRow);
1111
+ }
1112
+ });
1113
+ parent.appendChild(items);
1114
+ } else if (typeof val === "object" && val !== null) {
1115
+ const row = document.createElement("div");
1116
+ row.className = "prop-group-header";
1117
+ row.style.paddingLeft = (depth * 12) + "px";
1118
+ row.style.fontSize = "11px";
1119
+ row.innerHTML = `<span style="color:#93c5fd">▸ ${key}</span>`;
1120
+ let expanded = depth < 1;
1121
+ const items = document.createElement("div");
1122
+ items.style.display = expanded ? "" : "none";
1123
+ row.addEventListener("click", () => { expanded = !expanded; items.style.display = expanded ? "" : "none"; row.querySelector("span").textContent = (expanded ? "▾ " : "▸ ") + key; });
1124
+ if (expanded) row.querySelector("span").textContent = "▾ " + key;
1125
+ parent.appendChild(row);
1126
+ for (const [k, v] of Object.entries(val)) {
1127
+ renderValue(k, v, items, depth + 1);
1128
+ }
1129
+ parent.appendChild(items);
1130
+ } else {
1131
+ const row = document.createElement("div");
1132
+ row.className = "prop-row";
1133
+ row.style.paddingLeft = (depth * 12) + "px";
1134
+ const displayVal = typeof val === "string" && val.match(/^#[0-9a-fA-F]{3,8}$/)
1135
+ ? `<span class="color-swatch" style="background:${val}"></span>${val}`
1136
+ : escapeHtml(String(val));
1137
+ row.innerHTML = `<span class="prop-name">${escapeHtml(key)}</span><span class="prop-value">${displayVal}</span>`;
1138
+ parent.appendChild(row);
1139
+ }
1140
+ }
1141
+
1142
+ for (const [k, v] of Object.entries(fixture)) {
1143
+ renderValue(k, v, body, 0);
1144
+ }
1145
+ container.appendChild(body);
1146
+
1147
+ // Toggle data props section
1148
+ let dataExpanded = true;
1149
+ container.querySelector(".prop-group-header").addEventListener("click", () => {
1150
+ dataExpanded = !dataExpanded;
1151
+ body.style.display = dataExpanded ? "" : "none";
1152
+ container.querySelector(".prop-group-header").textContent = (dataExpanded ? "▾" : "▸") + " Data Props";
1153
+ });
1154
+ container.querySelector(".prop-group-header").textContent = "▾ Data Props";
1155
+
1156
+ inspectorInfo.appendChild(container);
1157
+ }
1158
+
1159
+ function escapeHtml(str) {
1160
+ return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
1161
+ }
1162
+
1163
+ // ── Gallery mode ──────────────────────────────────
1164
+ const galleryToggle = document.getElementById("gallery-toggle");
1165
+ let galleryActive = false;
1166
+
1167
+ galleryToggle.addEventListener("click", () => {
1168
+ galleryActive = !galleryActive;
1169
+ galleryToggle.classList.toggle("active", galleryActive);
1170
+ if (galleryActive) {
1171
+ previewIframe.src = "/review/gallery?cols=3";
1172
+ previewUrl.textContent = "Gallery — all components";
1173
+ } else if (selectedIdx >= 0 && filteredQueue[selectedIdx]) {
1174
+ const item = filteredQueue[selectedIdx];
1175
+ if (item.sourcePath) {
1176
+ previewIframe.src = `/review/preview?component=${encodeURIComponent(item.sourcePath)}`;
1177
+ previewUrl.textContent = item.sourcePath;
1178
+ }
1179
+ }
1180
+ });
1181
+
1057
1182
  // ── Inspect mode ──────────────────────────────────
1058
1183
  inspectToggle.addEventListener("click", () => {
1059
1184
  inspectActive = !inspectActive;
@@ -1105,6 +1230,7 @@
1105
1230
 
1106
1231
  case "gsdt-style-updated":
1107
1232
  currentStyles = msg.styles;
1233
+ if (msg.svgAttrs) currentSvgAttrs = msg.svgAttrs;
1108
1234
  renderPropertyValues(msg.styles);
1109
1235
  // Show propagation feedback with scope
1110
1236
  if (msg.propagated > 0) {
@@ -1130,9 +1256,12 @@
1130
1256
  });
1131
1257
 
1132
1258
  // ── Inspector rendering ───────────────────────────
1259
+ let currentSvgAttrs = null;
1260
+
1133
1261
  function renderInspector(msg) {
1134
1262
  const styles = msg.styles;
1135
1263
  const boxModel = msg.boxModel;
1264
+ currentSvgAttrs = msg.svgAttrs || null;
1136
1265
 
1137
1266
  // Only update props zone — preserve tree
1138
1267
  inspectorProps.innerHTML = "";
@@ -1186,8 +1315,9 @@
1186
1315
  "width", "height",
1187
1316
  "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
1188
1317
  "marginTop", "marginRight", "marginBottom", "marginLeft",
1189
- "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textAlign",
1318
+ "fontSize", "fontWeight", "fontFamily", "lineHeight", "letterSpacing", "textAlign",
1190
1319
  "backgroundColor", "color", "borderRadius", "border", "opacity",
1320
+ "overflow", "position", "top", "left", "boxShadow",
1191
1321
  ]);
1192
1322
 
1193
1323
  for (const [groupName, props] of Object.entries(groups)) {
@@ -1283,6 +1413,177 @@
1283
1413
  inspectorProps.appendChild(group);
1284
1414
  }
1285
1415
  }
1416
+
1417
+ // SVG Attributes group (if element is SVG)
1418
+ if (currentSvgAttrs) {
1419
+ const svgGroup = document.createElement("div");
1420
+ svgGroup.className = "prop-group";
1421
+
1422
+ const svgHeader = document.createElement("div");
1423
+ svgHeader.className = "prop-group-header";
1424
+ svgHeader.innerHTML = `<span>▾</span> SVG Attributes`;
1425
+ svgHeader.style.color = "#06b6d4";
1426
+ svgGroup.appendChild(svgHeader);
1427
+
1428
+ const svgRows = document.createElement("div");
1429
+ svgRows.style.display = "block";
1430
+
1431
+ svgHeader.addEventListener("click", () => {
1432
+ const visible = svgRows.style.display !== "none";
1433
+ svgRows.style.display = visible ? "none" : "block";
1434
+ svgHeader.innerHTML = `<span>${visible ? "▸" : "▾"}</span> SVG Attributes`;
1435
+ });
1436
+
1437
+ const svgColorAttrs = new Set(["stroke", "fill"]);
1438
+ const svgNumericAttrs = new Set(["stroke-width", "r", "rx", "ry", "cx", "cy", "x", "y",
1439
+ "x1", "y1", "x2", "y2", "width", "height", "opacity", "stroke-dashoffset"]);
1440
+
1441
+ for (const [attr, val] of Object.entries(currentSvgAttrs)) {
1442
+ const row = document.createElement("div");
1443
+ row.className = "prop-row";
1444
+ row.setAttribute("data-prop", "svg:" + attr);
1445
+
1446
+ const nameEl = document.createElement("span");
1447
+ nameEl.className = "prop-name";
1448
+ nameEl.textContent = attr;
1449
+ nameEl.title = `Click to highlight ${attr}`;
1450
+ nameEl.style.cursor = "pointer";
1451
+ nameEl.addEventListener("click", () => {
1452
+ if (previewIframe.contentWindow) {
1453
+ previewIframe.contentWindow.postMessage({
1454
+ type: "gsdt-highlight-zone",
1455
+ property: "svg:" + attr,
1456
+ }, "*");
1457
+ }
1458
+ });
1459
+ row.appendChild(nameEl);
1460
+
1461
+ const valEl = document.createElement("span");
1462
+ valEl.className = "prop-value editable";
1463
+ valEl.setAttribute("data-prop", "svg:" + attr);
1464
+ valEl.setAttribute("data-original", val);
1465
+
1466
+ if (svgColorAttrs.has(attr) && val && val !== "none") {
1467
+ valEl.innerHTML = `<span class="color-swatch" style="background:${val}"></span>${val}`;
1468
+ } else {
1469
+ valEl.textContent = val;
1470
+ }
1471
+
1472
+ // Check if changed
1473
+ const compId = filteredQueue[selectedIdx]?.id;
1474
+ const compChanges = changes.get(compId) || [];
1475
+ const existing = compChanges.find(c => c.path === currentElementPath && c.property === "svg:" + attr);
1476
+ if (existing) {
1477
+ valEl.classList.add("changed");
1478
+ valEl.textContent = existing.newValue;
1479
+ }
1480
+
1481
+ valEl.addEventListener("click", () => startSvgEdit(valEl, attr));
1482
+ row.appendChild(valEl);
1483
+ svgRows.appendChild(row);
1484
+ }
1485
+
1486
+ svgGroup.appendChild(svgRows);
1487
+ if (svgRows.children.length > 0) {
1488
+ inspectorProps.appendChild(svgGroup);
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ function startSvgEdit(valEl, attr) {
1494
+ if (valEl.querySelector("select, input")) return;
1495
+
1496
+ const currentVal = valEl.getAttribute("data-original");
1497
+ const compChanges = changes.get(filteredQueue[selectedIdx]?.id) || [];
1498
+ const existing = compChanges.find(c => c.path === currentElementPath && c.property === "svg:" + attr);
1499
+ const displayVal = existing ? existing.newValue : currentVal;
1500
+
1501
+ const permitted = PERMITTED_VALUES[attr];
1502
+ let inputEl;
1503
+
1504
+ if (permitted) {
1505
+ inputEl = document.createElement("select");
1506
+ inputEl.className = "prop-edit-input";
1507
+ for (const opt of permitted) {
1508
+ const option = document.createElement("option");
1509
+ option.value = opt;
1510
+ option.textContent = opt;
1511
+ if (opt === displayVal) option.selected = true;
1512
+ inputEl.appendChild(option);
1513
+ }
1514
+ } else {
1515
+ inputEl = document.createElement("input");
1516
+ inputEl.className = "prop-edit-input";
1517
+ inputEl.type = "text";
1518
+ inputEl.value = displayVal;
1519
+ }
1520
+
1521
+ valEl.innerHTML = "";
1522
+ valEl.appendChild(inputEl);
1523
+ inputEl.focus();
1524
+ if (!permitted) {
1525
+ const numMatch = displayVal.match(/^(-?[\d.]+)/);
1526
+ if (numMatch) {
1527
+ inputEl.setSelectionRange(0, numMatch[1].length);
1528
+ } else {
1529
+ inputEl.select();
1530
+ }
1531
+ }
1532
+
1533
+ let cancelled = false;
1534
+ function commit() {
1535
+ if (cancelled) return;
1536
+ const newVal = inputEl.value.trim();
1537
+ if (newVal && newVal !== currentVal) {
1538
+ previewIframe.contentWindow.postMessage({
1539
+ type: "gsdt-set-svg-attr",
1540
+ attribute: attr,
1541
+ value: newVal,
1542
+ }, "*");
1543
+
1544
+ const compId = filteredQueue[selectedIdx]?.id;
1545
+ if (compId) {
1546
+ if (!changes.has(compId)) changes.set(compId, []);
1547
+ const list = changes.get(compId);
1548
+ const existingIdx = list.findIndex(c => c.path === currentElementPath && c.property === "svg:" + attr);
1549
+ const change = {
1550
+ path: currentElementPath,
1551
+ property: "svg:" + attr,
1552
+ oldValue: currentVal,
1553
+ newValue: newVal,
1554
+ };
1555
+ if (existingIdx >= 0) {
1556
+ list[existingIdx] = change;
1557
+ } else {
1558
+ list.push(change);
1559
+ }
1560
+ renderChanges(compId);
1561
+ renderComponentList();
1562
+ }
1563
+
1564
+ valEl.textContent = newVal;
1565
+ valEl.classList.add("changed");
1566
+ } else {
1567
+ valEl.textContent = existing ? existing.newValue : currentVal;
1568
+ if (existing) valEl.classList.add("changed");
1569
+ }
1570
+ }
1571
+
1572
+ if (permitted) {
1573
+ inputEl.addEventListener("change", () => { commit(); cancelled = true; });
1574
+ inputEl.addEventListener("blur", () => { if (!cancelled) { valEl.textContent = existing ? existing.newValue : currentVal; } });
1575
+ } else {
1576
+ inputEl.addEventListener("blur", () => { if (!cancelled) commit(); });
1577
+ }
1578
+ inputEl.addEventListener("keydown", (e) => {
1579
+ if (e.key === "Enter") { commit(); cancelled = true; inputEl.blur(); }
1580
+ if (e.key === "Escape") {
1581
+ cancelled = true;
1582
+ if (inputEl.parentElement) inputEl.remove();
1583
+ valEl.textContent = existing ? existing.newValue : currentVal;
1584
+ if (existing) valEl.classList.add("changed");
1585
+ }
1586
+ });
1286
1587
  }
1287
1588
 
1288
1589
  function renderPropertyValues(styles) {
@@ -1408,32 +1709,71 @@
1408
1709
  }
1409
1710
 
1410
1711
  // ── Property editing ──────────────────────────────
1712
+ // Permitted values for enum-style CSS properties
1713
+ const PERMITTED_VALUES = {
1714
+ display: ["block", "flex", "inline-flex", "grid", "inline-grid", "inline", "inline-block", "none", "contents"],
1715
+ flexDirection: ["row", "row-reverse", "column", "column-reverse"],
1716
+ alignItems: ["stretch", "flex-start", "flex-end", "center", "baseline"],
1717
+ justifyContent: ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"],
1718
+ textAlign: ["left", "center", "right", "justify"],
1719
+ fontWeight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
1720
+ overflow: ["visible", "hidden", "scroll", "auto", "clip"],
1721
+ position: ["static", "relative", "absolute", "fixed", "sticky"],
1722
+ // SVG attribute permitted values (used by startSvgEdit)
1723
+ "stroke-linecap": ["butt", "round", "square"],
1724
+ "stroke-linejoin": ["miter", "round", "bevel"],
1725
+ "text-anchor": ["start", "middle", "end"],
1726
+ "dominant-baseline": ["auto", "middle", "hanging", "central", "text-top", "text-bottom"],
1727
+ };
1728
+
1411
1729
  function startEdit(valEl, prop) {
1730
+ // Prevent re-entry: if already editing (select/input inside), do nothing
1731
+ if (valEl.querySelector("select, input")) return;
1732
+
1412
1733
  const currentVal = valEl.getAttribute("data-original");
1413
1734
  const compChanges = changes.get(filteredQueue[selectedIdx]?.id) || [];
1414
1735
  const existing = compChanges.find(c => c.path === currentElementPath && c.property === prop);
1415
1736
  const displayVal = existing ? existing.newValue : currentVal;
1416
1737
 
1417
- const input = document.createElement("input");
1418
- input.className = "prop-edit-input";
1419
- input.type = "text";
1420
- input.value = displayVal;
1738
+ const permitted = PERMITTED_VALUES[prop];
1739
+ let inputEl;
1421
1740
  let cancelled = false;
1422
1741
 
1423
- valEl.innerHTML = "";
1424
- valEl.appendChild(input);
1425
- input.focus();
1426
- // Select just the numeric part for values like "24px", "1.5em", "600"
1427
- const numMatch = displayVal.match(/^(-?[\d.]+)/);
1428
- if (numMatch) {
1429
- input.setSelectionRange(0, numMatch[1].length);
1742
+ if (permitted) {
1743
+ // Dropdown for enum properties
1744
+ inputEl = document.createElement("select");
1745
+ inputEl.className = "prop-edit-input";
1746
+ for (const opt of permitted) {
1747
+ const option = document.createElement("option");
1748
+ option.value = opt;
1749
+ option.textContent = opt;
1750
+ if (opt === displayVal) option.selected = true;
1751
+ inputEl.appendChild(option);
1752
+ }
1430
1753
  } else {
1431
- input.select();
1754
+ // Text input for free-form values
1755
+ inputEl = document.createElement("input");
1756
+ inputEl.className = "prop-edit-input";
1757
+ inputEl.type = "text";
1758
+ inputEl.value = displayVal;
1759
+ }
1760
+
1761
+ valEl.innerHTML = "";
1762
+ valEl.appendChild(inputEl);
1763
+ inputEl.focus();
1764
+ if (!permitted) {
1765
+ // Select just the numeric part for values like "24px", "1.5em", "600"
1766
+ const numMatch = displayVal.match(/^(-?[\d.]+)/);
1767
+ if (numMatch) {
1768
+ inputEl.setSelectionRange(0, numMatch[1].length);
1769
+ } else {
1770
+ inputEl.select();
1771
+ }
1432
1772
  }
1433
1773
 
1434
1774
  function commit() {
1435
1775
  if (cancelled) return;
1436
- const newVal = input.value.trim();
1776
+ const newVal = inputEl.value.trim();
1437
1777
  if (newVal && newVal !== currentVal) {
1438
1778
  // Apply the style change to the iframe
1439
1779
  previewIframe.contentWindow.postMessage({
@@ -1472,13 +1812,18 @@
1472
1812
  }
1473
1813
  }
1474
1814
 
1475
- input.addEventListener("blur", () => { if (!cancelled) commit(); });
1476
- input.addEventListener("keydown", (e) => {
1477
- if (e.key === "Enter") { commit(); cancelled = true; input.blur(); }
1815
+ if (permitted) {
1816
+ // For select: commit on change, close on blur
1817
+ inputEl.addEventListener("change", () => { commit(); cancelled = true; });
1818
+ inputEl.addEventListener("blur", () => { if (!cancelled) { valEl.textContent = existing ? existing.newValue : currentVal; } });
1819
+ } else {
1820
+ inputEl.addEventListener("blur", () => { if (!cancelled) commit(); });
1821
+ }
1822
+ inputEl.addEventListener("keydown", (e) => {
1823
+ if (e.key === "Enter") { commit(); cancelled = true; inputEl.blur(); }
1478
1824
  if (e.key === "Escape") {
1479
1825
  cancelled = true;
1480
- // Remove input first, then restore text (avoids blur→commit race)
1481
- if (input.parentElement) input.remove();
1826
+ if (inputEl.parentElement) inputEl.remove();
1482
1827
  valEl.textContent = existing ? existing.newValue : currentVal;
1483
1828
  if (existing) valEl.classList.add("changed");
1484
1829
  }