@tekyzinc/gsd-t 2.73.14 → 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 +35 -0
- package/bin/design-orchestrator.js +1 -1
- package/bin/orchestrator.js +20 -2
- package/commands/gsd-t-design-build.md +1 -1
- package/package.json +1 -1
- package/scripts/gsd-t-design-review-inject.js +119 -4
- package/scripts/gsd-t-design-review-server.js +174 -0
- package/scripts/gsd-t-design-review.html +364 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [2.73.19] - 2026-04-09
|
|
6
|
+
|
|
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
|
+
|
|
5
40
|
## [2.73.14] - 2026-04-08
|
|
6
41
|
|
|
7
42
|
### 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:
|
|
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}
|
package/bin/orchestrator.js
CHANGED
|
@@ -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:
|
|
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 ||
|
|
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:
|
|
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.
|
|
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
|
-
// ──
|
|
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.
|
|
340
|
-
border: "
|
|
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
|
}
|
|
@@ -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,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,"&").replace(/</g,"<").replace(/>/g,">");
|
|
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
|
|
1418
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
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
|
}
|