@norskvideo/ctl-dev-kit 0.2.107 → 0.2.109

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 (34) hide show
  1. package/conventions/DESIGN.family.md +73 -0
  2. package/conventions/check-drift.ts +100 -0
  3. package/conventions/sync-drift.ts +21 -1
  4. package/create-demo/create-demo.ts +191 -0
  5. package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
  6. package/create-demo/template/core/README.md.tmpl +74 -0
  7. package/create-demo/template/core/ctl/install.sh +27 -0
  8. package/create-demo/template/core/ctl/lib.sh +146 -0
  9. package/create-demo/template/core/ctl/mode.sh +19 -0
  10. package/create-demo/template/core/ctl/push.sh +16 -0
  11. package/create-demo/template/core/ctl/site.example.env +30 -0
  12. package/create-demo/template/core/ctl/studio.example.env +12 -0
  13. package/create-demo/template/core/gitignore +18 -0
  14. package/create-demo/template/core/scripts/build.sh +30 -0
  15. package/create-demo/template/core/scripts/test-feed.sh +44 -0
  16. package/create-demo/template/dashboard/dashboard/index.html +13 -0
  17. package/create-demo/template/dashboard/dashboard/package.json +22 -0
  18. package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
  19. package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
  20. package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
  21. package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
  22. package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
  23. package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
  24. package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
  25. package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
  26. package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
  27. package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
  28. package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
  29. package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
  30. package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
  31. package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
  32. package/create-product/cli.ts +39 -1
  33. package/create-product/features/components.ts +10 -1
  34. package/package.json +1 -1
@@ -0,0 +1,98 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { EnvResponse } from "../api/types";
3
+ import type { StatusTone } from "../components/StatusPill";
4
+
5
+ // Studio's per-component alert. Emitted by `setAlert(id, {level, message})`
6
+ // in the runtime; polled from `<apiBasePath>/<componentId>/alerts`. Cleared
7
+ // entries drop out of the endpoint response, so an empty array means the
8
+ // component is currently healthy (from Studio's own POV).
9
+ export interface Alert {
10
+ level: "error" | "warning" | "info";
11
+ message: string;
12
+ startMs?: number;
13
+ }
14
+
15
+ const POLL_INTERVAL_MS = 3_000;
16
+
17
+ // Polls Studio's alerts endpoint for a single component. Returns the current
18
+ // alert array plus a `loaded` flag distinguishing "no alerts, healthy" from
19
+ // "haven't fetched yet". Reset on componentId / env change.
20
+ export function useAlerts(componentId: string | null, env: EnvResponse | null): { alerts: Alert[]; loaded: boolean } {
21
+ const [alerts, setAlerts] = useState<Alert[]>([]);
22
+ const [loaded, setLoaded] = useState(false);
23
+
24
+ useEffect(() => {
25
+ if (!componentId || !env) return;
26
+ setLoaded(false);
27
+ setAlerts([]);
28
+ let cancelled = false;
29
+ const url = `${env.apiBasePath}/${componentId}/alerts`;
30
+ const poll = async () => {
31
+ try {
32
+ const res = await fetch(url, { credentials: "include" });
33
+ if (!res.ok) return;
34
+ const json = (await res.json()) as Alert[];
35
+ if (cancelled) return;
36
+ setAlerts(Array.isArray(json) ? json : []);
37
+ setLoaded(true);
38
+ } catch {
39
+ // Silent — proxy hiccups, transient errors. Stale alerts stay
40
+ // visible until the next poll succeeds.
41
+ }
42
+ };
43
+ void poll();
44
+ const timer = setInterval(poll, POLL_INTERVAL_MS);
45
+ return () => {
46
+ cancelled = true;
47
+ clearInterval(timer);
48
+ };
49
+ }, [componentId, env]);
50
+
51
+ return { alerts, loaded };
52
+ }
53
+
54
+ // Derive a StatusPill tone from an alert list. Errors dominate warnings;
55
+ // warnings dominate the healthy case. When the endpoint hasn't answered
56
+ // yet, callers should fall back to the "unknown" tone via the second arg.
57
+ export function toneFromAlerts(alerts: Alert[], loaded: boolean, fallbackTone: StatusTone = "unknown"): StatusTone {
58
+ if (!loaded) return fallbackTone;
59
+ if (alerts.some((a) => a.level === "error")) return "error";
60
+ if (alerts.some((a) => a.level === "warning")) return "ready";
61
+ return "live";
62
+ }
63
+
64
+ // Rewrite Studio's internal alert messages into copy that doesn't require
65
+ // knowing what a "component" is. Studio phrases everything in terms of the
66
+ // producing/consuming component; we recast for the operator reading the
67
+ // dashboard. Optional `context` lets callers narrow to a role-specific
68
+ // noun ("source", "camera", etc.).
69
+ export function humanizeAlert(
70
+ message: string,
71
+ context?: { subject?: string; kind?: "input" | "output" | "session" },
72
+ ): string {
73
+ const subject = context?.subject ?? "This source";
74
+ if (/receiving no data/i.test(message)) {
75
+ return context?.kind === "input" ? "No data arriving from upstream" : `${subject}: nothing arriving yet`;
76
+ }
77
+ if (/sending no data/i.test(message)) {
78
+ return context?.kind === "output" ? "Not producing output yet" : `${subject}: not producing output yet`;
79
+ }
80
+ if (/no active connections/i.test(message)) return "No active connection";
81
+ return message.replace(/^This component /i, "");
82
+ }
83
+
84
+ // Short label for the pill based on alert state. `livedLabel` and
85
+ // `errorLabel` let callers customise per row-type (e.g. "3 streams" vs
86
+ // "Publishing").
87
+ export function labelFromAlerts(
88
+ alerts: Alert[],
89
+ loaded: boolean,
90
+ labels: { live: string; warning?: string; error?: string; unknown?: string },
91
+ ): string {
92
+ if (!loaded) return labels.unknown ?? "Connecting";
93
+ const errorCount = alerts.filter((a) => a.level === "error").length;
94
+ if (errorCount > 0) return labels.error ?? `${errorCount} error${errorCount === 1 ? "" : "s"}`;
95
+ const warningCount = alerts.filter((a) => a.level === "warning").length;
96
+ if (warningCount > 0) return labels.warning ?? "Warning";
97
+ return labels.live;
98
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ComponentsResponse, EnvResponse, WorkflowComponentDescriptor } from "../api/types";
3
+
4
+ interface UseDiscoveryResult {
5
+ components: WorkflowComponentDescriptor[];
6
+ error: string | null;
7
+ loading: boolean;
8
+ attempts: number;
9
+ nextRetryMs: number | null;
10
+ }
11
+
12
+ // Bounded-exponential-backoff poller of `<apiBasePath>/components`. Studio
13
+ // returns 503 with `{error: "Workflow is not running yet"}` until the
14
+ // workflow boots; we keep polling until the response is 200 with a non-empty
15
+ // `components` array. Once we have it, we exit the loop and the component
16
+ // stays mounted regardless of later disconnects — the per-component WS hooks own
17
+ // their own reconnect.
18
+ const INITIAL_DELAY_MS = 500;
19
+ const MAX_DELAY_MS = 5000;
20
+
21
+ export function useDiscovery(env: EnvResponse | null): UseDiscoveryResult {
22
+ const [components, setComponents] = useState<WorkflowComponentDescriptor[]>([]);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [loading, setLoading] = useState(true);
25
+ const [attempts, setAttempts] = useState(0);
26
+ const [nextRetryMs, setNextRetryMs] = useState<number | null>(null);
27
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!env) return;
31
+ let cancelled = false;
32
+ let delay = INITIAL_DELAY_MS;
33
+ let attempt = 0;
34
+
35
+ const tick = async () => {
36
+ attempt += 1;
37
+ if (cancelled) return;
38
+ setAttempts(attempt);
39
+ try {
40
+ const res = await fetch(`${env.apiBasePath}/components`);
41
+ if (res.status === 503) {
42
+ // Workflow not yet running — bounded retry.
43
+ scheduleRetry();
44
+ return;
45
+ }
46
+ if (!res.ok) {
47
+ throw new Error(`HTTP ${res.status}`);
48
+ }
49
+ const body = (await res.json()) as ComponentsResponse;
50
+ if (!body.components || body.components.length === 0) {
51
+ // Empty surface — keep polling.
52
+ scheduleRetry();
53
+ return;
54
+ }
55
+ if (cancelled) return;
56
+ setComponents(body.components);
57
+ setError(null);
58
+ setLoading(false);
59
+ setNextRetryMs(null);
60
+ } catch (err) {
61
+ if (cancelled) return;
62
+ setError(err instanceof Error ? err.message : String(err));
63
+ scheduleRetry();
64
+ }
65
+ };
66
+
67
+ const scheduleRetry = () => {
68
+ if (cancelled) return;
69
+ setNextRetryMs(delay);
70
+ timerRef.current = setTimeout(() => {
71
+ delay = Math.min(delay * 2, MAX_DELAY_MS);
72
+ void tick();
73
+ }, delay);
74
+ };
75
+
76
+ void tick();
77
+
78
+ return () => {
79
+ cancelled = true;
80
+ if (timerRef.current) {
81
+ clearTimeout(timerRef.current);
82
+ timerRef.current = null;
83
+ }
84
+ };
85
+ }, [env]);
86
+
87
+ return { components, error, loading, attempts, nextRetryMs };
88
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { EnvResponse } from "../api/types";
3
+
4
+ interface UseEnvResult {
5
+ env: EnvResponse | null;
6
+ error: string | null;
7
+ loading: boolean;
8
+ }
9
+
10
+ // Resolves the dashboard's bound `env` endpoint. Studio serves
11
+ // `/dashboard/<workflowName>/env` and returns `{studioUrlPrefix, apiBasePath, wsBasePath}`.
12
+ // We fetch via a relative URL so the bundle works under arbitrary
13
+ // `studioUrlPrefix` deployments.
14
+ export function useEnv(): UseEnvResult {
15
+ const [env, setEnv] = useState<EnvResponse | null>(null);
16
+ const [error, setError] = useState<string | null>(null);
17
+ const [loading, setLoading] = useState(true);
18
+
19
+ useEffect(() => {
20
+ let cancelled = false;
21
+
22
+ (async () => {
23
+ try {
24
+ const res = await fetch("./env");
25
+ if (!res.ok) {
26
+ throw new Error(`env returned HTTP ${res.status}`);
27
+ }
28
+ const body = (await res.json()) as Partial<EnvResponse>;
29
+ if (!body.wsBasePath || !body.apiBasePath) {
30
+ throw new Error("env response missing wsBasePath/apiBasePath");
31
+ }
32
+ if (cancelled) return;
33
+ setEnv({
34
+ studioUrlPrefix: body.studioUrlPrefix ?? "",
35
+ apiBasePath: body.apiBasePath,
36
+ wsBasePath: body.wsBasePath,
37
+ note: body.note,
38
+ });
39
+ setLoading(false);
40
+ } catch (err) {
41
+ if (cancelled) return;
42
+ setError(err instanceof Error ? err.message : String(err));
43
+ setLoading(false);
44
+ }
45
+ })();
46
+
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, []);
51
+
52
+ return { env, error, loading };
53
+ }
@@ -0,0 +1,249 @@
1
+ import type * as React from "react";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ import type { EnvResponse } from "../api/types";
4
+
5
+ // Inbound WS frame shapes — server → client. Studio's public WS
6
+ // (`<wsBasePath>/<id>/ws`) emits exactly these.
7
+ type IncomingMessage =
8
+ | { type: "initial-state"; state: unknown }
9
+ | { type: "state-update"; state: unknown }
10
+ | { type: "event"; event: unknown }
11
+ | { type: "alert"; alert: unknown }
12
+ | { type: "error"; message: string };
13
+
14
+ interface LiveComponentState<S, C = unknown> {
15
+ state: S | null;
16
+ config: C | null;
17
+ connected: boolean;
18
+ hasInitialState: boolean;
19
+ lastError: string | null;
20
+ disconnected: boolean;
21
+ }
22
+
23
+ type Action<S, C> =
24
+ | { type: "CONNECT" }
25
+ | { type: "DISCONNECT" }
26
+ | { type: "INIT"; state: S }
27
+ | { type: "UPDATE"; state: S }
28
+ | { type: "EVENT"; event: unknown }
29
+ | { type: "ALERT"; alert: unknown }
30
+ | { type: "ERROR"; message: string }
31
+ | { type: "CONFIG"; config: C };
32
+
33
+ function reducer<S, C>(state: LiveComponentState<S, C>, action: Action<S, C>): LiveComponentState<S, C> {
34
+ switch (action.type) {
35
+ case "CONNECT":
36
+ return { ...state, connected: true, disconnected: false, lastError: null };
37
+ case "DISCONNECT":
38
+ return { ...state, connected: false, disconnected: true };
39
+ case "INIT":
40
+ return { ...state, state: action.state, hasInitialState: true };
41
+ case "UPDATE":
42
+ return { ...state, state: action.state };
43
+ case "EVENT":
44
+ // Events go to the onEvent callback, not into this state.
45
+ return state;
46
+ case "ALERT":
47
+ return state;
48
+ case "ERROR":
49
+ return { ...state, lastError: action.message };
50
+ case "CONFIG":
51
+ return { ...state, config: action.config };
52
+ default:
53
+ return state;
54
+ }
55
+ }
56
+
57
+ export interface UseLiveComponentResult<S, C = unknown> {
58
+ state: S | null;
59
+ config: C | null;
60
+ connected: boolean;
61
+ hasInitialState: boolean;
62
+ lastError: string | null;
63
+ sendCommand: (cmd: unknown) => void;
64
+ }
65
+
66
+ interface UseLiveComponentOptions<C> {
67
+ componentId: string;
68
+ env: EnvResponse | null;
69
+ // Optional initial config to seed the reducer. The discovery payload does
70
+ // not carry config; callers may supply a known default (e.g. one known
71
+ // from the workflow YAML), or leave null and render a loading state until
72
+ // state arrives.
73
+ initialConfig?: C | null;
74
+ // Optional callback fired whenever an `event` WS frame arrives. Studio
75
+ // broadcasts many state transitions as events (see e.g. videoCompose's
76
+ // `layout-changed`, `resolved-layout-changed`) without necessarily
77
+ // following up with a `state-update` snapshot. Consumers that need those
78
+ // deltas to drive local rendering should implement `onEvent` to reduce
79
+ // events into their own state — the hook itself keeps `EVENT` as a
80
+ // no-op in the reducer so no policy about event shapes leaks in.
81
+ //
82
+ // The callback identity is captured in a ref, so passing a fresh function
83
+ // per render does NOT trigger a WS reconnect.
84
+ onEvent?: (evt: unknown) => void;
85
+ }
86
+
87
+ const RECONNECT_INITIAL_MS = 500;
88
+ const RECONNECT_MAX_MS = 10_000;
89
+
90
+ // Extracted for direct unit testing without a live WebSocket. Exported under
91
+ // a __-prefixed alias so consumers don't reach for it as public API.
92
+ export function __handleIncomingFrame<S>(
93
+ parsed: IncomingMessage,
94
+ dispatch: (action: Action<S, unknown>) => void,
95
+ onEventRef: { current: ((evt: unknown) => void) | null | undefined },
96
+ ): void {
97
+ switch (parsed.type) {
98
+ case "initial-state":
99
+ dispatch({ type: "INIT", state: parsed.state as S });
100
+ break;
101
+ case "state-update":
102
+ dispatch({ type: "UPDATE", state: parsed.state as S });
103
+ break;
104
+ case "event":
105
+ dispatch({ type: "EVENT", event: parsed.event });
106
+ onEventRef.current?.(parsed.event);
107
+ break;
108
+ case "alert":
109
+ dispatch({ type: "ALERT", alert: parsed.alert });
110
+ break;
111
+ case "error":
112
+ dispatch({ type: "ERROR", message: parsed.message });
113
+ break;
114
+ default:
115
+ break;
116
+ }
117
+ }
118
+
119
+ export function useLiveComponent<S, C = unknown>(opts: UseLiveComponentOptions<C>): UseLiveComponentResult<S, C> {
120
+ const { componentId, env, initialConfig = null, onEvent } = opts;
121
+
122
+ // Capture the callback in a ref so the WS `useEffect`'s dep array can stay
123
+ // `[componentId, env]` — otherwise every parent re-render with a fresh
124
+ // callback identity would tear down + reconnect the socket.
125
+ const onEventRef = useRef<UseLiveComponentOptions<C>["onEvent"]>(onEvent);
126
+ onEventRef.current = onEvent;
127
+
128
+ const [state, dispatch] = useReducer(reducer as React.Reducer<LiveComponentState<S, C>, Action<S, C>>, {
129
+ state: null,
130
+ config: initialConfig,
131
+ connected: false,
132
+ hasInitialState: false,
133
+ lastError: null,
134
+ disconnected: false,
135
+ });
136
+
137
+ const wsRef = useRef<WebSocket | null>(null);
138
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
139
+ const reconnectDelayRef = useRef<number>(RECONNECT_INITIAL_MS);
140
+
141
+ // Refresh seeded config if caller changes the prop.
142
+ useEffect(() => {
143
+ if (initialConfig) {
144
+ dispatch({ type: "CONFIG", config: initialConfig });
145
+ }
146
+ }, [initialConfig]);
147
+
148
+ useEffect(() => {
149
+ if (!env) return;
150
+ let cancelled = false;
151
+
152
+ const connect = () => {
153
+ if (cancelled) return;
154
+
155
+ // Compose the absolute WS URL relative to window.location. `env.wsBasePath`
156
+ // is path-only (e.g. `/live`). Honour the page's protocol — `wss:` when
157
+ // served over HTTPS.
158
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
159
+ const url = `${proto}//${window.location.host}${env.wsBasePath}/${componentId}/ws`;
160
+
161
+ let ws: WebSocket;
162
+ try {
163
+ ws = new WebSocket(url);
164
+ } catch (err) {
165
+ dispatch({ type: "ERROR", message: err instanceof Error ? err.message : String(err) });
166
+ scheduleReconnect();
167
+ return;
168
+ }
169
+ wsRef.current = ws;
170
+
171
+ ws.onopen = () => {
172
+ if (cancelled) return;
173
+ reconnectDelayRef.current = RECONNECT_INITIAL_MS;
174
+ dispatch({ type: "CONNECT" });
175
+ };
176
+
177
+ ws.onmessage = (event: MessageEvent) => {
178
+ if (cancelled) return;
179
+ let parsed: IncomingMessage;
180
+ try {
181
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "") as IncomingMessage;
182
+ } catch (err) {
183
+ dispatch({ type: "ERROR", message: `bad frame: ${err instanceof Error ? err.message : String(err)}` });
184
+ return;
185
+ }
186
+
187
+ __handleIncomingFrame<S>(parsed, dispatch as (a: Action<S, unknown>) => void, onEventRef);
188
+ };
189
+
190
+ ws.onerror = () => {
191
+ // The error event surface is opaque; rely on onclose to drive
192
+ // reconnect.
193
+ dispatch({ type: "ERROR", message: "websocket error" });
194
+ };
195
+
196
+ ws.onclose = () => {
197
+ if (cancelled) return;
198
+ wsRef.current = null;
199
+ dispatch({ type: "DISCONNECT" });
200
+ scheduleReconnect();
201
+ };
202
+ };
203
+
204
+ const scheduleReconnect = () => {
205
+ if (cancelled) return;
206
+ const delay = reconnectDelayRef.current;
207
+ reconnectDelayRef.current = Math.min(delay * 2, RECONNECT_MAX_MS);
208
+ reconnectTimerRef.current = setTimeout(() => {
209
+ connect();
210
+ }, delay);
211
+ };
212
+
213
+ connect();
214
+
215
+ return () => {
216
+ cancelled = true;
217
+ if (reconnectTimerRef.current) {
218
+ clearTimeout(reconnectTimerRef.current);
219
+ reconnectTimerRef.current = null;
220
+ }
221
+ if (wsRef.current) {
222
+ try {
223
+ wsRef.current.close();
224
+ } catch {
225
+ // ignore
226
+ }
227
+ wsRef.current = null;
228
+ }
229
+ };
230
+ }, [componentId, env]);
231
+
232
+ const sendCommand = (cmd: unknown) => {
233
+ const ws = wsRef.current;
234
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
235
+ console.warn(`useLiveComponent[${componentId}]: dropping command — socket not open`);
236
+ return;
237
+ }
238
+ ws.send(JSON.stringify({ type: "command", command: cmd }));
239
+ };
240
+
241
+ return {
242
+ state: state.state,
243
+ config: state.config,
244
+ connected: state.connected,
245
+ hasInitialState: state.hasInitialState,
246
+ lastError: state.lastError,
247
+ sendCommand,
248
+ };
249
+ }
@@ -0,0 +1,11 @@
1
+ // The one place the dashboard learns the workflow's shape. Component ids here
2
+ // must match workdir/studio-save-files/__DEMO__/__DEMO__.yaml; when the
3
+ // workflow changes, change it here too.
4
+
5
+ export const WORKFLOW = {
6
+ // input.srt-listener: the camera or encoder feeding the demo.
7
+ input: { id: "srt_in", label: "SRT in" },
8
+
9
+ // output.preview in image mode: a still of the program, refreshed.
10
+ preview: { id: "preview" },
11
+ } as const;
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles.css";
5
+
6
+ const root = document.getElementById("root");
7
+ if (!root) throw new Error("index.html has no #root");
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );
@@ -0,0 +1,84 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ font-family: system-ui, sans-serif;
4
+ background: #0b0d10;
5
+ color: #e5e7eb;
6
+ }
7
+
8
+ body {
9
+ margin: 0;
10
+ }
11
+
12
+ .page {
13
+ max-width: 1100px;
14
+ margin: 0 auto;
15
+ padding: 24px 16px;
16
+ }
17
+
18
+ h1 {
19
+ font-size: 20px;
20
+ margin: 0 0 16px;
21
+ }
22
+
23
+ h2 {
24
+ font-size: 14px;
25
+ text-transform: uppercase;
26
+ letter-spacing: 0.05em;
27
+ color: #9ca3af;
28
+ }
29
+
30
+ .preview {
31
+ display: block;
32
+ width: 100%;
33
+ aspect-ratio: 16 / 9;
34
+ object-fit: contain;
35
+ background: #000;
36
+ border-radius: 6px;
37
+ }
38
+
39
+ .preview-empty {
40
+ display: grid;
41
+ place-items: center;
42
+ color: #9ca3af;
43
+ }
44
+
45
+ .components {
46
+ list-style: none;
47
+ padding: 0;
48
+ margin: 0;
49
+ }
50
+
51
+ .components li {
52
+ padding: 8px 0;
53
+ border-bottom: 1px solid #1f2937;
54
+ }
55
+
56
+ .component-name {
57
+ font-weight: 600;
58
+ }
59
+
60
+ .component-type {
61
+ margin-left: 8px;
62
+ color: #6b7280;
63
+ font-size: 12px;
64
+ }
65
+
66
+ .component-state {
67
+ margin: 6px 0 0;
68
+ font-size: 11px;
69
+ color: #9ca3af;
70
+ }
71
+
72
+ .component-state pre {
73
+ white-space: pre-wrap;
74
+ }
75
+
76
+ .status-pill {
77
+ display: inline-block;
78
+ margin-left: 8px;
79
+ padding: 2px 6px;
80
+ border-radius: 4px;
81
+ font-size: 10px;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.05em;
84
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "noEmit": true
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,35 @@
1
+ import { resolve } from "node:path";
2
+ import react from "@vitejs/plugin-react";
3
+ import { defineConfig } from "vite";
4
+
5
+ // Studio serves the build from studio-save-files/__DEMO__/dashboards/ at
6
+ // /dashboard/__DEMO__/, so asset paths are relative. One page per HTML entry
7
+ // under build.rollupOptions.input; add a page by adding an entry.
8
+ //
9
+ // `bun run dev` proxies Studio's routes to STUDIO_URL, so the pages can be
10
+ // worked on against a running workflow. It must be a Studio reachable without
11
+ // the sign-in proxy in front.
12
+ const studio = process.env.STUDIO_URL ?? "http://localhost:8000";
13
+ const toStudio = { target: studio, changeOrigin: true, secure: false, ws: true };
14
+
15
+ export default defineConfig({
16
+ base: "./",
17
+ plugins: [react()],
18
+ resolve: { dedupe: ["react", "react-dom"] },
19
+ server: {
20
+ port: 5175,
21
+ proxy: {
22
+ "/env": { ...toStudio, rewrite: () => "/dashboard/__DEMO__/env" },
23
+ "/live": toStudio,
24
+ },
25
+ },
26
+ build: {
27
+ outDir: "dist",
28
+ emptyOutDir: true,
29
+ rollupOptions: {
30
+ input: {
31
+ index: resolve(__dirname, "index.html"),
32
+ },
33
+ },
34
+ },
35
+ });