@kirrosh/zond 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +110 -3
  2. package/README.md +26 -15
  3. package/package.json +10 -6
  4. package/src/cli/commands/catalog.ts +62 -0
  5. package/src/cli/commands/ci-init.ts +12 -6
  6. package/src/cli/commands/completions.ts +176 -0
  7. package/src/cli/commands/db.ts +2 -1
  8. package/src/cli/commands/generate.ts +18 -2
  9. package/src/cli/commands/init/agents-md.ts +61 -0
  10. package/src/cli/commands/init/bootstrap.ts +79 -0
  11. package/src/cli/commands/init/skills.ts +45 -0
  12. package/src/cli/commands/init/templates/agents.md +73 -0
  13. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  14. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  15. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  16. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  17. package/src/cli/commands/init.ts +124 -31
  18. package/src/cli/commands/probe-methods.ts +108 -0
  19. package/src/cli/commands/probe-validation.ts +124 -0
  20. package/src/cli/commands/run.ts +99 -10
  21. package/src/cli/commands/serve.ts +52 -19
  22. package/src/cli/commands/sync.ts +28 -1
  23. package/src/cli/commands/update.ts +1 -1
  24. package/src/cli/commands/use.ts +57 -0
  25. package/src/cli/index.ts +21 -591
  26. package/src/cli/program.ts +655 -0
  27. package/src/cli/version.ts +3 -0
  28. package/src/core/context/current.ts +35 -0
  29. package/src/core/diagnostics/db-analysis.ts +11 -2
  30. package/src/core/diagnostics/render-md.ts +112 -0
  31. package/src/core/generator/catalog-builder.ts +179 -0
  32. package/src/core/generator/chunker.ts +14 -2
  33. package/src/core/generator/data-factory.ts +50 -19
  34. package/src/core/generator/guide-builder.ts +1 -1
  35. package/src/core/generator/index.ts +2 -0
  36. package/src/core/generator/openapi-reader.ts +18 -0
  37. package/src/core/generator/serializer.ts +11 -2
  38. package/src/core/generator/suite-generator.ts +106 -7
  39. package/src/core/meta/types.ts +0 -2
  40. package/src/core/parser/schema.ts +3 -1
  41. package/src/core/parser/types.ts +10 -1
  42. package/src/core/parser/variables.ts +90 -2
  43. package/src/core/parser/yaml-parser.ts +50 -1
  44. package/src/core/probe/method-probe.ts +197 -0
  45. package/src/core/probe/negative-probe.ts +657 -0
  46. package/src/core/reporter/console.ts +29 -3
  47. package/src/core/reporter/index.ts +2 -2
  48. package/src/core/reporter/json.ts +5 -2
  49. package/src/core/runner/assertions.ts +4 -1
  50. package/src/core/runner/executor.ts +132 -37
  51. package/src/core/runner/http-client.ts +40 -5
  52. package/src/core/runner/rate-limiter.ts +131 -0
  53. package/src/core/setup-api.ts +4 -1
  54. package/src/core/workspace/root.ts +94 -0
  55. package/src/db/schema.ts +4 -1
  56. package/src/web/routes/api.ts +80 -0
  57. package/src/web/routes/dashboard.ts +15 -0
  58. package/src/web/static/style.css +290 -0
  59. package/src/web/views/explorer-tab.ts +402 -0
@@ -0,0 +1,94 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /**
6
+ * Files / directories that mark a workspace root. Order matters — earlier
7
+ * markers win when more than one is present in the same directory.
8
+ *
9
+ * zond.config.yml — explicit project config (T12)
10
+ * .zond/ — `zond init --here` subdir convention (T19)
11
+ * zond.db — flat layout from `zond init`
12
+ * apis/ — flat layout (collections directory)
13
+ */
14
+ export const WORKSPACE_MARKERS = ["zond.config.yml", ".zond", "zond.db", "apis"] as const;
15
+ export type WorkspaceMarker = (typeof WORKSPACE_MARKERS)[number];
16
+
17
+ export interface WorkspaceInfo {
18
+ /** Absolute path to the workspace root. */
19
+ root: string;
20
+ /** Marker that triggered detection, or "" when fallback (cwd) was used. */
21
+ marker: WorkspaceMarker | "";
22
+ /** True when no marker was found and we fell back to `cwd`. */
23
+ fromFallback: boolean;
24
+ }
25
+
26
+ let warned = false;
27
+
28
+ function hasMarker(dir: string): WorkspaceMarker | null {
29
+ for (const m of WORKSPACE_MARKERS) {
30
+ const p = join(dir, m);
31
+ if (!existsSync(p)) continue;
32
+ // .zond and apis must be directories; zond.config.yml and zond.db must be files
33
+ try {
34
+ const st = statSync(p);
35
+ if (m === ".zond" || m === "apis") {
36
+ if (st.isDirectory()) return m;
37
+ } else if (st.isFile()) {
38
+ return m;
39
+ }
40
+ } catch {
41
+ /* race / permissions — treat as no marker */
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Walk-up from `cwd` (default `process.cwd()`) to the nearest workspace
49
+ * marker. The walk stops at `os.homedir()` to avoid accidentally picking up
50
+ * `~/apis` or `~/zond.db` when the user runs zond from somewhere unrelated.
51
+ *
52
+ * When no marker is found, returns `{ root: cwd, fromFallback: true }` and
53
+ * prints a one-time stderr warning so the user knows zond is operating in
54
+ * cwd-mode.
55
+ */
56
+ export function findWorkspaceRoot(cwd?: string): WorkspaceInfo {
57
+ const start = resolve(cwd ?? process.cwd());
58
+ const stop = resolve(homedir());
59
+
60
+ let dir = start;
61
+ // Walk strictly while above (or equal to) HOME's length, but include HOME
62
+ // itself as a candidate only when start is inside HOME. If start is outside
63
+ // HOME (e.g. /tmp), walk all the way to "/".
64
+ const insideHome = start === stop || start.startsWith(stop + "/") || start.startsWith(stop + "\\");
65
+
66
+ while (true) {
67
+ const marker = hasMarker(dir);
68
+ if (marker) return { root: dir, marker, fromFallback: false };
69
+
70
+ const parent = dirname(dir);
71
+ if (parent === dir) break; // filesystem root
72
+ if (insideHome && dir === stop) break; // do not climb past HOME
73
+ dir = parent;
74
+ }
75
+
76
+ if (!warned) {
77
+ warned = true;
78
+ process.stderr.write(
79
+ `[zond] no workspace marker found from ${start}; using cwd. ` +
80
+ `Run 'zond init' or create zond.config.yml to anchor the workspace.\n`,
81
+ );
82
+ }
83
+ return { root: start, marker: "", fromFallback: true };
84
+ }
85
+
86
+ /** Resolve `relative` against the workspace root (auto-detected from `cwd`). */
87
+ export function resolveWorkspacePath(relative: string, cwd?: string): string {
88
+ return resolve(findWorkspaceRoot(cwd).root, relative);
89
+ }
90
+
91
+ /** Test helper: reset the one-shot warning latch. */
92
+ export function _resetWorkspaceWarning(): void {
93
+ warned = false;
94
+ }
package/src/db/schema.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { resolve } from "path";
3
3
  import { existsSync } from "fs";
4
+ import { findWorkspaceRoot } from "../core/workspace/root.ts";
4
5
 
5
6
  let _db: Database | null = null;
6
7
  let _dbPath: string | null = null;
7
8
 
8
9
  export function getDb(dbPath?: string): Database {
9
- const path = dbPath ? resolve(dbPath) : (_dbPath ?? resolve(process.cwd(), "zond.db"));
10
+ const path = dbPath
11
+ ? resolve(dbPath)
12
+ : (_dbPath ?? resolve(findWorkspaceRoot().root, "zond.db"));
10
13
 
11
14
  // If cached connection exists, verify the file still exists
12
15
  if (_db && _dbPath === path && existsSync(path)) return _db;
@@ -12,6 +12,7 @@ import {
12
12
  RunDetailSchema,
13
13
  RunIdParam,
14
14
  } from "../schemas.ts";
15
+ import { renderProxyResponse, renderProxyError } from "../views/explorer-tab.ts";
15
16
 
16
17
  const api = new OpenAPIHono();
17
18
 
@@ -114,6 +115,85 @@ api.openapi(runRoute, async (c) => {
114
115
  }
115
116
  });
116
117
 
118
+ // ──────────────────────────────────────────────
119
+ // POST /api/proxy — Explorer proxy for HTTP requests
120
+ // ──────────────────────────────────────────────
121
+
122
+ api.post("/api/proxy", async (c) => {
123
+ const form = await c.req.parseBody();
124
+ const baseUrl = (form["base_url"] as string) ?? "";
125
+ const method = ((form["method"] as string) ?? "GET").toUpperCase();
126
+ let path = (form["path"] as string) ?? "/";
127
+ const body = (form["body"] as string) || undefined;
128
+ const contentType = (form["content_type"] as string) || undefined;
129
+
130
+ if (!baseUrl) {
131
+ return c.html(renderProxyError("Base URL is required", 0));
132
+ }
133
+
134
+ // Substitute path parameters
135
+ for (const [key, value] of Object.entries(form)) {
136
+ if (typeof key === "string" && key.startsWith("param_path_") && value) {
137
+ const paramName = key.slice("param_path_".length);
138
+ path = path.replace(`{${paramName}}`, encodeURIComponent(value as string));
139
+ }
140
+ }
141
+
142
+ // Build query string
143
+ const queryParams = new URLSearchParams();
144
+ for (const [key, value] of Object.entries(form)) {
145
+ if (typeof key === "string" && key.startsWith("param_query_") && value) {
146
+ queryParams.set(key.slice("param_query_".length), value as string);
147
+ }
148
+ }
149
+
150
+ // Build headers
151
+ const headers: Record<string, string> = {};
152
+ for (const [key, value] of Object.entries(form)) {
153
+ if (typeof key === "string" && key.startsWith("param_header_") && value) {
154
+ headers[key.slice("param_header_".length)] = value as string;
155
+ }
156
+ }
157
+ // Custom headers
158
+ for (let i = 0; i < 50; i++) {
159
+ const k = form[`custom_header_key_${i}`] as string | undefined;
160
+ const v = form[`custom_header_value_${i}`] as string | undefined;
161
+ if (!k && !v) break;
162
+ if (k && v) headers[k] = v;
163
+ }
164
+
165
+ if (contentType && body) {
166
+ headers["Content-Type"] = contentType;
167
+ }
168
+
169
+ // Build URL
170
+ let url: URL;
171
+ try {
172
+ url = new URL(path, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
173
+ queryParams.forEach((v, k) => url.searchParams.set(k, v));
174
+ } catch (err) {
175
+ return c.html(renderProxyError(`Invalid URL: ${baseUrl}${path} — ${(err as Error).message}`, 0));
176
+ }
177
+
178
+ const startTime = performance.now();
179
+ try {
180
+ const resp = await fetch(url.toString(), {
181
+ method,
182
+ headers,
183
+ body: ["GET", "HEAD"].includes(method) ? undefined : (body || undefined),
184
+ });
185
+ const elapsed = Math.round(performance.now() - startTime);
186
+ const respBody = await resp.text();
187
+ const respHeaders: Record<string, string> = {};
188
+ resp.headers.forEach((v, k) => { respHeaders[k] = v; });
189
+
190
+ return c.html(renderProxyResponse(resp.status, respHeaders, respBody, elapsed));
191
+ } catch (err) {
192
+ const elapsed = Math.round(performance.now() - startTime);
193
+ return c.html(renderProxyError((err as Error).message, elapsed));
194
+ }
195
+ });
196
+
117
197
  // ──────────────────────────────────────────────
118
198
  // Export helpers
119
199
  // ──────────────────────────────────────────────
@@ -5,6 +5,7 @@ import { renderHealthStrip } from "../views/health-strip.ts";
5
5
  import { renderEndpointsTab } from "../views/endpoints-tab.ts";
6
6
  import { renderSuitesTab } from "../views/suites-tab.ts";
7
7
  import { renderRunsTab, renderRunDetail } from "../views/runs-tab.ts";
8
+ import { renderExplorerTab } from "../views/explorer-tab.ts";
8
9
  import { buildCollectionState, invalidateCollectionCache } from "../data/collection-state.ts";
9
10
  import {
10
11
  listCollections,
@@ -81,6 +82,16 @@ dashboard.get("/panels/endpoints", async (c) => {
81
82
  return c.html(renderEndpointsTab(state, filters));
82
83
  });
83
84
 
85
+ dashboard.get("/panels/explorer", async (c) => {
86
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
87
+ if (isNaN(collectionId)) return c.html("");
88
+
89
+ const collection = getCollectionById(collectionId);
90
+ if (!collection) return c.html("");
91
+
92
+ return c.html(await renderExplorerTab(collection));
93
+ });
94
+
84
95
  dashboard.get("/panels/suites", async (c) => {
85
96
  const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
86
97
  if (isNaN(collectionId)) return c.html("");
@@ -243,6 +254,10 @@ async function renderCollectionContent(collection: CollectionRecord): Promise<st
243
254
  hx-get="/panels/runs-tab?collection_id=${collection.id}"
244
255
  hx-target="#tab-content" hx-swap="innerHTML"
245
256
  onclick="activateTab(this)">Runs <span class="tab-count">${runCount}</span></button>
257
+ <button class="tab-btn" data-tab="explorer"
258
+ hx-get="/panels/explorer?collection_id=${collection.id}"
259
+ hx-target="#tab-content" hx-swap="innerHTML"
260
+ onclick="activateTab(this)">Explorer</button>
246
261
  </div>`;
247
262
 
248
263
  // Default tab content (endpoints)
@@ -847,6 +847,294 @@ h3 { font-size: 1.05rem; margin: 1rem 0 0.25rem; }
847
847
  100% { background: transparent; }
848
848
  }
849
849
 
850
+ /* ═══════════════════════════════════════════════════
851
+ Explorer Tab
852
+ ═══════════════════════════════════════════════════ */
853
+
854
+ .explorer-base-url {
855
+ display: flex;
856
+ align-items: center;
857
+ gap: 0.5rem;
858
+ padding: 0.75rem 1rem;
859
+ background: var(--bg-secondary);
860
+ border: 1px solid var(--border);
861
+ border-radius: var(--radius);
862
+ margin-bottom: 1rem;
863
+ }
864
+ .explorer-base-url .explorer-label {
865
+ font-size: 0.8rem;
866
+ font-weight: 600;
867
+ color: var(--text-dim);
868
+ white-space: nowrap;
869
+ }
870
+ .explorer-input {
871
+ background: var(--bg-inset);
872
+ border: 1px solid var(--border);
873
+ border-radius: var(--radius-sm);
874
+ color: var(--text);
875
+ font-family: var(--font-mono);
876
+ font-size: 0.85rem;
877
+ padding: 0.4rem 0.6rem;
878
+ flex: 1;
879
+ min-width: 0;
880
+ }
881
+ .explorer-input:focus {
882
+ outline: none;
883
+ border-color: var(--accent);
884
+ }
885
+ .explorer-input-sm { flex: unset; width: auto; }
886
+
887
+ /* Groups */
888
+ .explorer-list { display: flex; flex-direction: column; gap: 0.5rem; }
889
+ .explorer-group {
890
+ border: 1px solid var(--border);
891
+ border-radius: var(--radius);
892
+ overflow: hidden;
893
+ }
894
+ .explorer-group-title {
895
+ font-size: 0.85rem;
896
+ font-weight: 600;
897
+ padding: 0.6rem 1rem;
898
+ background: var(--bg-secondary);
899
+ cursor: pointer;
900
+ display: flex;
901
+ align-items: center;
902
+ gap: 0.5rem;
903
+ }
904
+ .explorer-group-title .tab-count {
905
+ font-size: 0.75rem;
906
+ background: var(--bg-hover);
907
+ padding: 0.1rem 0.5rem;
908
+ border-radius: var(--radius-pill);
909
+ color: var(--text-dim);
910
+ }
911
+
912
+ /* Endpoint row */
913
+ .explorer-endpoint {
914
+ display: flex;
915
+ align-items: center;
916
+ gap: 0.5rem;
917
+ padding: 0.5rem 1rem;
918
+ cursor: pointer;
919
+ border-top: 1px solid var(--border-subtle);
920
+ transition: background 0.15s;
921
+ }
922
+ .explorer-endpoint:hover { background: var(--bg-hover); }
923
+ .explorer-endpoint-path {
924
+ font-family: var(--font-mono);
925
+ font-size: 0.85rem;
926
+ font-weight: 500;
927
+ }
928
+ .explorer-endpoint-summary {
929
+ color: var(--text-dim);
930
+ font-size: 0.8rem;
931
+ margin-left: auto;
932
+ white-space: nowrap;
933
+ overflow: hidden;
934
+ text-overflow: ellipsis;
935
+ }
936
+ .explorer-auth-hint {
937
+ font-size: 0.65rem;
938
+ font-weight: 600;
939
+ padding: 0.1rem 0.35rem;
940
+ border-radius: var(--radius-sm);
941
+ background: var(--warn-dim);
942
+ color: var(--warn);
943
+ }
944
+
945
+ /* Detail / form */
946
+ .explorer-detail {
947
+ padding: 1rem;
948
+ background: var(--bg-raised);
949
+ border-top: 1px solid var(--border);
950
+ }
951
+ .explorer-section {
952
+ margin-bottom: 1rem;
953
+ }
954
+ .explorer-section-title {
955
+ font-size: 0.8rem;
956
+ font-weight: 600;
957
+ color: var(--text-dim);
958
+ margin-bottom: 0.5rem;
959
+ display: flex;
960
+ align-items: center;
961
+ gap: 0.5rem;
962
+ }
963
+
964
+ /* Parameters */
965
+ .explorer-param-row {
966
+ display: grid;
967
+ grid-template-columns: 140px 50px 90px 1fr;
968
+ gap: 0.5rem;
969
+ align-items: center;
970
+ margin-bottom: 0.35rem;
971
+ font-size: 0.85rem;
972
+ }
973
+ .explorer-param-name {
974
+ font-family: var(--font-mono);
975
+ font-weight: 500;
976
+ font-size: 0.8rem;
977
+ }
978
+ .explorer-param-location {
979
+ font-size: 0.7rem;
980
+ color: var(--text-muted);
981
+ background: var(--bg-inset);
982
+ padding: 0.1rem 0.3rem;
983
+ border-radius: var(--radius-sm);
984
+ text-align: center;
985
+ }
986
+ .explorer-param-type {
987
+ font-size: 0.75rem;
988
+ color: var(--text-dim);
989
+ font-family: var(--font-mono);
990
+ }
991
+ .explorer-required {
992
+ color: var(--fail);
993
+ font-weight: 700;
994
+ margin-left: 0.15rem;
995
+ }
996
+
997
+ /* Body editor */
998
+ .explorer-body-editor {
999
+ width: 100%;
1000
+ min-height: 120px;
1001
+ background: var(--bg-inset);
1002
+ border: 1px solid var(--border);
1003
+ border-radius: var(--radius-sm);
1004
+ color: var(--text);
1005
+ font-family: var(--font-mono);
1006
+ font-size: 0.85rem;
1007
+ padding: 0.5rem;
1008
+ resize: vertical;
1009
+ tab-size: 2;
1010
+ }
1011
+ .explorer-body-editor:focus { outline: none; border-color: var(--accent); }
1012
+ .explorer-content-type {
1013
+ font-size: 0.75rem;
1014
+ padding: 0.15rem 0.35rem;
1015
+ }
1016
+
1017
+ /* Custom headers */
1018
+ .explorer-headers-list {
1019
+ display: flex;
1020
+ flex-direction: column;
1021
+ gap: 0.35rem;
1022
+ }
1023
+ .explorer-header-pair {
1024
+ display: flex;
1025
+ gap: 0.35rem;
1026
+ align-items: center;
1027
+ }
1028
+ .explorer-header-pair input { flex: 1; }
1029
+ .explorer-remove-btn {
1030
+ background: none;
1031
+ border: 1px solid var(--border);
1032
+ border-radius: var(--radius-sm);
1033
+ color: var(--text-dim);
1034
+ cursor: pointer;
1035
+ font-size: 0.75rem;
1036
+ padding: 0.25rem 0.5rem;
1037
+ line-height: 1;
1038
+ }
1039
+ .explorer-remove-btn:hover { color: var(--fail); border-color: var(--fail); }
1040
+ .explorer-add-header-btn {
1041
+ background: none;
1042
+ border: none;
1043
+ color: var(--accent);
1044
+ cursor: pointer;
1045
+ font-size: 0.8rem;
1046
+ padding: 0.25rem 0;
1047
+ margin-top: 0.25rem;
1048
+ }
1049
+ .explorer-add-header-btn:hover { text-decoration: underline; }
1050
+
1051
+ /* Actions */
1052
+ .explorer-actions {
1053
+ display: flex;
1054
+ align-items: center;
1055
+ gap: 0.75rem;
1056
+ margin-top: 0.5rem;
1057
+ }
1058
+ .explorer-send-btn {
1059
+ background: var(--accent);
1060
+ color: #fff;
1061
+ border: none;
1062
+ border-radius: var(--radius);
1063
+ padding: 0.5rem 1.5rem;
1064
+ font-weight: 600;
1065
+ font-size: 0.85rem;
1066
+ cursor: pointer;
1067
+ transition: background 0.15s;
1068
+ }
1069
+ .explorer-send-btn:hover { background: var(--accent-hover); }
1070
+ .explorer-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
1071
+ .explorer-spinner { color: var(--text-dim); font-size: 0.85rem; }
1072
+
1073
+ /* Response */
1074
+ .explorer-response-container { margin-top: 1rem; }
1075
+ .explorer-response {
1076
+ border: 1px solid var(--border);
1077
+ border-radius: var(--radius);
1078
+ overflow: hidden;
1079
+ }
1080
+ .explorer-response-error { border-color: var(--fail); }
1081
+ .response-meta {
1082
+ display: flex;
1083
+ align-items: center;
1084
+ gap: 1rem;
1085
+ padding: 0.5rem 0.75rem;
1086
+ background: var(--bg-secondary);
1087
+ border-bottom: 1px solid var(--border);
1088
+ font-size: 0.85rem;
1089
+ }
1090
+ .response-status {
1091
+ font-weight: 700;
1092
+ font-family: var(--font-mono);
1093
+ }
1094
+ .response-status.status-2xx { color: var(--pass); }
1095
+ .response-status.status-3xx { color: var(--info); }
1096
+ .response-status.status-4xx { color: var(--warn); }
1097
+ .response-status.status-5xx { color: var(--fail); }
1098
+ .response-time { color: var(--text-dim); font-size: 0.8rem; }
1099
+ .response-size { color: var(--text-muted); font-size: 0.8rem; }
1100
+ .response-headers { padding: 0.5rem 0.75rem; }
1101
+ .response-headers summary {
1102
+ font-size: 0.8rem;
1103
+ color: var(--text-dim);
1104
+ cursor: pointer;
1105
+ }
1106
+ .response-headers-pre {
1107
+ font-size: 0.8rem;
1108
+ margin-top: 0.25rem;
1109
+ white-space: pre-wrap;
1110
+ word-break: break-all;
1111
+ }
1112
+ .response-body {
1113
+ padding: 0.75rem;
1114
+ background: var(--bg-inset);
1115
+ }
1116
+ .response-body pre {
1117
+ font-family: var(--font-mono);
1118
+ font-size: 0.8rem;
1119
+ white-space: pre-wrap;
1120
+ word-break: break-all;
1121
+ max-height: 500px;
1122
+ overflow-y: auto;
1123
+ margin: 0;
1124
+ }
1125
+ .response-error-msg {
1126
+ padding: 0.75rem;
1127
+ color: var(--fail);
1128
+ font-size: 0.85rem;
1129
+ }
1130
+
1131
+ /* JSON syntax highlighting */
1132
+ .json-key { color: var(--accent); }
1133
+ .json-string { color: var(--pass); }
1134
+ .json-number { color: var(--warn); }
1135
+ .json-boolean { color: var(--method-patch); }
1136
+ .json-null { color: var(--text-dim); font-style: italic; }
1137
+
850
1138
  /* ── Responsive ── */
851
1139
  @media (max-width: 768px) {
852
1140
  .health-strip { grid-template-columns: 1fr; gap: 1rem; }
@@ -855,4 +1143,6 @@ h3 { font-size: 1.05rem; margin: 1rem 0 0.25rem; }
855
1143
  .run-row { grid-template-columns: 48px 1fr 60px; }
856
1144
  .run-time, .run-duration { display: none; }
857
1145
  .runs-header { display: none; }
1146
+ .explorer-param-row { grid-template-columns: 1fr; }
1147
+ .explorer-endpoint-summary { display: none; }
858
1148
  }