@tekyzinc/gsd-t 2.73.14 → 2.73.20

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,6 +2,51 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [2.73.20] - 2026-04-09
6
+
7
+ ### Added
8
+ - **Editable fixture data** — segment label, value, and color fields in the Data Props tree are now clickable to edit. Color fields use a color picker. Changes tracked alongside CSS/SVG changes in the review output.
9
+ - **Better SVG tree labels** — circle/arc nodes show stroke color, width, and radius. Path nodes show fill/stroke color. SVG root shows viewBox.
10
+ - **Deeper SVG tree traversal** — SVG subtrees traverse up to depth 8 (was 4), ensuring individual arc segments appear in the element tree.
11
+
12
+ ### Removed
13
+ - **`percentages_shown`** from donut chart fixture — redundant with `segments[].value`.
14
+
15
+ ## [2.73.19] - 2026-04-09
16
+
17
+ ### Added
18
+ - **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).
19
+ - **SVG permitted value dropdowns** — `stroke-linecap`, `stroke-linejoin`, `text-anchor`, `dominant-baseline` show `<select>` dropdowns with valid SVG values.
20
+
21
+ ### Fixed
22
+ - **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.
23
+
24
+ ## [2.73.18] - 2026-04-09
25
+
26
+ ### Added
27
+ - **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.
28
+ - **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.
29
+ - **More editable properties** — added `overflow`, `position`, `top`, `left`, `boxShadow`, `fontFamily` to the editable set.
30
+
31
+ ## [2.73.17] - 2026-04-08
32
+
33
+ ### Added
34
+ - **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.
35
+
36
+ ## [2.73.16] - 2026-04-08
37
+
38
+ ### Added
39
+ - **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.
40
+ - **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.
41
+
42
+ ## [2.73.15] - 2026-04-08
43
+
44
+ ### Added
45
+ - **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.
46
+
47
+ ### Changed
48
+ - **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.
49
+
5
50
  ## [2.73.14] - 2026-04-08
6
51
 
7
52
  ### Fixed (review UI — component preview rendering)
@@ -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.14",
3
+ "version": "2.73.20",
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;
@@ -801,7 +898,9 @@
801
898
  let keyCounter = 0;
802
899
 
803
900
  function buildTree(el, depth) {
804
- if (depth > 4) return null; // limit depth
901
+ // SVG subtrees get deeper traversal (arcs are nested)
902
+ const isSvgSubtree = el instanceof SVGElement || el.closest("svg");
903
+ if (depth > (isSvgSubtree ? 8 : 4)) return null;
805
904
  const tag = el.tagName.toLowerCase();
806
905
  const s = getComputedStyle(el);
807
906
  const rect = el.getBoundingClientRect();
@@ -836,11 +935,20 @@
836
935
  label = "Col " + colIdx;
837
936
  if (text && text.length < 15) label += ' "' + text + '"';
838
937
  } else if (tag === "svg") {
839
- label = "svg";
938
+ const vb = el.getAttribute("viewBox");
939
+ label = "svg" + (vb ? ` [${vb}]` : "");
840
940
  } else if (tag === "circle") {
841
- label = "arc/circle";
941
+ const stroke = el.getAttribute("stroke") || "";
942
+ const fill = el.getAttribute("fill") || "";
943
+ const color = stroke && stroke !== "none" ? stroke : fill && fill !== "none" ? fill : "";
944
+ const r = el.getAttribute("r") || "";
945
+ const sw = el.getAttribute("stroke-width") || "";
946
+ label = "arc" + (color ? " " + color : "") + (sw ? " w:" + sw : "") + (r ? " r:" + r : "");
842
947
  } else if (tag === "path") {
843
- label = "path";
948
+ const stroke = el.getAttribute("stroke") || "";
949
+ const fill = el.getAttribute("fill") || "";
950
+ const color = stroke && stroke !== "none" ? stroke : fill && fill !== "none" ? fill : "";
951
+ label = "path" + (color ? " " + color : "");
844
952
  } else if (s.display === "flex" || s.display === "inline-flex") {
845
953
  label = "flex " + (s.flexDirection === "column" ? "col" : "row");
846
954
  } else if (s.display === "grid") {
@@ -876,10 +984,16 @@
876
984
  node.props.fontWeight = s.fontWeight;
877
985
  if (tag === "th") node.props.background = s.backgroundColor;
878
986
  }
879
- if (tag === "circle") {
987
+ if (tag === "circle" || tag === "ellipse") {
880
988
  node.props.stroke = el.getAttribute("stroke");
881
989
  node.props.strokeWidth = el.getAttribute("stroke-width");
882
990
  node.props.r = el.getAttribute("r");
991
+ node.props.fill = el.getAttribute("fill");
992
+ node.props.strokeDasharray = el.getAttribute("stroke-dasharray");
993
+ } else if (tag === "path" || tag === "line" || tag === "rect") {
994
+ node.props.stroke = el.getAttribute("stroke");
995
+ node.props.strokeWidth = el.getAttribute("stroke-width");
996
+ node.props.fill = el.getAttribute("fill");
883
997
  }
884
998
  if (s.display === "flex" || s.display === "inline-flex") {
885
999
  node.props.gap = s.gap;
@@ -926,6 +1040,7 @@
926
1040
  path: getElementPath(el),
927
1041
  styles: extractStyles(el),
928
1042
  boxModel: extractBoxModel(el),
1043
+ svgAttrs: extractSvgAttrs(el),
929
1044
  tagName: el.tagName.toLowerCase(),
930
1045
  className: typeof el.className === "string" ? el.className : "",
931
1046
  textContent: (el.textContent || "").trim().substring(0, 100),
@@ -933,6 +1048,23 @@
933
1048
  }, "*");
934
1049
  }
935
1050
  break;
1051
+
1052
+ case "gsdt-set-svg-attr":
1053
+ // Apply an SVG attribute change to the locked element
1054
+ if (lockedEl && msg.attribute && msg.value !== undefined) {
1055
+ lockedEl.setAttribute(msg.attribute, msg.value);
1056
+ positionOverlay(lockedEl);
1057
+ window.parent.postMessage({
1058
+ type: "gsdt-style-updated",
1059
+ path: getElementPath(lockedEl),
1060
+ property: "svg:" + msg.attribute,
1061
+ value: msg.value,
1062
+ styles: extractStyles(lockedEl),
1063
+ boxModel: extractBoxModel(lockedEl),
1064
+ svgAttrs: extractSvgAttrs(lockedEl),
1065
+ }, "*");
1066
+ }
1067
+ break;
936
1068
  }
937
1069
  });
938
1070
 
@@ -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
  }
@@ -123,6 +140,112 @@ ${linkTags}
123
140
  </html>`;
124
141
  }
125
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
+
126
249
  // ── Ensure coordination directory ─────────────────────────────────────
127
250
  function ensureDir(dir) {
128
251
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
@@ -360,6 +483,49 @@ const server = http.createServer((req, res) => {
360
483
  return;
361
484
  }
362
485
 
486
+ // Gallery — all 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
+
363
529
  // Component preview — writes temp HTML to project, proxies through Vite for module resolution
364
530
  if (pathname === "/review/preview") {
365
531
  const component = parsed.query.component;
@@ -422,6 +588,14 @@ const server = http.createServer((req, res) => {
422
588
  return;
423
589
  }
424
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
+
425
599
  if (pathname === "/review/api/feedback" && req.method === "GET") {
426
600
  res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
427
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,128 @@
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, propPath) {
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
+ const itemPath = propPath + "." + key + "[" + i + "]";
1097
+ if (typeof item === "object" && item !== null) {
1098
+ const itemRow = document.createElement("div");
1099
+ itemRow.style.paddingLeft = ((depth + 1) * 12) + "px";
1100
+ itemRow.style.cssText += "font-size:10px;color:var(--text-dim);margin:2px 0;";
1101
+ itemRow.textContent = `[${i}]`;
1102
+ items.appendChild(itemRow);
1103
+ for (const [k, v] of Object.entries(item)) {
1104
+ renderValue(k, v, items, depth + 2, itemPath);
1105
+ }
1106
+ } else {
1107
+ const itemRow = document.createElement("div");
1108
+ itemRow.className = "prop-row";
1109
+ itemRow.style.paddingLeft = ((depth + 1) * 12) + "px";
1110
+ itemRow.innerHTML = `<span class="prop-name">[${i}]</span><span class="prop-value">${escapeHtml(String(item))}</span>`;
1111
+ items.appendChild(itemRow);
1112
+ }
1113
+ });
1114
+ parent.appendChild(items);
1115
+ } else if (typeof val === "object" && val !== null) {
1116
+ const row = document.createElement("div");
1117
+ row.className = "prop-group-header";
1118
+ row.style.paddingLeft = (depth * 12) + "px";
1119
+ row.style.fontSize = "11px";
1120
+ row.innerHTML = `<span style="color:#93c5fd">▸ ${key}</span>`;
1121
+ let expanded = depth < 1;
1122
+ const items = document.createElement("div");
1123
+ items.style.display = expanded ? "" : "none";
1124
+ row.addEventListener("click", () => { expanded = !expanded; items.style.display = expanded ? "" : "none"; row.querySelector("span").textContent = (expanded ? "▾ " : "▸ ") + key; });
1125
+ if (expanded) row.querySelector("span").textContent = "▾ " + key;
1126
+ parent.appendChild(row);
1127
+ for (const [k, v] of Object.entries(val)) {
1128
+ renderValue(k, v, items, depth + 1, propPath + "." + key);
1129
+ }
1130
+ parent.appendChild(items);
1131
+ } else {
1132
+ const row = document.createElement("div");
1133
+ row.className = "prop-row";
1134
+ row.style.paddingLeft = (depth * 12) + "px";
1135
+ const isColor = typeof val === "string" && val.match(/^#[0-9a-fA-F]{3,8}$/);
1136
+ const displayVal = isColor
1137
+ ? `<span class="color-swatch" style="background:${val}"></span>${val}`
1138
+ : escapeHtml(String(val));
1139
+
1140
+ const nameEl = document.createElement("span");
1141
+ nameEl.className = "prop-name";
1142
+ nameEl.textContent = key;
1143
+ row.appendChild(nameEl);
1144
+
1145
+ const valEl = document.createElement("span");
1146
+ valEl.className = "prop-value editable";
1147
+ valEl.setAttribute("data-prop", "fixture:" + propPath + "." + key);
1148
+ valEl.setAttribute("data-original", String(val));
1149
+ valEl.innerHTML = displayVal;
1150
+
1151
+ valEl.addEventListener("click", () => startFixtureEdit(valEl, propPath + "." + key, String(val), isColor));
1152
+ row.appendChild(valEl);
1153
+ parent.appendChild(row);
1154
+ }
1155
+ }
1156
+
1157
+ for (const [k, v] of Object.entries(fixture)) {
1158
+ renderValue(k, v, body, 0, "");
1159
+ }
1160
+ container.appendChild(body);
1161
+
1162
+ // Toggle data props section
1163
+ let dataExpanded = true;
1164
+ container.querySelector(".prop-group-header").addEventListener("click", () => {
1165
+ dataExpanded = !dataExpanded;
1166
+ body.style.display = dataExpanded ? "" : "none";
1167
+ container.querySelector(".prop-group-header").textContent = (dataExpanded ? "▾" : "▸") + " Data Props";
1168
+ });
1169
+ container.querySelector(".prop-group-header").textContent = "▾ Data Props";
1170
+
1171
+ inspectorInfo.appendChild(container);
1172
+ }
1173
+
1174
+ function escapeHtml(str) {
1175
+ return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
1176
+ }
1177
+
1178
+ // ── Gallery mode ──────────────────────────────────
1179
+ const galleryToggle = document.getElementById("gallery-toggle");
1180
+ let galleryActive = false;
1181
+
1182
+ galleryToggle.addEventListener("click", () => {
1183
+ galleryActive = !galleryActive;
1184
+ galleryToggle.classList.toggle("active", galleryActive);
1185
+ if (galleryActive) {
1186
+ previewIframe.src = "/review/gallery?cols=3";
1187
+ previewUrl.textContent = "Gallery — all components";
1188
+ } else if (selectedIdx >= 0 && filteredQueue[selectedIdx]) {
1189
+ const item = filteredQueue[selectedIdx];
1190
+ if (item.sourcePath) {
1191
+ previewIframe.src = `/review/preview?component=${encodeURIComponent(item.sourcePath)}`;
1192
+ previewUrl.textContent = item.sourcePath;
1193
+ }
1194
+ }
1195
+ });
1196
+
1057
1197
  // ── Inspect mode ──────────────────────────────────
1058
1198
  inspectToggle.addEventListener("click", () => {
1059
1199
  inspectActive = !inspectActive;
@@ -1105,6 +1245,7 @@
1105
1245
 
1106
1246
  case "gsdt-style-updated":
1107
1247
  currentStyles = msg.styles;
1248
+ if (msg.svgAttrs) currentSvgAttrs = msg.svgAttrs;
1108
1249
  renderPropertyValues(msg.styles);
1109
1250
  // Show propagation feedback with scope
1110
1251
  if (msg.propagated > 0) {
@@ -1130,9 +1271,12 @@
1130
1271
  });
1131
1272
 
1132
1273
  // ── Inspector rendering ───────────────────────────
1274
+ let currentSvgAttrs = null;
1275
+
1133
1276
  function renderInspector(msg) {
1134
1277
  const styles = msg.styles;
1135
1278
  const boxModel = msg.boxModel;
1279
+ currentSvgAttrs = msg.svgAttrs || null;
1136
1280
 
1137
1281
  // Only update props zone — preserve tree
1138
1282
  inspectorProps.innerHTML = "";
@@ -1186,8 +1330,9 @@
1186
1330
  "width", "height",
1187
1331
  "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
1188
1332
  "marginTop", "marginRight", "marginBottom", "marginLeft",
1189
- "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textAlign",
1333
+ "fontSize", "fontWeight", "fontFamily", "lineHeight", "letterSpacing", "textAlign",
1190
1334
  "backgroundColor", "color", "borderRadius", "border", "opacity",
1335
+ "overflow", "position", "top", "left", "boxShadow",
1191
1336
  ]);
1192
1337
 
1193
1338
  for (const [groupName, props] of Object.entries(groups)) {
@@ -1283,6 +1428,267 @@
1283
1428
  inspectorProps.appendChild(group);
1284
1429
  }
1285
1430
  }
1431
+
1432
+ // SVG Attributes group (if element is SVG)
1433
+ if (currentSvgAttrs) {
1434
+ const svgGroup = document.createElement("div");
1435
+ svgGroup.className = "prop-group";
1436
+
1437
+ const svgHeader = document.createElement("div");
1438
+ svgHeader.className = "prop-group-header";
1439
+ svgHeader.innerHTML = `<span>▾</span> SVG Attributes`;
1440
+ svgHeader.style.color = "#06b6d4";
1441
+ svgGroup.appendChild(svgHeader);
1442
+
1443
+ const svgRows = document.createElement("div");
1444
+ svgRows.style.display = "block";
1445
+
1446
+ svgHeader.addEventListener("click", () => {
1447
+ const visible = svgRows.style.display !== "none";
1448
+ svgRows.style.display = visible ? "none" : "block";
1449
+ svgHeader.innerHTML = `<span>${visible ? "▸" : "▾"}</span> SVG Attributes`;
1450
+ });
1451
+
1452
+ const svgColorAttrs = new Set(["stroke", "fill"]);
1453
+ const svgNumericAttrs = new Set(["stroke-width", "r", "rx", "ry", "cx", "cy", "x", "y",
1454
+ "x1", "y1", "x2", "y2", "width", "height", "opacity", "stroke-dashoffset"]);
1455
+
1456
+ for (const [attr, val] of Object.entries(currentSvgAttrs)) {
1457
+ const row = document.createElement("div");
1458
+ row.className = "prop-row";
1459
+ row.setAttribute("data-prop", "svg:" + attr);
1460
+
1461
+ const nameEl = document.createElement("span");
1462
+ nameEl.className = "prop-name";
1463
+ nameEl.textContent = attr;
1464
+ nameEl.title = `Click to highlight ${attr}`;
1465
+ nameEl.style.cursor = "pointer";
1466
+ nameEl.addEventListener("click", () => {
1467
+ if (previewIframe.contentWindow) {
1468
+ previewIframe.contentWindow.postMessage({
1469
+ type: "gsdt-highlight-zone",
1470
+ property: "svg:" + attr,
1471
+ }, "*");
1472
+ }
1473
+ });
1474
+ row.appendChild(nameEl);
1475
+
1476
+ const valEl = document.createElement("span");
1477
+ valEl.className = "prop-value editable";
1478
+ valEl.setAttribute("data-prop", "svg:" + attr);
1479
+ valEl.setAttribute("data-original", val);
1480
+
1481
+ if (svgColorAttrs.has(attr) && val && val !== "none") {
1482
+ valEl.innerHTML = `<span class="color-swatch" style="background:${val}"></span>${val}`;
1483
+ } else {
1484
+ valEl.textContent = val;
1485
+ }
1486
+
1487
+ // Check if changed
1488
+ const compId = filteredQueue[selectedIdx]?.id;
1489
+ const compChanges = changes.get(compId) || [];
1490
+ const existing = compChanges.find(c => c.path === currentElementPath && c.property === "svg:" + attr);
1491
+ if (existing) {
1492
+ valEl.classList.add("changed");
1493
+ valEl.textContent = existing.newValue;
1494
+ }
1495
+
1496
+ valEl.addEventListener("click", () => startSvgEdit(valEl, attr));
1497
+ row.appendChild(valEl);
1498
+ svgRows.appendChild(row);
1499
+ }
1500
+
1501
+ svgGroup.appendChild(svgRows);
1502
+ if (svgRows.children.length > 0) {
1503
+ inspectorProps.appendChild(svgGroup);
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ function startSvgEdit(valEl, attr) {
1509
+ if (valEl.querySelector("select, input")) return;
1510
+
1511
+ const currentVal = valEl.getAttribute("data-original");
1512
+ const compChanges = changes.get(filteredQueue[selectedIdx]?.id) || [];
1513
+ const existing = compChanges.find(c => c.path === currentElementPath && c.property === "svg:" + attr);
1514
+ const displayVal = existing ? existing.newValue : currentVal;
1515
+
1516
+ const permitted = PERMITTED_VALUES[attr];
1517
+ let inputEl;
1518
+
1519
+ if (permitted) {
1520
+ inputEl = document.createElement("select");
1521
+ inputEl.className = "prop-edit-input";
1522
+ for (const opt of permitted) {
1523
+ const option = document.createElement("option");
1524
+ option.value = opt;
1525
+ option.textContent = opt;
1526
+ if (opt === displayVal) option.selected = true;
1527
+ inputEl.appendChild(option);
1528
+ }
1529
+ } else {
1530
+ inputEl = document.createElement("input");
1531
+ inputEl.className = "prop-edit-input";
1532
+ inputEl.type = "text";
1533
+ inputEl.value = displayVal;
1534
+ }
1535
+
1536
+ valEl.innerHTML = "";
1537
+ valEl.appendChild(inputEl);
1538
+ inputEl.focus();
1539
+ if (!permitted) {
1540
+ const numMatch = displayVal.match(/^(-?[\d.]+)/);
1541
+ if (numMatch) {
1542
+ inputEl.setSelectionRange(0, numMatch[1].length);
1543
+ } else {
1544
+ inputEl.select();
1545
+ }
1546
+ }
1547
+
1548
+ let cancelled = false;
1549
+ function commit() {
1550
+ if (cancelled) return;
1551
+ const newVal = inputEl.value.trim();
1552
+ if (newVal && newVal !== currentVal) {
1553
+ previewIframe.contentWindow.postMessage({
1554
+ type: "gsdt-set-svg-attr",
1555
+ attribute: attr,
1556
+ value: newVal,
1557
+ }, "*");
1558
+
1559
+ const compId = filteredQueue[selectedIdx]?.id;
1560
+ if (compId) {
1561
+ if (!changes.has(compId)) changes.set(compId, []);
1562
+ const list = changes.get(compId);
1563
+ const existingIdx = list.findIndex(c => c.path === currentElementPath && c.property === "svg:" + attr);
1564
+ const change = {
1565
+ path: currentElementPath,
1566
+ property: "svg:" + attr,
1567
+ oldValue: currentVal,
1568
+ newValue: newVal,
1569
+ };
1570
+ if (existingIdx >= 0) {
1571
+ list[existingIdx] = change;
1572
+ } else {
1573
+ list.push(change);
1574
+ }
1575
+ renderChanges(compId);
1576
+ renderComponentList();
1577
+ }
1578
+
1579
+ valEl.textContent = newVal;
1580
+ valEl.classList.add("changed");
1581
+ } else {
1582
+ valEl.textContent = existing ? existing.newValue : currentVal;
1583
+ if (existing) valEl.classList.add("changed");
1584
+ }
1585
+ }
1586
+
1587
+ if (permitted) {
1588
+ inputEl.addEventListener("change", () => { commit(); cancelled = true; });
1589
+ inputEl.addEventListener("blur", () => { if (!cancelled) { valEl.textContent = existing ? existing.newValue : currentVal; } });
1590
+ } else {
1591
+ inputEl.addEventListener("blur", () => { if (!cancelled) commit(); });
1592
+ }
1593
+ inputEl.addEventListener("keydown", (e) => {
1594
+ if (e.key === "Enter") { commit(); cancelled = true; inputEl.blur(); }
1595
+ if (e.key === "Escape") {
1596
+ cancelled = true;
1597
+ if (inputEl.parentElement) inputEl.remove();
1598
+ valEl.textContent = existing ? existing.newValue : currentVal;
1599
+ if (existing) valEl.classList.add("changed");
1600
+ }
1601
+ });
1602
+ }
1603
+
1604
+ function startFixtureEdit(valEl, fixturePath, currentVal, isColor) {
1605
+ if (valEl.querySelector("select, input")) return;
1606
+
1607
+ const compId = filteredQueue[selectedIdx]?.id;
1608
+ const compChanges = changes.get(compId) || [];
1609
+ const prop = "fixture:" + fixturePath;
1610
+ const existing = compChanges.find(c => c.property === prop);
1611
+ const displayVal = existing ? existing.newValue : currentVal;
1612
+
1613
+ const inputEl = document.createElement("input");
1614
+ inputEl.className = "prop-edit-input";
1615
+ inputEl.type = isColor ? "color" : "text";
1616
+ inputEl.value = displayVal;
1617
+
1618
+ valEl.innerHTML = "";
1619
+ valEl.appendChild(inputEl);
1620
+ inputEl.focus();
1621
+ if (!isColor) {
1622
+ const numMatch = displayVal.match(/^(-?[\d.]+)/);
1623
+ if (numMatch) {
1624
+ inputEl.setSelectionRange(0, numMatch[1].length);
1625
+ } else {
1626
+ inputEl.select();
1627
+ }
1628
+ }
1629
+
1630
+ let cancelled = false;
1631
+ function commit() {
1632
+ if (cancelled) return;
1633
+ const newVal = inputEl.value.trim();
1634
+ if (newVal && newVal !== currentVal) {
1635
+ // Track as a fixture data change
1636
+ if (compId) {
1637
+ if (!changes.has(compId)) changes.set(compId, []);
1638
+ const list = changes.get(compId);
1639
+ const existingIdx = list.findIndex(c => c.property === prop);
1640
+ const change = {
1641
+ path: "(fixture data)",
1642
+ property: prop,
1643
+ oldValue: currentVal,
1644
+ newValue: newVal,
1645
+ };
1646
+ if (existingIdx >= 0) {
1647
+ list[existingIdx] = change;
1648
+ } else {
1649
+ list.push(change);
1650
+ }
1651
+ renderChanges(compId);
1652
+ renderComponentList();
1653
+ }
1654
+
1655
+ if (isColor) {
1656
+ valEl.innerHTML = `<span class="color-swatch" style="background:${newVal}"></span>${newVal}`;
1657
+ } else {
1658
+ valEl.textContent = newVal;
1659
+ }
1660
+ valEl.classList.add("changed");
1661
+ } else {
1662
+ if (isColor && (existing ? existing.newValue : currentVal).match(/^#/)) {
1663
+ const v = existing ? existing.newValue : currentVal;
1664
+ valEl.innerHTML = `<span class="color-swatch" style="background:${v}"></span>${v}`;
1665
+ } else {
1666
+ valEl.textContent = existing ? existing.newValue : currentVal;
1667
+ }
1668
+ if (existing) valEl.classList.add("changed");
1669
+ }
1670
+ }
1671
+
1672
+ if (isColor) {
1673
+ inputEl.addEventListener("change", () => { commit(); cancelled = true; });
1674
+ inputEl.addEventListener("blur", () => { if (!cancelled) { valEl.innerHTML = `<span class="color-swatch" style="background:${currentVal}"></span>${currentVal}`; } });
1675
+ } else {
1676
+ inputEl.addEventListener("blur", () => { if (!cancelled) commit(); });
1677
+ }
1678
+ inputEl.addEventListener("keydown", (e) => {
1679
+ if (e.key === "Enter") { commit(); cancelled = true; inputEl.blur(); }
1680
+ if (e.key === "Escape") {
1681
+ cancelled = true;
1682
+ if (inputEl.parentElement) inputEl.remove();
1683
+ if (isColor) {
1684
+ const v = existing ? existing.newValue : currentVal;
1685
+ valEl.innerHTML = `<span class="color-swatch" style="background:${v}"></span>${v}`;
1686
+ } else {
1687
+ valEl.textContent = existing ? existing.newValue : currentVal;
1688
+ }
1689
+ if (existing) valEl.classList.add("changed");
1690
+ }
1691
+ });
1286
1692
  }
1287
1693
 
1288
1694
  function renderPropertyValues(styles) {
@@ -1408,32 +1814,71 @@
1408
1814
  }
1409
1815
 
1410
1816
  // ── Property editing ──────────────────────────────
1817
+ // Permitted values for enum-style CSS properties
1818
+ const PERMITTED_VALUES = {
1819
+ display: ["block", "flex", "inline-flex", "grid", "inline-grid", "inline", "inline-block", "none", "contents"],
1820
+ flexDirection: ["row", "row-reverse", "column", "column-reverse"],
1821
+ alignItems: ["stretch", "flex-start", "flex-end", "center", "baseline"],
1822
+ justifyContent: ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"],
1823
+ textAlign: ["left", "center", "right", "justify"],
1824
+ fontWeight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
1825
+ overflow: ["visible", "hidden", "scroll", "auto", "clip"],
1826
+ position: ["static", "relative", "absolute", "fixed", "sticky"],
1827
+ // SVG attribute permitted values (used by startSvgEdit)
1828
+ "stroke-linecap": ["butt", "round", "square"],
1829
+ "stroke-linejoin": ["miter", "round", "bevel"],
1830
+ "text-anchor": ["start", "middle", "end"],
1831
+ "dominant-baseline": ["auto", "middle", "hanging", "central", "text-top", "text-bottom"],
1832
+ };
1833
+
1411
1834
  function startEdit(valEl, prop) {
1835
+ // Prevent re-entry: if already editing (select/input inside), do nothing
1836
+ if (valEl.querySelector("select, input")) return;
1837
+
1412
1838
  const currentVal = valEl.getAttribute("data-original");
1413
1839
  const compChanges = changes.get(filteredQueue[selectedIdx]?.id) || [];
1414
1840
  const existing = compChanges.find(c => c.path === currentElementPath && c.property === prop);
1415
1841
  const displayVal = existing ? existing.newValue : currentVal;
1416
1842
 
1417
- const input = document.createElement("input");
1418
- input.className = "prop-edit-input";
1419
- input.type = "text";
1420
- input.value = displayVal;
1843
+ const permitted = PERMITTED_VALUES[prop];
1844
+ let inputEl;
1421
1845
  let cancelled = false;
1422
1846
 
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);
1847
+ if (permitted) {
1848
+ // Dropdown for enum properties
1849
+ inputEl = document.createElement("select");
1850
+ inputEl.className = "prop-edit-input";
1851
+ for (const opt of permitted) {
1852
+ const option = document.createElement("option");
1853
+ option.value = opt;
1854
+ option.textContent = opt;
1855
+ if (opt === displayVal) option.selected = true;
1856
+ inputEl.appendChild(option);
1857
+ }
1430
1858
  } else {
1431
- input.select();
1859
+ // Text input for free-form values
1860
+ inputEl = document.createElement("input");
1861
+ inputEl.className = "prop-edit-input";
1862
+ inputEl.type = "text";
1863
+ inputEl.value = displayVal;
1864
+ }
1865
+
1866
+ valEl.innerHTML = "";
1867
+ valEl.appendChild(inputEl);
1868
+ inputEl.focus();
1869
+ if (!permitted) {
1870
+ // Select just the numeric part for values like "24px", "1.5em", "600"
1871
+ const numMatch = displayVal.match(/^(-?[\d.]+)/);
1872
+ if (numMatch) {
1873
+ inputEl.setSelectionRange(0, numMatch[1].length);
1874
+ } else {
1875
+ inputEl.select();
1876
+ }
1432
1877
  }
1433
1878
 
1434
1879
  function commit() {
1435
1880
  if (cancelled) return;
1436
- const newVal = input.value.trim();
1881
+ const newVal = inputEl.value.trim();
1437
1882
  if (newVal && newVal !== currentVal) {
1438
1883
  // Apply the style change to the iframe
1439
1884
  previewIframe.contentWindow.postMessage({
@@ -1472,13 +1917,18 @@
1472
1917
  }
1473
1918
  }
1474
1919
 
1475
- input.addEventListener("blur", () => { if (!cancelled) commit(); });
1476
- input.addEventListener("keydown", (e) => {
1477
- if (e.key === "Enter") { commit(); cancelled = true; input.blur(); }
1920
+ if (permitted) {
1921
+ // For select: commit on change, close on blur
1922
+ inputEl.addEventListener("change", () => { commit(); cancelled = true; });
1923
+ inputEl.addEventListener("blur", () => { if (!cancelled) { valEl.textContent = existing ? existing.newValue : currentVal; } });
1924
+ } else {
1925
+ inputEl.addEventListener("blur", () => { if (!cancelled) commit(); });
1926
+ }
1927
+ inputEl.addEventListener("keydown", (e) => {
1928
+ if (e.key === "Enter") { commit(); cancelled = true; inputEl.blur(); }
1478
1929
  if (e.key === "Escape") {
1479
1930
  cancelled = true;
1480
- // Remove input first, then restore text (avoids blur→commit race)
1481
- if (input.parentElement) input.remove();
1931
+ if (inputEl.parentElement) inputEl.remove();
1482
1932
  valEl.textContent = existing ? existing.newValue : currentVal;
1483
1933
  if (existing) valEl.classList.add("changed");
1484
1934
  }