@checkstack/gitops-frontend 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @checkstack/gitops-frontend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
8
+ - cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
9
+
10
+ **Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
11
+
12
+ **Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
13
+
14
+ **New features**:
15
+
16
+ - Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
17
+ - Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
18
+ - Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
19
+ - Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
20
+ - New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
21
+ - Secrets UI now shows an expandable usage panel per secret showing referencing entities
22
+ - Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
23
+ - New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
24
+
25
+ **Security**:
26
+
27
+ - Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
28
+ - Secrets are validated to exist at sync time but **not pre-resolved** into the spec
29
+ - Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
30
+ - Only fields with `x-secret` schema annotations get resolved — no escape hatch
31
+ - Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
32
+
33
+ **Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
34
+
35
+ ### Patch Changes
36
+
37
+ - Updated dependencies [8ef367a]
38
+ - Updated dependencies [cb65e9d]
39
+ - @checkstack/gitops-common@0.2.0
40
+
3
41
  ## 0.2.1
4
42
 
5
43
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-frontend",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "checkstack": {
@@ -14,7 +14,7 @@
14
14
  "dependencies": {
15
15
  "@checkstack/common": "0.6.5",
16
16
  "@checkstack/frontend-api": "0.3.9",
17
- "@checkstack/gitops-common": "0.1.0",
17
+ "@checkstack/gitops-common": "0.1.1",
18
18
  "@checkstack/ui": "1.3.6",
19
19
  "lucide-react": "^0.344.0",
20
20
  "react": "^18.2.0",
@@ -61,6 +61,9 @@ export const ProvenanceStatus = () => {
61
61
  const synced = entries.filter((e) => e.status === "synced");
62
62
  const errors = entries.filter((e) => e.status === "error");
63
63
  const orphaned = entries.filter((e) => e.status === "orphaned");
64
+ const withWarnings = entries.filter(
65
+ (e) => e.warnings.length > 0 && e.status === "synced",
66
+ );
64
67
 
65
68
  const statusIcon = (status: Provenance["status"]) => {
66
69
  switch (status) {
@@ -107,6 +110,19 @@ export const ProvenanceStatus = () => {
107
110
  {entry.errorMessage}
108
111
  </div>
109
112
  )}
113
+ {entry.warnings.length > 0 && (
114
+ <div className="mt-1 space-y-0.5">
115
+ {entry.warnings.map((warning, index) => (
116
+ <div
117
+ key={index}
118
+ className="text-xs text-amber-500 flex items-start gap-1"
119
+ >
120
+ <AlertTriangle className="w-3 h-3 shrink-0 mt-0.5" />
121
+ <span className="truncate">{warning}</span>
122
+ </div>
123
+ ))}
124
+ </div>
125
+ )}
110
126
  </div>
111
127
  </div>
112
128
 
@@ -148,7 +164,7 @@ export const ProvenanceStatus = () => {
148
164
  <>
149
165
  <div className="space-y-6">
150
166
  {/* Summary */}
151
- <div className="grid grid-cols-3 gap-4">
167
+ <div className="grid grid-cols-4 gap-4">
152
168
  <Card>
153
169
  <CardContent className="pt-6">
154
170
  <div className="flex items-center gap-2">
@@ -176,6 +192,15 @@ export const ProvenanceStatus = () => {
176
192
  </div>
177
193
  </CardContent>
178
194
  </Card>
195
+ <Card>
196
+ <CardContent className="pt-6">
197
+ <div className="flex items-center gap-2">
198
+ <AlertTriangle className="w-5 h-5 text-amber-500" />
199
+ <span className="text-2xl font-bold">{withWarnings.length}</span>
200
+ <span className="text-sm text-muted-foreground">Warnings</span>
201
+ </div>
202
+ </CardContent>
203
+ </Card>
179
204
  </div>
180
205
 
181
206
  {/* Orphaned entities — shown first if any */}
@@ -196,6 +221,22 @@ export const ProvenanceStatus = () => {
196
221
  </Card>
197
222
  )}
198
223
 
224
+ {/* Warnings */}
225
+ {withWarnings.length > 0 && (
226
+ <Card>
227
+ <CardHeader>
228
+ <CardTitle className="flex items-center gap-2">
229
+ <AlertTriangle className="w-5 h-5 text-amber-500" />
230
+ Sync Warnings
231
+ </CardTitle>
232
+ <p className="text-xs text-muted-foreground mt-1">
233
+ These entities contain secret templates in non-secret fields. The templates will not be resolved.
234
+ </p>
235
+ </CardHeader>
236
+ <CardContent>{renderEntryList(withWarnings, false)}</CardContent>
237
+ </Card>
238
+ )}
239
+
199
240
  {/* Errors */}
200
241
  {errors.length > 0 && (
201
242
  <Card>
@@ -10,6 +10,7 @@ import {
10
10
  DialogTitle,
11
11
  DialogFooter,
12
12
  } from "@checkstack/ui";
13
+ import { SECRET_NAME_REGEX } from "@checkstack/gitops-common";
13
14
 
14
15
  interface SecretEditorProps {
15
16
  open: boolean;
@@ -34,9 +35,18 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
34
35
  }
35
36
  }, [open]);
36
37
 
38
+ const nameError =
39
+ name.length > 0 && !SECRET_NAME_REGEX.test(name)
40
+ ? "Must start with a letter and contain only letters, digits, underscores, or hyphens"
41
+ : name.length > 63
42
+ ? "Must be 63 characters or fewer"
43
+ : undefined;
44
+
45
+ const canSubmit = name.trim().length > 0 && !nameError && value.trim().length > 0;
46
+
37
47
  const handleSubmit = (e: React.FormEvent) => {
38
48
  e.preventDefault();
39
- if (!name.trim() || !value.trim()) return;
49
+ if (!canSubmit) return;
40
50
 
41
51
  onSave({
42
52
  name: name.trim(),
@@ -64,12 +74,16 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
64
74
  placeholder="e.g. GITHUB_TOKEN"
65
75
  value={name}
66
76
  onChange={(e) => setName(e.target.value)}
67
- className="font-mono"
77
+ className={`font-mono ${nameError ? "border-destructive" : ""}`}
68
78
  required
69
79
  />
70
- <p className="text-xs text-muted-foreground">
71
- Referenced as <code className="text-xs">{"${{ secrets.NAME }}"}</code> in descriptors.
72
- </p>
80
+ {nameError ? (
81
+ <p className="text-xs text-destructive">{nameError}</p>
82
+ ) : (
83
+ <p className="text-xs text-muted-foreground">
84
+ Referenced as <code className="text-xs">{"${{ secrets.NAME }}"}</code> in descriptors.
85
+ </p>
86
+ )}
73
87
  </div>
74
88
 
75
89
  <div className="space-y-2">
@@ -99,7 +113,7 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
99
113
  <Button type="button" variant="outline" onClick={onClose}>
100
114
  Cancel
101
115
  </Button>
102
- <Button type="submit" disabled={!name.trim() || !value.trim()}>
116
+ <Button type="submit" disabled={!canSubmit}>
103
117
  Create Secret
104
118
  </Button>
105
119
  </DialogFooter>
@@ -108,3 +122,4 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
108
122
  </Dialog>
109
123
  );
110
124
  };
125
+
@@ -15,14 +15,56 @@ import {
15
15
  EmptyState,
16
16
  ConfirmationModal,
17
17
  useToast,
18
+ Badge,
18
19
  } from "@checkstack/ui";
19
- import { Plus, RotateCw, Trash2, KeyRound } from "lucide-react";
20
+ import { Plus, RotateCw, Trash2, KeyRound, ChevronDown, ChevronRight } from "lucide-react";
20
21
  import { extractErrorMessage } from "@checkstack/common";
21
22
  import { SecretEditor } from "./SecretEditor";
22
23
  import { SecretRotateDialog } from "./SecretRotateDialog";
23
24
 
24
25
  const formatDate = (date: Date) => new Date(date).toLocaleString();
25
26
 
27
+ /** Expandable usage panel for a single secret. */
28
+ const SecretUsagePanel = ({ secretName }: { secretName: string }) => {
29
+ const client = usePluginClient(GitOpsApi);
30
+ const { data: usage, isLoading } = client.getSecretUsage.useQuery({
31
+ secretName,
32
+ });
33
+
34
+ if (isLoading) {
35
+ return (
36
+ <div className="text-xs text-muted-foreground py-2 px-4">Loading…</div>
37
+ );
38
+ }
39
+
40
+ if (!usage || usage.length === 0) {
41
+ return (
42
+ <div className="text-xs text-muted-foreground py-2 px-4">
43
+ Not referenced by any entities.
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="px-4 pb-3 space-y-1">
50
+ {usage.map((entry) => (
51
+ <div
52
+ key={`${entry.kind}::${entry.entityName}`}
53
+ className="flex items-center gap-2 text-xs text-muted-foreground"
54
+ >
55
+ <Badge variant="outline" className="text-[10px] font-mono px-1.5 py-0">
56
+ {entry.kind}
57
+ </Badge>
58
+ <span className="font-medium text-foreground">{entry.entityName}</span>
59
+ <span className="hidden sm:inline truncate">
60
+ {entry.repository}/{entry.filePath}
61
+ </span>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ };
67
+
26
68
  export const SecretList = () => {
27
69
  const client = usePluginClient(GitOpsApi);
28
70
  const accessApi = useApi(accessApiRef);
@@ -42,6 +84,9 @@ export const SecretList = () => {
42
84
  secretId: string;
43
85
  secretName: string;
44
86
  }>({ isOpen: false, secretId: "", secretName: "" });
87
+ const [expandedSecrets, setExpandedSecrets] = useState<Set<string>>(
88
+ new Set(),
89
+ );
45
90
 
46
91
  const { data: secrets, isLoading, refetch } = client.listSecrets.useQuery({});
47
92
 
@@ -78,6 +123,18 @@ export const SecretList = () => {
78
123
  },
79
124
  });
80
125
 
126
+ const toggleExpanded = (secretId: string) => {
127
+ setExpandedSecrets((prev) => {
128
+ const next = new Set(prev);
129
+ if (next.has(secretId)) {
130
+ next.delete(secretId);
131
+ } else {
132
+ next.add(secretId);
133
+ }
134
+ return next;
135
+ });
136
+ };
137
+
81
138
  return (
82
139
  <>
83
140
  <Card>
@@ -108,64 +165,83 @@ export const SecretList = () => {
108
165
  />
109
166
  ) : (
110
167
  <div className="space-y-3">
111
- {secrets.map((secret) => (
112
- <div
113
- key={secret.id}
114
- className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
115
- >
116
- <div className="flex items-center gap-3 min-w-0">
117
- <KeyRound className="w-4 h-4 text-muted-foreground shrink-0" />
118
- <div className="min-w-0">
119
- <div className="font-medium font-mono text-sm">
120
- {secret.name}
121
- </div>
122
- {secret.description && (
123
- <div className="text-xs text-muted-foreground mt-0.5 truncate">
124
- {secret.description}
168
+ {secrets.map((secret) => {
169
+ const isExpanded = expandedSecrets.has(secret.id);
170
+ return (
171
+ <div
172
+ key={secret.id}
173
+ className="rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
174
+ >
175
+ <div className="flex items-center justify-between p-4">
176
+ <button
177
+ type="button"
178
+ className="flex items-center gap-3 min-w-0 cursor-pointer bg-transparent border-none p-0 text-left"
179
+ onClick={() => toggleExpanded(secret.id)}
180
+ title={isExpanded ? "Hide usage" : "Show usage"}
181
+ >
182
+ {isExpanded ? (
183
+ <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
184
+ ) : (
185
+ <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
186
+ )}
187
+ <KeyRound className="w-4 h-4 text-muted-foreground shrink-0" />
188
+ <div className="min-w-0">
189
+ <div className="font-medium font-mono text-sm">
190
+ {secret.name}
191
+ </div>
192
+ {secret.description && (
193
+ <div className="text-xs text-muted-foreground mt-0.5 truncate">
194
+ {secret.description}
195
+ </div>
196
+ )}
125
197
  </div>
126
- )}
127
- </div>
128
- </div>
198
+ </button>
129
199
 
130
- <div className="flex items-center gap-4 shrink-0">
131
- <div className="text-right text-xs text-muted-foreground hidden md:block">
132
- <div>Updated: {formatDate(secret.updatedAt)}</div>
133
- </div>
200
+ <div className="flex items-center gap-4 shrink-0">
201
+ <div className="text-right text-xs text-muted-foreground hidden md:block">
202
+ <div>Updated: {formatDate(secret.updatedAt)}</div>
203
+ </div>
134
204
 
135
- {canManage && (
136
- <div className="flex items-center gap-1">
137
- <Button
138
- variant="ghost"
139
- size="icon"
140
- onClick={() =>
141
- setRotatingSecret({
142
- id: secret.id,
143
- name: secret.name,
144
- })
145
- }
146
- title="Rotate secret"
147
- >
148
- <RotateCw className="w-4 h-4" />
149
- </Button>
150
- <Button
151
- variant="ghost"
152
- size="icon"
153
- onClick={() =>
154
- setConfirmModal({
155
- isOpen: true,
156
- secretId: secret.id,
157
- secretName: secret.name,
158
- })
159
- }
160
- title="Delete secret"
161
- >
162
- <Trash2 className="w-4 h-4" />
163
- </Button>
205
+ {canManage && (
206
+ <div className="flex items-center gap-1">
207
+ <Button
208
+ variant="ghost"
209
+ size="icon"
210
+ onClick={() =>
211
+ setRotatingSecret({
212
+ id: secret.id,
213
+ name: secret.name,
214
+ })
215
+ }
216
+ title="Rotate secret"
217
+ >
218
+ <RotateCw className="w-4 h-4" />
219
+ </Button>
220
+ <Button
221
+ variant="ghost"
222
+ size="icon"
223
+ onClick={() =>
224
+ setConfirmModal({
225
+ isOpen: true,
226
+ secretId: secret.id,
227
+ secretName: secret.name,
228
+ })
229
+ }
230
+ title="Delete secret"
231
+ >
232
+ <Trash2 className="w-4 h-4" />
233
+ </Button>
234
+ </div>
235
+ )}
164
236
  </div>
237
+ </div>
238
+
239
+ {isExpanded && (
240
+ <SecretUsagePanel secretName={secret.name} />
165
241
  )}
166
242
  </div>
167
- </div>
168
- ))}
243
+ );
244
+ })}
169
245
  </div>
170
246
  )}
171
247
  </CardContent>
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, useCallback, useMemo, useEffect } from "react";
2
2
  import {
3
3
  Card,
4
4
  CardContent,
@@ -7,8 +7,22 @@ import {
7
7
  CardTitle,
8
8
  Badge,
9
9
  PageLayout,
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ CodeEditor,
16
+ Markdown,
17
+ MarkdownBlock,
10
18
  } from "@checkstack/ui";
11
- import { ChevronDown, ChevronRight, Puzzle, Blocks } from "lucide-react";
19
+ import {
20
+ ChevronDown,
21
+ ChevronRight,
22
+ Puzzle,
23
+ Blocks,
24
+ BookOpen,
25
+ } from "lucide-react";
12
26
  import { usePluginClient } from "@checkstack/frontend-api";
13
27
  import { GitOpsApi } from "@checkstack/gitops-common";
14
28
  import { extractErrorMessage } from "@checkstack/common";
@@ -29,11 +43,23 @@ interface JsonSchemaProperty {
29
43
  interface KindDescription {
30
44
  apiVersion: string;
31
45
  kind: string;
46
+ metadataSchema: JsonSchemaProperty;
32
47
  specSchema: JsonSchemaProperty;
33
48
  extensions: Array<{
34
49
  namespace: string;
35
50
  specSchema: JsonSchemaProperty;
36
51
  }>;
52
+ specSchemaDocumentation?: Array<{
53
+ fieldPath: string;
54
+ variantId?: string;
55
+ label: string;
56
+ description?: string;
57
+ specSchema: JsonSchemaProperty;
58
+ conditions?: Array<{
59
+ fieldPath: string;
60
+ variantIds: string[];
61
+ }>;
62
+ }>;
37
63
  }
38
64
 
39
65
  // ─── Schema Display ────────────────────────────────────────────────────────
@@ -87,8 +113,11 @@ function SchemaPropertyDisplay({
87
113
  )}
88
114
  : <SchemaPropertyDisplay schema={value} depth={depth + 1} />
89
115
  {value.description && (
90
- <span className="text-muted-foreground ml-2 text-xs">
91
- // {value.description}
116
+ <span className="text-muted-foreground ml-2 text-xs inline-flex items-center gap-1">
117
+ //{" "}
118
+ <Markdown size="sm" className="inline">
119
+ {value.description}
120
+ </Markdown>
92
121
  </span>
93
122
  )}
94
123
  </div>
@@ -160,12 +189,31 @@ function SchemaBlock({
160
189
  // ─── YAML Example Generator ────────────────────────────────────────────────
161
190
 
162
191
  function generateYamlExample({ kind }: { kind: KindDescription }): string {
163
- const lines = [
164
- `apiVersion: ${kind.apiVersion}`,
165
- `kind: ${kind.kind}`,
166
- "metadata:",
167
- ` name: my-${kind.kind.toLowerCase()}`,
168
- ];
192
+ const lines = [`apiVersion: ${kind.apiVersion}`, `kind: ${kind.kind}`];
193
+
194
+ if (kind.metadataSchema) {
195
+ lines.push("metadata:");
196
+ const metadataProps = kind.metadataSchema.properties ?? {};
197
+ const metadataRequired = new Set(kind.metadataSchema.required);
198
+
199
+ for (const [key, prop] of Object.entries(metadataProps)) {
200
+ // Provide a nice default for name instead of generic "..."
201
+ const customProp =
202
+ key === "name"
203
+ ? { ...prop, default: `my-${kind.kind.toLowerCase()}` }
204
+ : prop;
205
+
206
+ emitProperty({
207
+ lines,
208
+ key,
209
+ prop: customProp,
210
+ indent: 2,
211
+ required: metadataRequired.has(key),
212
+ });
213
+ }
214
+ } else {
215
+ lines.push("metadata:", ` name: my-${kind.kind.toLowerCase()}`);
216
+ }
169
217
 
170
218
  const baseProps = kind.specSchema.properties ?? {};
171
219
  const hasBaseProps = Object.keys(baseProps).length > 0;
@@ -380,10 +428,149 @@ function scalarExample({ prop }: { prop: JsonSchemaProperty }): string {
380
428
  }
381
429
  }
382
430
 
431
+ // ─── Spec Schema Documentation ─────────────────────────────────────────────
432
+
433
+ function SpecSchemaDocumentationSection({
434
+ docs,
435
+ }: {
436
+ docs: NonNullable<KindDescription["specSchemaDocumentation"]>;
437
+ }) {
438
+ const [selections, setSelections] = useState<Record<string, string>>({});
439
+
440
+ const handleSelect = useCallback((fieldPath: string, variantId: string) => {
441
+ setSelections((prev) => {
442
+ if (prev[fieldPath] === variantId) return prev;
443
+ return { ...prev, [fieldPath]: variantId };
444
+ });
445
+ }, []);
446
+
447
+ const groupedDocs: Record<string, typeof docs> = {};
448
+ for (const doc of docs) {
449
+ if (!groupedDocs[doc.fieldPath]) {
450
+ groupedDocs[doc.fieldPath] = [];
451
+ }
452
+ groupedDocs[doc.fieldPath].push(doc);
453
+ }
454
+
455
+ return (
456
+ <div className="space-y-6">
457
+ <h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
458
+ <BookOpen className="h-4 w-4" />
459
+ Additional Schemas
460
+ </h4>
461
+
462
+ {Object.entries(groupedDocs).map(([fieldPath, fieldDocs]) => {
463
+ return (
464
+ <SpecSchemaDocumentationField
465
+ key={fieldPath}
466
+ fieldPath={fieldPath}
467
+ docs={fieldDocs.toSorted((a, b) => a.label.localeCompare(b.label))}
468
+ selections={selections}
469
+ onSelect={handleSelect}
470
+ />
471
+ );
472
+ })}
473
+ </div>
474
+ );
475
+ }
476
+
477
+ function SpecSchemaDocumentationField({
478
+ fieldPath,
479
+ docs,
480
+ selections,
481
+ onSelect,
482
+ }: {
483
+ fieldPath: string;
484
+ docs: NonNullable<KindDescription["specSchemaDocumentation"]>;
485
+ selections: Record<string, string>;
486
+ onSelect: (fieldPath: string, variantId: string) => void;
487
+ }) {
488
+ const availableDocs = useMemo(() => {
489
+ return docs.filter((doc) => {
490
+ if (!doc.conditions || doc.conditions.length === 0) return true;
491
+ return doc.conditions.every((cond) => {
492
+ const selectedForField = selections[cond.fieldPath];
493
+ if (!selectedForField) return false;
494
+ return cond.variantIds.includes(selectedForField);
495
+ });
496
+ });
497
+ }, [docs, selections]);
498
+
499
+ const currentSelection = selections[fieldPath] || "";
500
+ const isValidSelection =
501
+ currentSelection !== "" &&
502
+ availableDocs.some((d) => (d.variantId || d.label) === currentSelection);
503
+
504
+ useEffect(() => {
505
+ if (currentSelection !== "" && !isValidSelection) {
506
+ onSelect(fieldPath, "");
507
+ }
508
+ }, [currentSelection, isValidSelection, onSelect, fieldPath]);
509
+
510
+ if (availableDocs.length === 0) {
511
+ return <></>;
512
+ }
513
+
514
+ const selectedDoc = availableDocs.find(
515
+ (d) => (d.variantId || d.label) === currentSelection,
516
+ );
517
+
518
+ return (
519
+ <div className="border rounded-lg p-4 space-y-4">
520
+ <div className="flex items-center justify-between gap-4 flex-wrap">
521
+ <div className="flex items-center gap-2">
522
+ <Badge variant="secondary" className="font-mono">
523
+ {fieldPath}
524
+ </Badge>
525
+ <span className="text-sm text-muted-foreground">
526
+ {availableDocs.length} variant{availableDocs.length > 1 ? "s" : ""}
527
+ </span>
528
+ </div>
529
+
530
+ <div className="w-full sm:w-64">
531
+ <Select
532
+ value={isValidSelection ? currentSelection : ""}
533
+ onValueChange={(val) => onSelect(fieldPath, val)}
534
+ >
535
+ <SelectTrigger>
536
+ <SelectValue placeholder="Select a schema variant..." />
537
+ </SelectTrigger>
538
+ <SelectContent>
539
+ {availableDocs.map((doc, i) => (
540
+ <SelectItem key={i} value={doc.variantId || doc.label}>
541
+ {doc.label}
542
+ </SelectItem>
543
+ ))}
544
+ </SelectContent>
545
+ </Select>
546
+ </div>
547
+ </div>
548
+
549
+ {selectedDoc ? (
550
+ <div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
551
+ {selectedDoc.description && (
552
+ <div className="text-sm text-muted-foreground">
553
+ <MarkdownBlock>{selectedDoc.description}</MarkdownBlock>
554
+ </div>
555
+ )}
556
+ <div className="bg-muted rounded-md p-3 overflow-x-auto">
557
+ <SchemaPropertyDisplay schema={selectedDoc.specSchema} />
558
+ </div>
559
+ </div>
560
+ ) : (
561
+ <div className="text-sm text-muted-foreground italic bg-muted/50 rounded-md p-4 text-center">
562
+ Select a variant from the dropdown above to view its schema.
563
+ </div>
564
+ )}
565
+ </div>
566
+ );
567
+ }
568
+
383
569
  // ─── Kind Card ─────────────────────────────────────────────────────────────
384
570
 
385
571
  function KindCard({ kind }: { kind: KindDescription }) {
386
572
  const [isOpen, setIsOpen] = useState(false);
573
+ const yamlExample = useMemo(() => generateYamlExample({ kind }), [kind]);
387
574
 
388
575
  return (
389
576
  <Card className="mb-3">
@@ -423,6 +610,12 @@ function KindCard({ kind }: { kind: KindDescription }) {
423
610
 
424
611
  {isOpen && (
425
612
  <CardContent className="pt-0 space-y-6">
613
+ {/* Entity Envelope Fields */}
614
+ <SchemaBlock
615
+ schema={kind.metadataSchema}
616
+ label="Entity Envelope Fields"
617
+ />
618
+
426
619
  {/* Base Spec Schema */}
427
620
  <SchemaBlock schema={kind.specSchema} label="Base Spec Schema" />
428
621
 
@@ -451,14 +644,28 @@ function KindCard({ kind }: { kind: KindDescription }) {
451
644
  </div>
452
645
  )}
453
646
 
647
+ {/* Spec Schema Documentation */}
648
+ {kind.specSchemaDocumentation &&
649
+ kind.specSchemaDocumentation.length > 0 && (
650
+ <SpecSchemaDocumentationSection
651
+ docs={kind.specSchemaDocumentation}
652
+ />
653
+ )}
654
+
454
655
  {/* YAML Example */}
455
656
  <div>
456
657
  <h4 className="text-sm font-medium mb-2 text-muted-foreground">
457
658
  YAML Example
458
659
  </h4>
459
- <pre className="bg-muted rounded-md p-3 overflow-x-auto text-sm">
460
- <code>{generateYamlExample({ kind })}</code>
461
- </pre>
660
+ <div className="rounded-md overflow-hidden border border-input">
661
+ <CodeEditor
662
+ value={yamlExample}
663
+ language="yaml"
664
+ readOnly
665
+ onChange={() => {}}
666
+ minHeight={`${Math.max(100, yamlExample.split("\n").length * 20 + 20)}px`}
667
+ />
668
+ </div>
462
669
  </div>
463
670
  </CardContent>
464
671
  )}