@featurevisor/catalog 0.0.1 → 3.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/assets/index-DB7fEbI6.js +25 -0
  3. package/dist/assets/index-DgA0Oqrg.css +2 -0
  4. package/dist/favicon.png +0 -0
  5. package/dist/index.html +14 -0
  6. package/dist/logo-text.png +0 -0
  7. package/lib/entityTypes.d.ts +16 -0
  8. package/lib/entityTypes.js +96 -0
  9. package/lib/entityTypes.js.map +1 -0
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +18 -0
  12. package/lib/index.js.map +1 -0
  13. package/lib/node/index.d.ts +74 -0
  14. package/lib/node/index.js +1368 -0
  15. package/lib/node/index.js.map +1 -0
  16. package/lib/types.d.ts +95 -0
  17. package/lib/types.js +3 -0
  18. package/lib/types.js.map +1 -0
  19. package/package.json +66 -13
  20. package/public/favicon.png +0 -0
  21. package/public/logo-text.png +0 -0
  22. package/src/App.tsx +62 -0
  23. package/src/api.spec.ts +14 -0
  24. package/src/api.ts +74 -0
  25. package/src/components/tests.spec.ts +134 -0
  26. package/src/components/tests.tsx +301 -0
  27. package/src/components/trees.tsx +202 -0
  28. package/src/components/ui.tsx +660 -0
  29. package/src/components/variables.tsx +700 -0
  30. package/src/components/variations.tsx +450 -0
  31. package/src/context/CatalogContext.tsx +30 -0
  32. package/src/entityTypes.spec.ts +13 -0
  33. package/src/entityTypes.ts +102 -0
  34. package/src/index.ts +1 -0
  35. package/src/main.tsx +26 -0
  36. package/src/node/index.spec.ts +380 -0
  37. package/src/node/index.ts +1906 -0
  38. package/src/pages/EntityDetailPage.tsx +1144 -0
  39. package/src/pages/HistoryPage.tsx +144 -0
  40. package/src/pages/HomePage.tsx +21 -0
  41. package/src/pages/ListPage.tsx +737 -0
  42. package/src/styles.css +59 -0
  43. package/src/testModel.ts +123 -0
  44. package/src/types.ts +112 -0
  45. package/src/vite-env.d.ts +1 -0
  46. package/index.js +0 -1
@@ -0,0 +1,660 @@
1
+ import * as React from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import { NavLink, Link, useLocation, useNavigate } from "react-router-dom";
4
+
5
+ import { fetchIndex } from "../api";
6
+ import type { CatalogEntityType, CatalogIndex, EntityPath } from "../types";
7
+ import {
8
+ decodeRouteSegment,
9
+ encodeRouteSegment,
10
+ entityLabels,
11
+ entityPaths,
12
+ entityPathToType,
13
+ getBasePath,
14
+ sortSetKeys,
15
+ } from "../entityTypes";
16
+ import { useCatalog } from "../context/CatalogContext";
17
+
18
+ type BadgeTone = "neutral" | "success" | "warning" | "danger" | "primary";
19
+
20
+ const toneClasses: Record<BadgeTone, string> = {
21
+ neutral: "border-pill bg-pill text-text",
22
+ success: "border-green-300 bg-success-surface text-text",
23
+ warning: "border-orange-300 bg-warning-surface text-text",
24
+ danger: "border-red-300 bg-danger-surface text-danger",
25
+ primary: "border-pill bg-pill text-text",
26
+ };
27
+
28
+ export function Badge(props: { children: React.ReactNode; tone?: BadgeTone | "default" }) {
29
+ const tone = props.tone === "default" ? "neutral" : props.tone || "neutral";
30
+
31
+ return (
32
+ <span
33
+ className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${toneClasses[tone]}`}
34
+ >
35
+ {props.children}
36
+ </span>
37
+ );
38
+ }
39
+
40
+ export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
41
+ const { className = "", ...rest } = props;
42
+
43
+ return (
44
+ <button
45
+ className={`rounded border border-border bg-elevated px-4 py-2 text-sm font-bold text-muted shadow-sm hover:bg-background ${className}`}
46
+ {...rest}
47
+ />
48
+ );
49
+ }
50
+
51
+ export function CodeBlock(props: { value: unknown }) {
52
+ const value =
53
+ typeof props.value === "string" ? props.value : JSON.stringify(props.value, null, 2);
54
+
55
+ return (
56
+ <pre className="max-w-full whitespace-pre-wrap rounded border border-border bg-elevated p-4 text-xs text-text [overflow-wrap:anywhere]">
57
+ <code>{value}</code>
58
+ </pre>
59
+ );
60
+ }
61
+
62
+ function toMarkdownString(value: unknown): string | undefined {
63
+ if (value === undefined || value === null) {
64
+ return undefined;
65
+ }
66
+
67
+ if (typeof value === "string") {
68
+ return value;
69
+ }
70
+
71
+ if (typeof value === "number" || typeof value === "boolean") {
72
+ return String(value);
73
+ }
74
+
75
+ if (Array.isArray(value)) {
76
+ const lines = value
77
+ .map((item) => toMarkdownString(item))
78
+ .filter((item): item is string => Boolean(item?.trim()));
79
+
80
+ return lines.length > 0 ? lines.join("\n") : undefined;
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ export function MarkdownContent(props: { value?: unknown }) {
87
+ const markdown = toMarkdownString(props.value);
88
+
89
+ if (!markdown?.trim()) {
90
+ return <span className="text-muted">n/a</span>;
91
+ }
92
+
93
+ return (
94
+ <div className="prose prose-sm prose-slate max-w-none text-text">
95
+ <ReactMarkdown>{markdown}</ReactMarkdown>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export function DescriptionField(props: { value?: unknown; showTopDivider?: boolean }) {
101
+ const showTopDivider = props.showTopDivider !== false;
102
+
103
+ return (
104
+ <div className={showTopDivider ? "border-t border-border pt-6" : undefined}>
105
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-faint">
106
+ Description
107
+ </div>
108
+ <div className="mt-2 min-w-0 text-sm [overflow-wrap:anywhere]">
109
+ <MarkdownContent value={props.value} />
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export function LabelValueBadge(props: {
116
+ label: React.ReactNode;
117
+ value: React.ReactNode;
118
+ to?: string;
119
+ compact?: boolean;
120
+ }) {
121
+ const valueContent = props.to ? (
122
+ <Link to={props.to} className="font-medium text-primary hover:underline">
123
+ {props.value}
124
+ </Link>
125
+ ) : (
126
+ <span className="font-medium text-text">{props.value}</span>
127
+ );
128
+
129
+ if (props.compact) {
130
+ return (
131
+ <span className="inline-flex h-5 shrink-0 overflow-hidden rounded-md border border-border/70 text-[10px] leading-none">
132
+ <span className="flex items-center bg-elevated px-1.5 text-muted">{props.label}</span>
133
+ <span className="flex items-center bg-surface px-1.5 text-text">{valueContent}</span>
134
+ </span>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <span className="inline-flex shrink-0 overflow-hidden rounded-md border border-border text-xs shadow-sm">
140
+ <span className="bg-primary/10 px-2 py-1 font-medium text-primary">{props.label}</span>
141
+ <span className="bg-surface px-2 py-1">{valueContent}</span>
142
+ </span>
143
+ );
144
+ }
145
+
146
+ export function OverviewChip(props: { children: React.ReactNode; className?: string }) {
147
+ return (
148
+ <span
149
+ className={[
150
+ "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-text",
151
+ props.className || "",
152
+ ].join(" ")}
153
+ >
154
+ {props.children}
155
+ </span>
156
+ );
157
+ }
158
+
159
+ export function OverviewChipLink(props: { to: string; children: React.ReactNode }) {
160
+ return (
161
+ <Link
162
+ to={props.to}
163
+ className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-slate-200"
164
+ >
165
+ {props.children}
166
+ </Link>
167
+ );
168
+ }
169
+
170
+ export function OverviewMetaPanel(props: { children: React.ReactNode }) {
171
+ return (
172
+ <div className="rounded-xl bg-elevated px-5 py-4">
173
+ <dl className="space-y-3.5">{props.children}</dl>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ export function OverviewMetaRow(props: { label: string; children?: React.ReactNode }) {
179
+ if (!props.children) {
180
+ return null;
181
+ }
182
+
183
+ return (
184
+ <div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-5">
185
+ <dt className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-faint sm:w-[4.75rem]">
186
+ {props.label}
187
+ </dt>
188
+ <dd className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
189
+ {props.children}
190
+ </dd>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ export function OverviewLabeledRow(props: { label: string; children?: React.ReactNode }) {
196
+ if (!props.children) {
197
+ return null;
198
+ }
199
+
200
+ return (
201
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
202
+ <span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-faint">
203
+ {props.label}
204
+ </span>
205
+ <div className="flex min-w-0 flex-wrap items-center gap-2">{props.children}</div>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ export function OverviewSection(props: { title: string; children?: React.ReactNode }) {
211
+ if (!props.children) {
212
+ return null;
213
+ }
214
+
215
+ return (
216
+ <section>
217
+ <h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
218
+ {props.title}
219
+ </h2>
220
+ {props.children}
221
+ </section>
222
+ );
223
+ }
224
+
225
+ export function EmptyState(props: { title: string; description?: string }) {
226
+ return (
227
+ <div className="rounded-lg border border-border bg-elevated px-6 py-10 text-center">
228
+ <p className="text-sm text-muted">{props.title}</p>
229
+ {props.description && (
230
+ <p className="mt-2 max-w-md text-sm text-faint [overflow-wrap:anywhere]">
231
+ {props.description}
232
+ </p>
233
+ )}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ export function PageHeader(props: {
239
+ title: React.ReactNode;
240
+ titleAction?: React.ReactNode;
241
+ description?: React.ReactNode;
242
+ actions?: React.ReactNode;
243
+ }) {
244
+ return (
245
+ <div className="mb-6 flex flex-col justify-between gap-4 border-b border-border px-6 pb-4 pt-8 md:flex-row md:items-start">
246
+ <div className="min-w-0 flex-1">
247
+ <div className="group flex min-w-0 items-center gap-2">
248
+ <h1 className="min-w-0 text-3xl font-black text-text [overflow-wrap:anywhere]">
249
+ {props.title}
250
+ </h1>
251
+ {props.titleAction ? <div className="shrink-0">{props.titleAction}</div> : null}
252
+ </div>
253
+ {props.description && (
254
+ <div className="mt-2 min-w-0 text-sm text-muted [overflow-wrap:anywhere]">
255
+ {props.description}
256
+ </div>
257
+ )}
258
+ </div>
259
+ {props.actions ? <div className="shrink-0">{props.actions}</div> : null}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function sidebarClass({ isActive }: { isActive: boolean }) {
265
+ return [
266
+ "flex items-center justify-between rounded-lg px-3 py-2 text-sm font-bold",
267
+ isActive
268
+ ? "bg-header-active !text-header-text"
269
+ : "text-muted hover:bg-elevated hover:text-text",
270
+ ].join(" ");
271
+ }
272
+
273
+ function Sidebar(props: { setKey?: string }) {
274
+ const [index, setIndex] = React.useState<CatalogIndex | null>(null);
275
+ const basePath = getBasePath(props.setKey);
276
+
277
+ React.useEffect(() => {
278
+ setIndex(null);
279
+ fetchIndex(props.setKey)
280
+ .then(setIndex)
281
+ .catch(() => setIndex(null));
282
+ }, [props.setKey]);
283
+
284
+ return (
285
+ <aside className="rounded-lg bg-surface p-4 shadow-md ring-1 ring-black/5 md:w-56">
286
+ <div className="mb-3 text-xs font-black uppercase tracking-wide text-muted">
287
+ <span className="block px-3">{props.setKey ? "Set" : "Project"}</span>
288
+ </div>
289
+ <nav className="space-y-1">
290
+ {entityPaths.map((entityPath) => {
291
+ const type = entityPathToType[entityPath];
292
+
293
+ return (
294
+ <NavLink key={entityPath} to={`${basePath}/${entityPath}`} className={sidebarClass}>
295
+ <span>{entityLabels[type].plural}</span>
296
+ <span className="rounded-full bg-pill px-2 py-0.5 text-xs font-black text-header">
297
+ {index?.counts[type] ?? "-"}
298
+ </span>
299
+ </NavLink>
300
+ );
301
+ })}
302
+ <NavLink to={`${basePath}/history`} className={sidebarClass}>
303
+ History
304
+ </NavLink>
305
+ </nav>
306
+ </aside>
307
+ );
308
+ }
309
+
310
+ function isEntityPath(value: string): value is EntityPath {
311
+ return entityPaths.indexOf(value as EntityPath) !== -1;
312
+ }
313
+
314
+ function hasEntity(index: CatalogIndex, entityPath: EntityPath, entityKey: string) {
315
+ const type = entityPathToType[entityPath];
316
+ const entities = index.entities[type] || [];
317
+
318
+ return entities.some((entity) => entity.key === entityKey);
319
+ }
320
+
321
+ async function getSetSwitchPath(pathname: string, nextSetKey: string) {
322
+ const encodedSetKey = encodeRouteSegment(nextSetKey);
323
+ const listMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)$/);
324
+
325
+ if (listMatch && isEntityPath(listMatch[1])) {
326
+ return `/sets/${encodedSetKey}/${listMatch[1]}`;
327
+ }
328
+
329
+ if (pathname.match(/^\/sets\/[^/]+\/history$/)) {
330
+ return `/sets/${encodedSetKey}/history`;
331
+ }
332
+
333
+ const detailMatch = pathname.match(/^\/sets\/[^/]+\/([^/]+)\/([^/]+)(\/.*)?$/);
334
+
335
+ if (detailMatch && isEntityPath(detailMatch[1])) {
336
+ const entityPath = detailMatch[1];
337
+ const entityKey = decodeRouteSegment(detailMatch[2]);
338
+ const suffix = detailMatch[3] || "";
339
+
340
+ try {
341
+ const index = await fetchIndex(nextSetKey);
342
+
343
+ if (hasEntity(index, entityPath, entityKey)) {
344
+ return `/sets/${encodedSetKey}/${entityPath}/${encodeRouteSegment(entityKey)}${suffix}`;
345
+ }
346
+
347
+ return `/sets/${encodedSetKey}/${entityPath}`;
348
+ } catch {
349
+ return `/sets/${encodedSetKey}/${entityPath}`;
350
+ }
351
+ }
352
+
353
+ return `/sets/${encodedSetKey}/features`;
354
+ }
355
+
356
+ function SetSwitcher(props: { currentSetKey?: string }) {
357
+ const { manifest } = useCatalog();
358
+ const location = useLocation();
359
+ const navigate = useNavigate();
360
+ const setSelectId = React.useId();
361
+ const setSelectRef = React.useRef<HTMLSelectElement | null>(null);
362
+ const setKeys = sortSetKeys(manifest.setKeys);
363
+ const selectedSetKey = props.currentSetKey || setKeys[0] || "";
364
+
365
+ if (!manifest.sets || manifest.setKeys.length === 0) {
366
+ return null;
367
+ }
368
+
369
+ function openSetPicker() {
370
+ const select = setSelectRef.current;
371
+
372
+ if (!select) {
373
+ return;
374
+ }
375
+
376
+ select.focus();
377
+
378
+ if (
379
+ typeof (select as HTMLSelectElement & { showPicker?: () => void }).showPicker === "function"
380
+ ) {
381
+ (select as HTMLSelectElement & { showPicker: () => void }).showPicker();
382
+ }
383
+ }
384
+
385
+ return (
386
+ <label
387
+ htmlFor={setSelectId}
388
+ className="relative inline-flex cursor-pointer items-center gap-2 rounded-lg bg-header-active px-3 py-1.5 text-sm font-semibold text-header-text"
389
+ onClick={(event) => {
390
+ if (event.target instanceof HTMLSelectElement) {
391
+ return;
392
+ }
393
+
394
+ openSetPicker();
395
+ }}
396
+ >
397
+ <span className="text-xs font-black uppercase tracking-wide text-pill">Set</span>
398
+ <select
399
+ id={setSelectId}
400
+ ref={setSelectRef}
401
+ value={selectedSetKey}
402
+ onChange={async (event) => {
403
+ navigate(await getSetSwitchPath(location.pathname, event.target.value));
404
+ }}
405
+ className="max-w-44 appearance-none bg-transparent pr-7 font-black text-header-text outline-none"
406
+ aria-label="Switch catalog set"
407
+ >
408
+ {setKeys.map((setKey) => (
409
+ <option key={setKey} value={setKey}>
410
+ {setKey}
411
+ </option>
412
+ ))}
413
+ </select>
414
+ <svg
415
+ aria-hidden="true"
416
+ viewBox="0 0 20 20"
417
+ fill="none"
418
+ className="pointer-events-none absolute right-3 h-4 w-4 text-pill"
419
+ >
420
+ <path
421
+ d="M6 8l4 4 4-4"
422
+ stroke="currentColor"
423
+ strokeWidth="2"
424
+ strokeLinecap="round"
425
+ strokeLinejoin="round"
426
+ />
427
+ </svg>
428
+ </label>
429
+ );
430
+ }
431
+
432
+ function formatGeneratedAt(value: string) {
433
+ const date = new Date(value);
434
+
435
+ if (Number.isNaN(date.getTime())) {
436
+ return value;
437
+ }
438
+
439
+ return date.toLocaleString();
440
+ }
441
+
442
+ function RepositoryIcon(props: { provider?: string; className?: string }) {
443
+ const iconClassName = [
444
+ "h-5 w-5 shrink-0 transition-colors",
445
+ props.className || "fill-white/80",
446
+ ].join(" ");
447
+
448
+ if (props.provider === "github") {
449
+ return (
450
+ <svg aria-hidden="true" className={iconClassName} viewBox="0 0 16 16">
451
+ <path d="M8 0C3.58 0 0 3.69 0 8.24c0 3.64 2.29 6.72 5.47 7.81.4.08.55-.18.55-.4 0-.2-.01-.86-.01-1.56-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.15-.28-.16-.68-.55-.01-.56.63-.01 1.08.6 1.23.85.72 1.25 1.87.9 2.33.69.07-.54.28-.9.51-1.11-1.78-.21-3.64-.92-3.64-4.07 0-.9.31-1.64.82-2.22-.08-.21-.36-1.05.08-2.19 0 0 .67-.22 2.2.85A7.43 7.43 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.52-1.07 2.19-.85 2.19-.85.44 1.14.16 1.98.08 2.19.51.58.82 1.32.82 2.22 0 3.16-1.87 3.86-3.65 4.07.29.26.54.76.54 1.54 0 1.11-.01 2.01-.01 2.28 0 .22.15.48.55.4A8.13 8.13 0 0 0 16 8.24C16 3.69 12.42 0 8 0Z" />
452
+ </svg>
453
+ );
454
+ }
455
+
456
+ if (props.provider === "gitlab") {
457
+ return (
458
+ <svg aria-hidden="true" className={iconClassName} viewBox="0 0 24 24">
459
+ <path d="m22.75 9.77-.03-.08-2.17-6.69a.57.57 0 0 0-.55-.39.58.58 0 0 0-.52.35l-1.47 4.48H5.99L4.52 2.96A.58.58 0 0 0 4 2.61a.57.57 0 0 0-.55.39L1.28 9.69l-.03.08a1.54 1.54 0 0 0 .51 1.73l.01.01 10.22 7.43 10.24-7.44.01-.01a1.54 1.54 0 0 0 .51-1.72Z" />
460
+ </svg>
461
+ );
462
+ }
463
+
464
+ if (props.provider === "bitbucket") {
465
+ return (
466
+ <svg aria-hidden="true" className={iconClassName} viewBox="0 0 24 24">
467
+ <path d="M2.19 3.25a.77.77 0 0 0-.76.89l2.7 16.42a1.02 1.02 0 0 0 1 .86H18.9a1.02 1.02 0 0 0 1-.82l2.69-16.46a.77.77 0 0 0-.76-.89H2.19Zm13.36 10.71H9.46l-1.1-5.83h8.25l-1.06 5.83Z" />
468
+ </svg>
469
+ );
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ function isKnownRepositoryProvider(provider?: string) {
476
+ return provider === "github" || provider === "gitlab" || provider === "bitbucket";
477
+ }
478
+
479
+ export function AppShell(props: { children: React.ReactNode }) {
480
+ const { manifest } = useCatalog();
481
+ const location = useLocation();
482
+ const setKeyMatch = location.pathname.match(/^\/sets\/([^/]+)/);
483
+ const setKey = setKeyMatch ? decodeRouteSegment(setKeyMatch[1]) : undefined;
484
+ const showSidebar = !manifest.sets || Boolean(setKey);
485
+
486
+ return (
487
+ <div className="min-h-screen bg-background text-text">
488
+ <header className="bg-header">
489
+ <nav className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-3 py-3 sm:px-4">
490
+ <div className="flex min-w-0 flex-1 items-center">
491
+ <NavLink
492
+ to="/"
493
+ className={[
494
+ "flex min-w-0 max-w-full items-center gap-2.5 rounded-lg py-1 pr-2 outline-none",
495
+ "ring-offset-2 ring-offset-header focus-visible:ring-2 focus-visible:ring-header-text",
496
+ ].join(" ")}
497
+ aria-label="Featurevisor Catalog home"
498
+ >
499
+ <img
500
+ src="/favicon.png"
501
+ alt=""
502
+ width={32}
503
+ height={32}
504
+ className="h-8 w-8 shrink-0 object-contain"
505
+ decoding="async"
506
+ />
507
+ <img
508
+ src="/logo-text.png"
509
+ alt=""
510
+ className="h-auto max-h-4 min-w-0 shrink object-contain object-left pl-2"
511
+ decoding="async"
512
+ />
513
+ </NavLink>
514
+ </div>
515
+
516
+ <div className="flex items-center gap-3">
517
+ <SetSwitcher currentSetKey={setKey} />
518
+ {manifest.links?.repository && isKnownRepositoryProvider(manifest.links.provider) && (
519
+ <a
520
+ href={manifest.links.repository}
521
+ target="_blank"
522
+ rel="noreferrer"
523
+ className="group inline-flex shrink-0"
524
+ aria-label={`Open ${manifest.links.provider || "repository"}`}
525
+ >
526
+ <RepositoryIcon
527
+ provider={manifest.links.provider}
528
+ className="fill-white/80 group-hover:fill-white"
529
+ />
530
+ </a>
531
+ )}
532
+ </div>
533
+ </nav>
534
+ </header>
535
+
536
+ <main className="m-8 mx-auto max-w-5xl">
537
+ <div className={showSidebar ? "items-start gap-6 md:flex" : ""}>
538
+ {showSidebar && <Sidebar setKey={setKey} />}
539
+ <div className={["min-w-0", showSidebar ? "flex-1" : "w-full"].filter(Boolean).join(" ")}>
540
+ <section className="overflow-hidden rounded-lg bg-surface shadow">
541
+ {props.children}
542
+ </section>
543
+ <footer className="mt-4 pt-3 text-center">
544
+ <p className="pb-2 text-xs leading-5 text-faint">
545
+ Generated at {formatGeneratedAt(manifest.generatedAt)}
546
+ </p>
547
+ <p className="pb-5 text-xs font-medium leading-5 text-muted">
548
+ Built using{" "}
549
+ <a
550
+ target="_blank"
551
+ rel="noreferrer"
552
+ href="https://featurevisor.com"
553
+ className="font-semibold hover:underline"
554
+ >
555
+ Featurevisor
556
+ </a>
557
+ </p>
558
+ </footer>
559
+ </div>
560
+ </div>
561
+ </main>
562
+ </div>
563
+ );
564
+ }
565
+
566
+ export function Tabs(props: {
567
+ items: { to: string; label: string }[];
568
+ children?: React.ReactNode;
569
+ }) {
570
+ return (
571
+ <div>
572
+ <nav className="flex overflow-x-auto border-b border-border">
573
+ {props.items.map((item) => (
574
+ <NavLink
575
+ key={item.to}
576
+ to={item.to}
577
+ end={item.to === "."}
578
+ className={({ isActive }) =>
579
+ [
580
+ "inline-block min-w-24 shrink-0 border-b-2 px-3 pb-4 pt-2 text-center text-sm font-medium",
581
+ isActive
582
+ ? "border-primary text-primary"
583
+ : "border-transparent text-muted hover:border-border hover:text-text",
584
+ ].join(" ")
585
+ }
586
+ >
587
+ {item.label}
588
+ </NavLink>
589
+ ))}
590
+ </nav>
591
+ {props.children ? <div className="px-6 py-6">{props.children}</div> : null}
592
+ </div>
593
+ );
594
+ }
595
+
596
+ export function Card(props: { children: React.ReactNode; className?: string }) {
597
+ return (
598
+ <section className={`rounded border border-border bg-surface shadow ${props.className || ""}`}>
599
+ {props.children}
600
+ </section>
601
+ );
602
+ }
603
+
604
+ export function EntityKey(props: { value: string; className?: string }) {
605
+ return (
606
+ <span
607
+ className={["inline leading-snug [overflow-wrap:anywhere]", props.className || ""].join(" ")}
608
+ >
609
+ {props.value.split(".").flatMap((part, index, parts) => {
610
+ const nodes: React.ReactNode[] = [part];
611
+ if (index < parts.length - 1) {
612
+ nodes.push(
613
+ <React.Fragment key={`dot-${index}`}>
614
+ .<wbr />
615
+ </React.Fragment>,
616
+ );
617
+ }
618
+ return nodes;
619
+ })}
620
+ </span>
621
+ );
622
+ }
623
+
624
+ export function FieldGrid(props: {
625
+ fields: { label: string; value: React.ReactNode; fullWidth?: boolean }[];
626
+ }) {
627
+ return (
628
+ <dl className="grid grid-cols-1 gap-x-4 gap-y-8 md:grid-cols-2">
629
+ {props.fields.map((field) => (
630
+ <div key={field.label} className={field.fullWidth ? "md:col-span-2" : ""}>
631
+ <dt className="text-sm font-medium text-muted">{field.label}</dt>
632
+ <dd className="mt-1 min-w-0 text-sm [overflow-wrap:anywhere]">{field.value || "n/a"}</dd>
633
+ </div>
634
+ ))}
635
+ </dl>
636
+ );
637
+ }
638
+
639
+ export function formatList(values?: string[]) {
640
+ if (!values || values.length === 0) {
641
+ return <span className="text-muted">none</span>;
642
+ }
643
+
644
+ return (
645
+ <div className="flex flex-wrap gap-2">
646
+ {values.map((value) => (
647
+ <Badge key={value}>{value}</Badge>
648
+ ))}
649
+ </div>
650
+ );
651
+ }
652
+
653
+ export function getEntityTone(
654
+ type: CatalogEntityType,
655
+ ): "neutral" | "success" | "warning" | "danger" {
656
+ if (type === "feature") return "success";
657
+ if (type === "segment") return "warning";
658
+ if (type === "test") return "danger";
659
+ return "neutral";
660
+ }