@tekyzinc/gsd-t 2.73.11 → 2.73.12

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,13 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [2.73.12] - 2026-04-08
6
+
7
+ ### Added (review UI — isolated component preview + tier tabs)
8
+ - **`/review/preview` endpoint** — mounts a single component in isolation via Vite module resolution. Framework-aware: auto-detects Vue/React/Svelte from package.json. Includes global styles and Vite HMR client. Components now render in the review iframe instead of showing a blank page.
9
+ - **Tier tabs** — Elements | Widgets | Pages tabs in the sidebar filter components by tier. Counts update as items are queued. All tab shows everything.
10
+ - **Framework detection** — review server reads project's package.json to determine mount strategy. Logs detected framework and global styles on startup.
11
+
5
12
  ## [2.73.11] - 2026-04-08
6
13
 
7
14
  ### Changed (reviewer — Playwright-first visual inspection)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "2.73.11",
3
+ "version": "2.73.12",
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",
@@ -26,6 +26,78 @@ const TARGET = getArg("target", "http://localhost:5173");
26
26
  const PROJECT_DIR = getArg("project", process.cwd());
27
27
  const REVIEW_DIR = path.join(PROJECT_DIR, ".gsd-t", "design-review");
28
28
 
29
+ // ── Framework detection ──────────────────────────────────────────────
30
+ function detectFramework() {
31
+ try {
32
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_DIR, "package.json"), "utf8"));
33
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
34
+ if (deps.vue || deps["vue-router"]) return "vue";
35
+ if (deps.react || deps["react-dom"]) return "react";
36
+ if (deps.svelte) return "svelte";
37
+ if (deps["@angular/core"]) return "angular";
38
+ } catch { /* no package.json */ }
39
+ return "vue"; // default
40
+ }
41
+
42
+ function findGlobalStyles() {
43
+ const candidates = [
44
+ "src/assets/main.css", "src/assets/index.css", "src/assets/global.css",
45
+ "src/styles/main.css", "src/styles/index.css", "src/styles/global.css",
46
+ "src/index.css", "src/main.css", "src/app.css",
47
+ ];
48
+ return candidates.filter(f => fs.existsSync(path.join(PROJECT_DIR, f)));
49
+ }
50
+
51
+ const FRAMEWORK = detectFramework();
52
+ const GLOBAL_STYLES = findGlobalStyles();
53
+
54
+ function generatePreviewHtml(componentPath) {
55
+ const linkTags = GLOBAL_STYLES.map(s => ` <link rel="stylesheet" href="/${s}">`).join("\n");
56
+
57
+ let mountScript;
58
+ if (FRAMEWORK === "vue") {
59
+ mountScript = `
60
+ <script type="module">
61
+ import { createApp } from 'vue'
62
+ import Component from '/${componentPath}'
63
+ const app = createApp(Component)
64
+ app.mount('#app')
65
+ </script>`;
66
+ } else if (FRAMEWORK === "react") {
67
+ mountScript = `
68
+ <script type="module">
69
+ import React from 'react'
70
+ import { createRoot } from 'react-dom/client'
71
+ import Component from '/${componentPath}'
72
+ createRoot(document.getElementById('app')).render(React.createElement(Component))
73
+ </script>`;
74
+ } else {
75
+ mountScript = `<script type="module">import '/${componentPath}'</script>`;
76
+ }
77
+
78
+ return `<!DOCTYPE html>
79
+ <html lang="en">
80
+ <head>
81
+ <meta charset="UTF-8">
82
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
83
+ <title>Preview: ${path.basename(componentPath)}</title>
84
+ <script type="module" src="/@vite/client"></script>
85
+ ${linkTags}
86
+ <style>
87
+ * { margin: 0; padding: 0; box-sizing: border-box; }
88
+ body { background: #ffffff; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
89
+ #app { width: 100%; max-width: 800px; }
90
+ .preview-error { color: #ef4444; font-size: 14px; padding: 16px; border: 1px solid #ef4444; border-radius: 8px; }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div id="app"></div>
95
+ ${mountScript}
96
+ <script src="/review/inject.js"></script>
97
+ </body>
98
+ </html>`;
99
+ }
100
+
29
101
  // ── Ensure coordination directory ─────────────────────────────────────
30
102
  function ensureDir(dir) {
31
103
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
@@ -263,6 +335,35 @@ const server = http.createServer((req, res) => {
263
335
  return;
264
336
  }
265
337
 
338
+ // Component preview — mounts a single component in isolation via Vite
339
+ if (pathname === "/review/preview") {
340
+ const component = parsed.query.component;
341
+ if (!component) {
342
+ res.writeHead(400, { "Content-Type": "text/plain" });
343
+ res.end("Missing ?component= parameter");
344
+ return;
345
+ }
346
+ // Proxy this HTML through Vite so module imports resolve correctly
347
+ const html = generatePreviewHtml(component);
348
+ // Send to Vite's HTML transform endpoint via proxy
349
+ const proxyOpts = {
350
+ hostname: targetUrl.hostname,
351
+ port: targetUrl.port,
352
+ path: "/__gsd_preview",
353
+ method: "GET",
354
+ headers: { ...req.headers, host: `${targetUrl.hostname}:${targetUrl.port}` },
355
+ };
356
+ // Vite won't know this path — serve directly but let browser resolve modules from Vite
357
+ const buf = Buffer.from(html, "utf8");
358
+ res.writeHead(200, {
359
+ "Content-Type": "text/html",
360
+ "Content-Length": buf.length,
361
+ "Cache-Control": "no-cache",
362
+ });
363
+ res.end(buf);
364
+ return;
365
+ }
366
+
266
367
  // ── Review API ──────────────────────────────────────────────────
267
368
  if (pathname === "/review/api/status") {
268
369
  res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
@@ -388,5 +489,9 @@ server.listen(PORT, () => {
388
489
  console.log(`${GREEN} ✓${RESET} Review UI: ${CYAN}http://localhost:${PORT}/review${RESET}`);
389
490
  console.log(`${GREEN} ✓${RESET} Proxying: ${DIM}${TARGET} → http://localhost:${PORT}/${RESET}`);
390
491
  console.log(`${GREEN} ✓${RESET} Project: ${DIM}${PROJECT_DIR}${RESET}`);
492
+ console.log(`${GREEN} ✓${RESET} Framework: ${DIM}${FRAMEWORK}${RESET}`);
493
+ if (GLOBAL_STYLES.length > 0) {
494
+ console.log(`${GREEN} ✓${RESET} Styles: ${DIM}${GLOBAL_STYLES.join(", ")}${RESET}`);
495
+ }
391
496
  console.log(`${DIM} Coordination: ${REVIEW_DIR}${RESET}\n`);
392
497
  });
@@ -127,6 +127,39 @@
127
127
  border-bottom: 1px solid var(--border);
128
128
  }
129
129
 
130
+ /* ── Tier tabs ──────────────────────────────────── */
131
+ .tier-tabs {
132
+ display: flex;
133
+ padding: 4px;
134
+ gap: 2px;
135
+ border-bottom: 1px solid var(--border);
136
+ flex-shrink: 0;
137
+ }
138
+
139
+ .tier-tab {
140
+ flex: 1;
141
+ padding: 5px 4px;
142
+ font-size: 11px;
143
+ font-weight: 600;
144
+ text-align: center;
145
+ border: none;
146
+ border-radius: 4px;
147
+ background: transparent;
148
+ color: var(--text-dim);
149
+ cursor: pointer;
150
+ transition: all 0.1s;
151
+ }
152
+
153
+ .tier-tab:hover { color: var(--text-muted); background: var(--bg-hover); }
154
+ .tier-tab.active { color: white; background: var(--accent); }
155
+
156
+ .tier-tab .tier-count {
157
+ font-size: 9px;
158
+ font-weight: 400;
159
+ opacity: 0.7;
160
+ margin-left: 2px;
161
+ }
162
+
130
163
  .component-list {
131
164
  flex: 1;
132
165
  overflow-y: auto;
@@ -687,6 +720,12 @@
687
720
  <!-- Left sidebar: component list -->
688
721
  <div class="sidebar">
689
722
  <div class="sidebar-header">Components</div>
723
+ <div class="tier-tabs" id="tier-tabs">
724
+ <button class="tier-tab active" data-tier="all">All</button>
725
+ <button class="tier-tab" data-tier="element">Elements</button>
726
+ <button class="tier-tab" data-tier="widget">Widgets</button>
727
+ <button class="tier-tab" data-tier="page">Pages</button>
728
+ </div>
690
729
  <div class="component-list" id="component-list">
691
730
  <div class="waiting-overlay" id="waiting-state">
692
731
  <div class="spinner"></div>
@@ -750,7 +789,9 @@
750
789
 
751
790
  // ── State ─────────────────────────────────────────
752
791
  let queue = [];
753
- let selectedIdx = -1;
792
+ let filteredQueue = []; // queue filtered by active tier
793
+ let activeTier = "all";
794
+ let selectedIdx = -1; // index into filteredQueue
754
795
  let inspectActive = false;
755
796
  const changes = new Map(); // componentId → [{path, property, oldValue, newValue}]
756
797
  const comments = new Map(); // componentId → string
@@ -783,6 +824,46 @@
783
824
  let currentTree = null;
784
825
  let selectedTreeKey = null;
785
826
  const propagateBadge = document.getElementById("propagate-badge");
827
+ const tierTabs = document.getElementById("tier-tabs");
828
+
829
+ // ── Tier tab handling ────────────────────────────
830
+ function filterByTier() {
831
+ if (activeTier === "all") {
832
+ filteredQueue = [...queue];
833
+ } else {
834
+ filteredQueue = queue.filter(item => item.type === activeTier);
835
+ }
836
+ }
837
+
838
+ function updateTierCounts() {
839
+ const counts = { all: queue.length, element: 0, widget: 0, page: 0 };
840
+ for (const item of queue) {
841
+ if (counts[item.type] !== undefined) counts[item.type]++;
842
+ }
843
+ tierTabs.querySelectorAll(".tier-tab").forEach(tab => {
844
+ const tier = tab.getAttribute("data-tier");
845
+ const count = counts[tier] || 0;
846
+ const label = tier === "all" ? "All" : tier.charAt(0).toUpperCase() + tier.slice(1) + "s";
847
+ tab.innerHTML = count > 0 ? `${label} <span class="tier-count">${count}</span>` : label;
848
+ // Hide tabs with 0 items (except All)
849
+ if (tier !== "all") {
850
+ tab.style.display = count > 0 ? "" : "none";
851
+ }
852
+ });
853
+ }
854
+
855
+ tierTabs.addEventListener("click", (e) => {
856
+ const tab = e.target.closest(".tier-tab");
857
+ if (!tab) return;
858
+ activeTier = tab.getAttribute("data-tier");
859
+ tierTabs.querySelectorAll(".tier-tab").forEach(t => t.classList.remove("active"));
860
+ tab.classList.add("active");
861
+ selectedIdx = -1;
862
+ filterByTier();
863
+ renderComponentList();
864
+ updateSubmitStats();
865
+ if (filteredQueue.length > 0) selectComponent(0);
866
+ });
786
867
 
787
868
  // ── SSE connection ────────────────────────────────
788
869
  function connectSSE() {
@@ -836,21 +917,15 @@
836
917
  submitBar.style.display = "flex";
837
918
  feedbackPanel.style.display = "block";
838
919
  }
920
+ filterByTier();
921
+ updateTierCounts();
839
922
  renderComponentList();
840
923
  updateSubmitStats();
841
924
 
842
925
  // Auto-select first if none selected
843
- if (selectedIdx < 0 && queue.length > 0) {
926
+ if (selectedIdx < 0 && filteredQueue.length > 0) {
844
927
  selectComponent(0);
845
928
  }
846
-
847
- // Load iframe with the app URL
848
- if (queue.length > 0 && previewIframe.src === "about:blank") {
849
- // Use the first component's route or default to "/"
850
- const route = queue[0].route || "/";
851
- previewIframe.src = route;
852
- previewUrl.textContent = route;
853
- }
854
929
  }
855
930
 
856
931
  function renderComponentList() {
@@ -859,16 +934,16 @@
859
934
  if (ch !== waitingState) ch.remove();
860
935
  });
861
936
 
862
- queue.forEach((item, idx) => {
937
+ filteredQueue.forEach((item, idx) => {
863
938
  const itemChanges = changes.get(item.id) || [];
864
939
  const itemComment = comments.get(item.id) || "";
865
940
  let statusClass = "pending";
866
941
  if (itemChanges.length > 0 && itemComment) {
867
- statusClass = "changed"; // has both changes and comments
942
+ statusClass = "changed";
868
943
  } else if (itemChanges.length > 0) {
869
944
  statusClass = "changed";
870
945
  } else if (itemComment) {
871
- statusClass = "rejected"; // comment only = feedback
946
+ statusClass = "rejected";
872
947
  }
873
948
 
874
949
  const div = document.createElement("div");
@@ -885,53 +960,61 @@
885
960
 
886
961
  function selectComponent(idx) {
887
962
  // Save comment from current element before switching
888
- if (selectedIdx >= 0 && queue[selectedIdx]) {
963
+ if (selectedIdx >= 0 && filteredQueue[selectedIdx]) {
889
964
  const comment = feedbackComment.value.trim();
890
- if (comment) comments.set(queue[selectedIdx].id, comment);
891
- else comments.delete(queue[selectedIdx].id);
965
+ if (comment) comments.set(filteredQueue[selectedIdx].id, comment);
966
+ else comments.delete(filteredQueue[selectedIdx].id);
892
967
  }
893
968
 
894
969
  selectedIdx = idx;
895
970
  renderComponentList();
896
971
 
897
- const item = queue[idx];
972
+ const item = filteredQueue[idx];
898
973
  if (!item) return;
899
974
 
900
975
  // Always show component info in the inspector immediately
901
976
  renderComponentInfo(item);
902
977
 
903
- // Scroll iframe to the component if it has a selector
904
- if (item.selector && previewIframe.contentWindow) {
905
- // Auto-enable inspect mode so the element gets highlighted
978
+ // Load isolated component preview in iframe
979
+ if (item.sourcePath) {
980
+ const previewSrc = `/review/preview?component=${encodeURIComponent(item.sourcePath)}`;
981
+ previewIframe.src = previewSrc;
982
+ previewUrl.textContent = item.sourcePath;
983
+ }
984
+
985
+ // Auto-enable inspect mode and request tree after iframe loads
986
+ const onPreviewLoad = () => {
987
+ previewIframe.removeEventListener("load", onPreviewLoad);
988
+
906
989
  if (!inspectActive) {
907
990
  inspectActive = true;
908
991
  inspectToggle.classList.add("active");
909
992
  previewHint.textContent = "Hover to inspect, click to lock selection";
993
+ }
994
+
995
+ if (previewIframe.contentWindow) {
910
996
  previewIframe.contentWindow.postMessage({ type: "gsdt-activate" }, "*");
911
997
  }
912
- previewIframe.contentWindow.postMessage({
913
- type: "gsdt-scroll-to",
914
- selector: item.selector,
915
- }, "*");
916
998
 
917
- // Request component tree for hierarchical control
999
+ // Request component tree
918
1000
  currentTree = null;
919
1001
  selectedTreeKey = null;
920
1002
  inspectorTree.innerHTML = "";
921
- // Small delay to let scroll-to complete before querying tree
922
1003
  setTimeout(() => {
923
1004
  if (previewIframe.contentWindow) {
1005
+ const selector = item.selector || "#app > *";
1006
+ previewIframe.contentWindow.postMessage({
1007
+ type: "gsdt-scroll-to",
1008
+ selector: selector,
1009
+ }, "*");
924
1010
  previewIframe.contentWindow.postMessage({
925
1011
  type: "gsdt-get-tree",
926
- selector: item.selector,
1012
+ selector: selector,
927
1013
  }, "*");
928
1014
  }
929
- }, 300);
930
- }
931
-
932
- // Save comment from previous element before switching
933
- const prevItem = selectedIdx >= 0 ? queue[selectedIdx] : null;
934
- // (selectedIdx already updated above via renderComponentList, so use prevItem logic below)
1015
+ }, 500);
1016
+ };
1017
+ previewIframe.addEventListener("load", onPreviewLoad);
935
1018
 
936
1019
  // Load existing comment for this element
937
1020
  feedbackComment.value = comments.get(item.id) || "";
@@ -1179,7 +1262,7 @@
1179
1262
  }
1180
1263
 
1181
1264
  // Check if this property was changed
1182
- const compId = queue[selectedIdx]?.id;
1265
+ const compId = filteredQueue[selectedIdx]?.id;
1183
1266
  const compChanges = changes.get(compId) || [];
1184
1267
  const existing = compChanges.find(c => c.path === currentElementPath && c.property === prop);
1185
1268
  if (existing) {
@@ -1327,7 +1410,7 @@
1327
1410
  // ── Property editing ──────────────────────────────
1328
1411
  function startEdit(valEl, prop) {
1329
1412
  const currentVal = valEl.getAttribute("data-original");
1330
- const compChanges = changes.get(queue[selectedIdx]?.id) || [];
1413
+ const compChanges = changes.get(filteredQueue[selectedIdx]?.id) || [];
1331
1414
  const existing = compChanges.find(c => c.path === currentElementPath && c.property === prop);
1332
1415
  const displayVal = existing ? existing.newValue : currentVal;
1333
1416
 
@@ -1361,7 +1444,7 @@
1361
1444
  }, "*");
1362
1445
 
1363
1446
  // Track the change
1364
- const compId = queue[selectedIdx]?.id;
1447
+ const compId = filteredQueue[selectedIdx]?.id;
1365
1448
  if (compId) {
1366
1449
  if (!changes.has(compId)) changes.set(compId, []);
1367
1450
  const list = changes.get(compId);
@@ -1445,7 +1528,7 @@
1445
1528
  // ── Ctrl/Cmd+Z to undo last change ──────────────────
1446
1529
  document.addEventListener("keydown", (e) => {
1447
1530
  if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
1448
- const compId = queue[selectedIdx]?.id;
1531
+ const compId = filteredQueue[selectedIdx]?.id;
1449
1532
  if (!compId) return;
1450
1533
  const list = changes.get(compId);
1451
1534
  if (!list || list.length === 0) return;
@@ -1543,7 +1626,7 @@
1543
1626
 
1544
1627
  // ── Save comment on blur ────────────────────────────
1545
1628
  feedbackComment.addEventListener("blur", () => {
1546
- const item = queue[selectedIdx];
1629
+ const item = filteredQueue[selectedIdx];
1547
1630
  if (!item) return;
1548
1631
  const comment = feedbackComment.value.trim();
1549
1632
  if (comment) comments.set(item.id, comment);
@@ -1575,10 +1658,10 @@
1575
1658
 
1576
1659
  submitAll.addEventListener("click", async () => {
1577
1660
  // Save current element's comment
1578
- if (selectedIdx >= 0 && queue[selectedIdx]) {
1661
+ if (selectedIdx >= 0 && filteredQueue[selectedIdx]) {
1579
1662
  const comment = feedbackComment.value.trim();
1580
- if (comment) comments.set(queue[selectedIdx].id, comment);
1581
- else comments.delete(queue[selectedIdx].id);
1663
+ if (comment) comments.set(filteredQueue[selectedIdx].id, comment);
1664
+ else comments.delete(filteredQueue[selectedIdx].id);
1582
1665
  }
1583
1666
 
1584
1667
  // Check for non-actionable comments (documentation, not change requests)
@@ -1654,7 +1737,7 @@
1654
1737
  });
1655
1738
 
1656
1739
  undoAllChanges.addEventListener("click", () => {
1657
- const compId = queue[selectedIdx]?.id;
1740
+ const compId = filteredQueue[selectedIdx]?.id;
1658
1741
  if (compId) {
1659
1742
  changes.delete(compId);
1660
1743
  renderChanges(compId);