@unoverse-platform/studio 0.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 (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Schema-driven config form — renders a node's `configSchema` as labelled fields
3
+ * (select / number / toggle / text / JSON), the same way the Canvas does, instead
4
+ * of a raw JSON blob. A compact port of GravityCanvas's FieldRenderer.
5
+ */
6
+ import { useEffect, useState, type ReactNode } from "react";
7
+ // Colours come from the HOST, never from this shared file. Canvas renders these fields
8
+ // on a white panel; local Studio renders them on dark chrome. Hardcoding either one
9
+ // makes the text unreadable in the other.
10
+ import { chrome } from "studio-host";
11
+
12
+ type Property = Record<string, any>;
13
+ interface ConfigSchema {
14
+ properties?: Record<string, Property>;
15
+ required?: string[];
16
+ }
17
+
18
+ const inputCls =
19
+ `rounded-md border px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none ${chrome.input}`;
20
+
21
+ /** Build the initial config object from the schema's declared defaults. */
22
+ export function defaultsFromSchema(schema?: ConfigSchema): Record<string, any> {
23
+ const props = schema?.properties ?? {};
24
+ return Object.fromEntries(
25
+ Object.entries(props).map(([k, p]) => [
26
+ k,
27
+ p.default ?? (p.type === "boolean" ? false : p.type === "array" ? [] : p.type === "object" ? "" : ""),
28
+ ]),
29
+ );
30
+ }
31
+
32
+ function FieldShell({
33
+ label,
34
+ required,
35
+ description,
36
+ children,
37
+ }: {
38
+ label: string;
39
+ required?: boolean;
40
+ description?: string;
41
+ children: ReactNode;
42
+ }) {
43
+ return (
44
+ <label className="flex flex-col gap-1">
45
+ <span className={`text-sm font-semibold ${chrome.label}`}>
46
+ {label}
47
+ {required && <span className="ml-0.5 text-red-400">*</span>}
48
+ </span>
49
+ {description && <span className={`text-[11px] leading-snug ${chrome.muted}`}>{description}</span>}
50
+ {children}
51
+ </label>
52
+ );
53
+ }
54
+
55
+ /** Textarea with a local text mirror so half-typed JSON is allowed while editing. */
56
+ function Jsonish({
57
+ fieldKey,
58
+ property,
59
+ value,
60
+ required,
61
+ onChange,
62
+ }: {
63
+ fieldKey: string;
64
+ property: Property;
65
+ value: any;
66
+ required?: boolean;
67
+ onChange: (k: string, v: any) => void;
68
+ }) {
69
+ const toText = (v: any) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2));
70
+ const [text, setText] = useState(toText(value));
71
+ // Re-seed only when the field identity changes (node switch), not on each keystroke.
72
+ useEffect(() => setText(toText(value)), [fieldKey]); // eslint-disable-line react-hooks/exhaustive-deps
73
+ const isStructured = property.type === "object" || property.type === "array";
74
+ return (
75
+ <FieldShell label={property.title || fieldKey} required={required} description={property.description}>
76
+ <textarea
77
+ value={text}
78
+ spellCheck={false}
79
+ rows={property["ui:field"] === "template" ? 3 : 5}
80
+ className={`${inputCls} resize-y font-mono text-xs`}
81
+ onChange={(e) => {
82
+ setText(e.target.value);
83
+ if (isStructured) {
84
+ try {
85
+ onChange(fieldKey, JSON.parse(e.target.value));
86
+ } catch {
87
+ onChange(fieldKey, e.target.value); // keep raw until valid
88
+ }
89
+ } else {
90
+ onChange(fieldKey, e.target.value);
91
+ }
92
+ }}
93
+ />
94
+ </FieldShell>
95
+ );
96
+ }
97
+
98
+ function Field({
99
+ fieldKey,
100
+ property,
101
+ value,
102
+ required,
103
+ onChange,
104
+ }: {
105
+ fieldKey: string;
106
+ property: Property;
107
+ value: any;
108
+ required?: boolean;
109
+ onChange: (k: string, v: any) => void;
110
+ }) {
111
+ const label = property.title || fieldKey;
112
+
113
+ // Enum → select
114
+ if (property.enum) {
115
+ const labels: any[] = property.enumNames ?? property.enum;
116
+ return (
117
+ <FieldShell label={label} required={required} description={property.description}>
118
+ <select className={inputCls} value={value ?? ""} onChange={(e) => onChange(fieldKey, e.target.value)}>
119
+ {property.enum.map((opt: any, i: number) => (
120
+ <option key={String(opt)} value={opt}>
121
+ {labels[i] ?? String(opt)}
122
+ </option>
123
+ ))}
124
+ </select>
125
+ </FieldShell>
126
+ );
127
+ }
128
+
129
+ // Number / integer
130
+ if (property.type === "number" || property.type === "integer") {
131
+ return (
132
+ <FieldShell label={label} required={required} description={property.description}>
133
+ <input
134
+ type="number"
135
+ className={inputCls}
136
+ value={value ?? ""}
137
+ min={property.minimum}
138
+ max={property.maximum}
139
+ step={property.step ?? (property.type === "integer" ? 1 : "any")}
140
+ onChange={(e) => onChange(fieldKey, e.target.value === "" ? undefined : Number(e.target.value))}
141
+ />
142
+ </FieldShell>
143
+ );
144
+ }
145
+
146
+ // Boolean → checkbox/toggle
147
+ if (property.type === "boolean") {
148
+ return (
149
+ <label className="flex items-center gap-2">
150
+ <input
151
+ type="checkbox"
152
+ checked={!!value}
153
+ onChange={(e) => onChange(fieldKey, e.target.checked)}
154
+ className="h-4 w-4 accent-emerald-500"
155
+ />
156
+ <span className={`text-sm font-semibold ${chrome.label}`}>{label}</span>
157
+ {property.description && <span className={`text-[11px] ${chrome.muted}`}>{property.description}</span>}
158
+ </label>
159
+ );
160
+ }
161
+
162
+ // Object / array / template / JSON / textarea → multi-line editor
163
+ if (
164
+ property.type === "object" ||
165
+ property.type === "array" ||
166
+ property["ui:field"] === "template" ||
167
+ property["ui:field"] === "JSON" ||
168
+ property["ui:widget"] === "textarea"
169
+ ) {
170
+ return <Jsonish fieldKey={fieldKey} property={property} value={value} required={required} onChange={onChange} />;
171
+ }
172
+
173
+ // String (default)
174
+ return (
175
+ <FieldShell label={label} required={required} description={property.description}>
176
+ <input
177
+ type="text"
178
+ className={inputCls}
179
+ value={value ?? ""}
180
+ placeholder={property.placeholder}
181
+ onChange={(e) => onChange(fieldKey, e.target.value)}
182
+ />
183
+ </FieldShell>
184
+ );
185
+ }
186
+
187
+ export function ConfigForm({
188
+ schema,
189
+ value,
190
+ onChange,
191
+ }: {
192
+ schema?: ConfigSchema;
193
+ value: Record<string, any>;
194
+ onChange: (key: string, val: any) => void;
195
+ }) {
196
+ const props = schema?.properties ?? {};
197
+ const required = new Set(schema?.required ?? []);
198
+ const entries = Object.entries(props);
199
+ if (!entries.length) return <div className={`text-xs ${chrome.muted}`}>No configuration for this node.</div>;
200
+ return (
201
+ <div className="flex flex-col gap-4">
202
+ {entries.map(([key, p]) => {
203
+ // ui:dependencies — hide when a dependency value isn't met.
204
+ const deps = p["ui:dependencies"];
205
+ if (
206
+ deps &&
207
+ Object.entries(deps).some(([f, dv]: [string, any]) =>
208
+ Array.isArray(dv) ? !dv.includes(value[f]) : value[f] !== dv,
209
+ )
210
+ ) {
211
+ return null;
212
+ }
213
+ return <Field key={key} fieldKey={key} property={p} value={value[key] ?? p.default} required={required.has(key)} onChange={onChange} />;
214
+ })}
215
+ </div>
216
+ );
217
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Design System showcase — renders the live design tokens (from the SDK's compiled
3
+ * theme) so you can SEE the colors, typography, spacing, radius and shadows. Dogfoods
4
+ * the real `themes` export, so this IS the design system, not a copy. Driven by the
5
+ * active theme, so the light/dark toggle restyles it. Left nav jumps between token
6
+ * groups (no one big scroll); each group shows how many tokens THIS project overrides.
7
+ */
8
+ import { useState } from "react";
9
+ import type { ResolvedTheme } from "@unoverse/sdk";
10
+
11
+ const COLOR_GROUP_ORDER = [
12
+ "brand", "primary", "secondary", "success", "action", "text", "surface", "border", "status",
13
+ "gray", "info", "warning", "error", "chart", "white", "black",
14
+ ];
15
+
16
+ function groupColors(color: Record<string, string>) {
17
+ const groups: Record<string, [string, string][]> = {};
18
+ for (const [k, v] of Object.entries(color)) {
19
+ const g = k.split(".")[0];
20
+ (groups[g] ??= []).push([k, v]);
21
+ }
22
+ const ordered = Object.keys(groups).sort(
23
+ (a, b) => (COLOR_GROUP_ORDER.indexOf(a) + 1 || 99) - (COLOR_GROUP_ORDER.indexOf(b) + 1 || 99),
24
+ );
25
+ return ordered.map((g) => [g, groups[g]] as const);
26
+ }
27
+
28
+ // Motion families — group animations by what they DO (slide/pulse/fade/loop) so the
29
+ // gallery reads as related sets rather than a flat wall. First matching family wins;
30
+ // anything unmatched falls to "Other".
31
+ const ANIM_GROUPS: { title: string; test: (n: string) => boolean }[] = [
32
+ { title: "Slide & enter", test: (n) => /slide|enter|reveal/i.test(n) },
33
+ { title: "Pulse & breathe", test: (n) => /pulse|ring|breathe|beat|glow/i.test(n) },
34
+ { title: "Fade", test: (n) => /fade|dissolve/i.test(n) },
35
+ { title: "Loop & ambient", test: (n) => /wave|equalize|spin|loop|bounce|shimmer/i.test(n) },
36
+ ];
37
+ function groupAnimations(names: string[]) {
38
+ const buckets: Record<string, string[]> = {};
39
+ for (const n of names) {
40
+ const g = ANIM_GROUPS.find((g) => g.test(n));
41
+ (buckets[g ? g.title : "Other"] ??= []).push(n);
42
+ }
43
+ const order = [...ANIM_GROUPS.map((g) => g.title), "Other"];
44
+ return order.filter((t) => buckets[t]?.length).map((t) => [t, buckets[t]] as const);
45
+ }
46
+
47
+ export function DesignSystemView({
48
+ theme,
49
+ foundation = null,
50
+ org = "all",
51
+ }: {
52
+ theme: ResolvedTheme;
53
+ /** The design-system foundation theme (same variant) to diff against — null when
54
+ * viewing the foundation itself (nothing to compare, all tokens read as inherited). */
55
+ foundation?: ResolvedTheme | null;
56
+ org?: string;
57
+ }) {
58
+ const ink = theme.color["text.primary"] ?? "#111827";
59
+ const sub = theme.color["text.secondary"] ?? "#6b7280";
60
+ const surface = theme.color["surface.base"] ?? "#ffffff";
61
+ const raised = theme.color["surface.raised"] ?? surface;
62
+ const borderC = theme.color["border.subtle"] ?? "#e5e7eb";
63
+ // FIXED neutral chrome — the view's OWN accent (nav highlight, override ring/pencil,
64
+ // spacing/radius demo blocks). Deliberately NOT the viewed project's action colour, so a
65
+ // loud brand (e.g. an all-red project) doesn't turn the whole page its brand colour. The
66
+ // real token swatches still render their true values; only the chrome is neutralised.
67
+ const accent = "#6366f1";
68
+ const onAction = "#ffffff";
69
+ const bodyFont = (theme.text["body.md"]?.fontFamily as string) ?? "system-ui";
70
+
71
+ // A token is THIS PROJECT'S OVERRIDE when its resolved value differs from the
72
+ // foundation's; otherwise it's INHERITED from the design system. No foundation to
73
+ // compare (viewing the design system itself) → everything is inherited.
74
+ const isOverride = (bucket: keyof ResolvedTheme, key: string) =>
75
+ !!foundation &&
76
+ JSON.stringify((foundation as any)[bucket]?.[key]) !== JSON.stringify((theme as any)[bucket]?.[key]);
77
+ const overrideCount = (bucket: keyof ResolvedTheme) =>
78
+ !foundation ? 0 : Object.keys((theme as any)[bucket] ?? {}).filter((k) => isOverride(bucket, k)).length;
79
+
80
+ // A small pencil marks a token this project has CUSTOMISED (overridden). Inherited
81
+ // tokens carry no mark and are NOT dimmed — full-strength, just unmarked — so the eye
82
+ // lands on what changed without washing out the rest of the palette.
83
+ const OverrideMark = () => (
84
+ <span className="ml-1 inline-flex align-middle" title={`Customised in ${org} — overrides the design-system value`}>
85
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={accent} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
86
+ <path d="M12 20h9" />
87
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
88
+ </svg>
89
+ </span>
90
+ );
91
+
92
+ // Serialise the theme's keyframes (DATA: stop → decls) into real @keyframes CSS so the
93
+ // Animations section can PLAY each one. Prefixed `uds-` to not collide with any globally
94
+ // injected keyframes of the same name.
95
+ const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
96
+ const keyframesCss = Object.entries((theme.keyframes ?? {}) as Record<string, Record<string, Record<string, string>>>)
97
+ .map(
98
+ ([name, stops]) =>
99
+ `@keyframes uds-${name}{` +
100
+ Object.entries(stops)
101
+ .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${kebab(p)}:${v}`).join(";")}}`)
102
+ .join("") +
103
+ `}`,
104
+ )
105
+ .join("\n");
106
+
107
+ const [active, setActive] = useState("colors");
108
+
109
+ const SECTIONS: { id: string; title: string; bucket?: keyof ResolvedTheme; body: React.ReactNode }[] = [
110
+ {
111
+ id: "colors",
112
+ title: "Colors",
113
+ bucket: "color",
114
+ body: (
115
+ <div className="space-y-5">
116
+ {groupColors(theme.color).map(([group, entries]) => (
117
+ <div key={group}>
118
+ <div className="mb-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: sub }}>{group}</div>
119
+ <div className="flex flex-wrap gap-3">
120
+ {entries.map(([name, val]) => {
121
+ const ov = isOverride("color", name);
122
+ return (
123
+ <div key={name} className="w-[110px]">
124
+ <div
125
+ className="h-14 w-full rounded-lg"
126
+ style={{ background: val, border: ov ? `2px solid ${accent}` : `1px solid ${borderC}` }}
127
+ />
128
+ <div className="mt-1 truncate text-[11px] font-medium" title={name}>
129
+ {name}
130
+ {ov && <OverrideMark />}
131
+ </div>
132
+ <div className="text-[10px] uppercase" style={{ color: sub }}>{val}</div>
133
+ </div>
134
+ );
135
+ })}
136
+ </div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ ),
141
+ },
142
+ {
143
+ id: "typography",
144
+ title: "Typography",
145
+ bucket: "text",
146
+ body: (
147
+ <div className="space-y-5 rounded-xl p-6" style={{ background: raised, border: `1px solid ${borderC}` }}>
148
+ {Object.entries(theme.text).map(([name, style]) => {
149
+ const s = style as Record<string, unknown>;
150
+ const fams = String(s?.fontFamily ?? "")
151
+ .split(",")
152
+ .map((f) => f.trim().replace(/^['"]|['"]$/g, ""))
153
+ .filter(Boolean);
154
+ const meta = [s?.fontSize, s?.fontWeight && `weight ${s.fontWeight}`, s?.lineHeight && `lh ${s.lineHeight}`]
155
+ .filter(Boolean)
156
+ .join(" · ");
157
+ return (
158
+ <div key={name} className="flex items-baseline gap-4">
159
+ <div className="w-32 shrink-0 text-[11px] uppercase tracking-wider" style={{ color: sub }}>
160
+ {name}
161
+ {isOverride("text", name) && <OverrideMark />}
162
+ </div>
163
+ <div className="min-w-0 flex-1">
164
+ <div style={style as React.CSSProperties}>The quick brown fox</div>
165
+ <div className="mt-0.5 text-[10px]" style={{ color: sub }}>
166
+ {fams.length ? (
167
+ <>
168
+ <span className="font-semibold" style={{ color: ink }}>{fams[0]}</span>
169
+ {fams.length > 1 && <span> → {fams.slice(1).join(", ")}</span>}
170
+ </>
171
+ ) : (
172
+ "—"
173
+ )}
174
+ {meta && <span> · {meta}</span>}
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ })}
180
+ </div>
181
+ ),
182
+ },
183
+ {
184
+ id: "spacing",
185
+ title: "Spacing",
186
+ bucket: "space",
187
+ body: (
188
+ <div className="space-y-2 rounded-xl p-6" style={{ background: raised, border: `1px solid ${borderC}` }}>
189
+ {Object.entries(theme.space).map(([name, val]) => (
190
+ <div key={name} className="flex items-center gap-4">
191
+ <div className="w-20 shrink-0 text-[11px]" style={{ color: sub }}>
192
+ {name}
193
+ {isOverride("space", name) && <OverrideMark />}
194
+ </div>
195
+ <div className="h-3 rounded" style={{ width: val, background: accent }} />
196
+ <div className="text-[10px]" style={{ color: sub }}>{val}</div>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ ),
201
+ },
202
+ {
203
+ id: "radius",
204
+ title: "Radius",
205
+ bucket: "radius",
206
+ body: (
207
+ <div className="flex flex-wrap gap-5">
208
+ {Object.entries(theme.radius).map(([name, val]) => (
209
+ <div key={name} className="text-center">
210
+ <div className="h-16 w-16" style={{ background: accent, borderRadius: val }} />
211
+ <div className="mt-1 text-[11px] font-medium">
212
+ {name}
213
+ {isOverride("radius", name) && <OverrideMark />}
214
+ </div>
215
+ <div className="text-[10px]" style={{ color: sub }}>{val}</div>
216
+ </div>
217
+ ))}
218
+ </div>
219
+ ),
220
+ },
221
+ {
222
+ id: "shadow",
223
+ title: "Shadow / Elevation",
224
+ body: (
225
+ <div className="flex flex-wrap gap-6 rounded-xl p-8" style={{ background: theme.color["surface.sunken"] ?? "#f3f4f6" }}>
226
+ {Object.entries(theme.shadow).map(([name, val]) => (
227
+ <div key={name} className="text-center">
228
+ <div className="h-20 w-28 rounded-lg" style={{ background: surface, boxShadow: val }} />
229
+ <div className="mt-2 text-[11px] font-medium">{name}</div>
230
+ </div>
231
+ ))}
232
+ </div>
233
+ ),
234
+ },
235
+ {
236
+ id: "animations",
237
+ title: "Animations",
238
+ bucket: "keyframes",
239
+ body: (
240
+ <>
241
+ <style>{keyframesCss}</style>
242
+ <div className="space-y-6">
243
+ {groupAnimations(Object.keys(theme.keyframes ?? {})).map(([title, names]) => (
244
+ <div key={title}>
245
+ <div className="mb-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: sub }}>{title}</div>
246
+ <div className="flex flex-wrap gap-6">
247
+ {names.map((name) => (
248
+ <div key={name} className="text-center">
249
+ <div
250
+ className="flex h-24 w-32 items-center justify-center overflow-hidden rounded-xl"
251
+ style={{ background: raised, border: `1px solid ${borderC}` }}
252
+ >
253
+ <div style={{ width: 28, height: 28, borderRadius: 8, background: accent, animation: `uds-${name} 1.8s ease-in-out infinite` }} />
254
+ </div>
255
+ <div className="mt-2 text-[11px] font-medium">
256
+ {name}
257
+ {isOverride("keyframes", name) && <OverrideMark />}
258
+ </div>
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ ))}
264
+ </div>
265
+ </>
266
+ ),
267
+ },
268
+ ];
269
+ const current = SECTIONS.find((s) => s.id === active) ?? SECTIONS[0];
270
+
271
+ return (
272
+ <div className="flex h-full" style={{ background: theme.color["surface.sunken"] ?? "#f9fafb", color: ink }}>
273
+ {/* LEFT NAV — jump between token groups; badge = how many tokens THIS project overrides */}
274
+ <nav className="w-56 shrink-0 overflow-auto border-r p-5" style={{ borderColor: borderC, background: surface, fontFamily: bodyFont }}>
275
+ <h1 className="mb-1 text-lg font-bold">Design System</h1>
276
+ <p className="mb-5 text-[11px] leading-snug" style={{ color: sub }}>
277
+ {foundation ? (
278
+ <>Viewing <b style={{ color: ink }}>{org}</b></>
279
+ ) : (
280
+ <>The base foundation</>
281
+ )}
282
+ </p>
283
+ <ul className="space-y-1">
284
+ {SECTIONS.map((s) => {
285
+ const n = foundation && s.bucket ? overrideCount(s.bucket) : 0;
286
+ const on = active === s.id;
287
+ return (
288
+ <li key={s.id}>
289
+ <button
290
+ onClick={() => setActive(s.id)}
291
+ className="flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-medium transition-colors"
292
+ style={on ? { background: accent, color: onAction } : { color: ink }}
293
+ >
294
+ <span>{s.title}</span>
295
+ {n > 0 && (
296
+ <span
297
+ className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
298
+ style={{ background: on ? "rgba(255,255,255,0.25)" : accent, color: onAction }}
299
+ >
300
+ {n}
301
+ </span>
302
+ )}
303
+ </button>
304
+ </li>
305
+ );
306
+ })}
307
+ </ul>
308
+ </nav>
309
+
310
+ {/* CONTENT — the selected group only */}
311
+ <div className="flex-1 overflow-auto p-10" style={{ fontFamily: bodyFont }}>
312
+ <h2 className="mb-1 text-2xl font-bold">{current.title}</h2>
313
+ <p className="mb-8 text-xs" style={{ color: sub }}>
314
+ {foundation ? (
315
+ <>Tokens with an <OverrideMark /> are <b style={{ color: ink }}>{org}</b>&rsquo;s own; the rest are inherited from the design-system foundation.</>
316
+ ) : (
317
+ <>Live tokens from the design-system foundation (<code>rx/design-system/styles</code>). Toggle the theme to restyle everything.</>
318
+ )}
319
+ </p>
320
+ {current.body}
321
+ </div>
322
+ </div>
323
+ );
324
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * IdentityHeader — the standardized identity presentation for catalog items
3
+ * (apps, skills, prompts, nodes): initials avatar, title + category pill +
4
+ * version, description, and the accented "When to use" callout. Extracted from
5
+ * the Apps view's MCP-app meta panel so every section presents the same anatomy.
6
+ * Designed for the white main panel.
7
+ */
8
+ import type { ReactNode } from "react";
9
+
10
+ export function IdentityHeader({
11
+ name,
12
+ category,
13
+ version,
14
+ id,
15
+ description,
16
+ whenToUse,
17
+ accent = "#6366f1",
18
+ logoUrl,
19
+ chips,
20
+ right,
21
+ }: {
22
+ name: string;
23
+ category?: string;
24
+ version?: string;
25
+ /** A mono identity line under the title (node type, `{{prompt.*}}` key, URI). */
26
+ id?: string;
27
+ description?: string;
28
+ whenToUse?: string;
29
+ accent?: string;
30
+ /** The item's real logo (node/package `logoUrl`) — replaces the initials avatar. */
31
+ logoUrl?: string | null;
32
+ /** Extra chips after the version (e.g. execution mode). */
33
+ chips?: ReactNode;
34
+ /** Right-aligned actions (e.g. a ToggleSwitch). */
35
+ right?: ReactNode;
36
+ }) {
37
+ const initials = name
38
+ .split(/\s+/)
39
+ .map((w) => w[0])
40
+ .slice(0, 2)
41
+ .join("")
42
+ .toUpperCase();
43
+ return (
44
+ <div className="mb-5 flex items-start gap-3.5">
45
+ {logoUrl ? (
46
+ <div
47
+ className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white p-1.5"
48
+ style={{ border: `1px solid ${accent}33` }}
49
+ >
50
+ <img src={logoUrl} alt="" className="max-h-full max-w-full object-contain" />
51
+ </div>
52
+ ) : (
53
+ <div
54
+ className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[13px] font-bold tracking-tight"
55
+ style={{ background: `${accent}1a`, color: accent, border: `1px solid ${accent}33` }}
56
+ >
57
+ {initials}
58
+ </div>
59
+ )}
60
+ <div className="min-w-0 flex-1">
61
+ <div className="flex flex-wrap items-center gap-2">
62
+ <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900">{name}</h1>
63
+ {category && (
64
+ <span
65
+ className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
66
+ style={{ background: `${accent}14`, color: accent }}
67
+ >
68
+ {category}
69
+ </span>
70
+ )}
71
+ {version && <span className="font-mono text-[10px] text-gray-400">v{version}</span>}
72
+ {chips}
73
+ {right && <span className="ml-auto">{right}</span>}
74
+ </div>
75
+ {id && <div className="mt-0.5 font-mono text-[11px] text-gray-400">{id}</div>}
76
+ {description && <p className="mt-1 max-w-3xl text-sm leading-relaxed text-gray-600">{description}</p>}
77
+ {whenToUse && (
78
+ <div className="mt-2 max-w-3xl rounded-r py-1.5 pl-3" style={{ borderLeft: `3px solid ${accent}`, background: "#f3f4f6" }}>
79
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
80
+ When to use
81
+ </div>
82
+ <p className="text-[15px] font-medium leading-relaxed text-gray-900">{whenToUse}</p>
83
+ </div>
84
+ )}
85
+ </div>
86
+ </div>
87
+ );
88
+ }