@tangle-network/blueprint-ui 0.3.1 → 0.5.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.
@@ -0,0 +1,710 @@
1
+ // Testing harness for iframe blueprints. The promise of the SDK is that
2
+ // publishers can iterate on their UI without running the Tangle Cloud dapp
3
+ // — these utilities are what makes that true.
4
+
5
+ import {
6
+ type FC,
7
+ type ReactNode,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import type { Address } from 'viem';
15
+
16
+ import type {
17
+ ServiceSnapshot,
18
+ WalletSnapshot,
19
+ } from './tangleIframeClient';
20
+ import type {
21
+ CallJobRequest,
22
+ JobInputs,
23
+ JobResultEvent,
24
+ ParentMessage,
25
+ ServiceContextBroadcast,
26
+ ServiceContextJob,
27
+ ServiceContextOperator,
28
+ } from '../wallet/parentBridgeProtocol';
29
+
30
+ export type MockWalletInput = Partial<{
31
+ address: Address | null;
32
+ chainId: number;
33
+ isConnected: boolean;
34
+ }>;
35
+
36
+ export type MockServiceInput = Partial<{
37
+ blueprintId: string;
38
+ serviceId: string | null;
39
+ operators: readonly ServiceContextOperator[];
40
+ jobs: readonly ServiceContextJob[];
41
+ mode: string | null;
42
+ chain: import('../wallet/parentBridgeProtocol').ChainContext | null;
43
+ }>;
44
+
45
+ /**
46
+ * Construct a deterministic wallet snapshot for tests. Defaults:
47
+ * connected, vitalik.eth's address, Base Sepolia (84532).
48
+ */
49
+ export function mockWallet(input: MockWalletInput = {}): WalletSnapshot {
50
+ return {
51
+ address:
52
+ input.address === undefined
53
+ ? '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
54
+ : input.address,
55
+ chainId: input.chainId ?? 84532,
56
+ isConnected: input.isConnected ?? input.address !== null,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Construct a deterministic service snapshot for tests. Defaults: blueprint
62
+ * id `0`, no service deployed yet (serviceId null), single mock operator on
63
+ * the canonical local sidecar URL.
64
+ */
65
+ export function mockServiceContext(
66
+ input: MockServiceInput = {},
67
+ ): ServiceSnapshot {
68
+ return {
69
+ blueprintId: input.blueprintId ?? '0',
70
+ serviceId: input.serviceId === undefined ? null : input.serviceId,
71
+ operators:
72
+ input.operators ?? [
73
+ {
74
+ address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
75
+ rpcAddress: 'http://localhost:8545',
76
+ status: 'active',
77
+ },
78
+ ],
79
+ jobs:
80
+ input.jobs ?? [
81
+ { index: 0, name: 'invoke' },
82
+ ],
83
+ mode: input.mode ?? null,
84
+ chain:
85
+ input.chain === undefined
86
+ ? {
87
+ id: 84532,
88
+ name: 'Base Sepolia',
89
+ rpcUrl: 'https://sepolia.base.org',
90
+ blockExplorerUrl: 'https://sepolia.basescan.org',
91
+ nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },
92
+ }
93
+ : input.chain,
94
+ };
95
+ }
96
+
97
+ export type CallJobHandler = (
98
+ request: CallJobRequest,
99
+ ) => Promise<{
100
+ status: 'success' | 'error';
101
+ data?: unknown;
102
+ error?: string;
103
+ /** Streaming chunks emitted in order before the terminal status. */
104
+ chunks?: readonly unknown[];
105
+ }>;
106
+
107
+ type HarnessProps = {
108
+ appId?: string;
109
+ wallet?: WalletSnapshot;
110
+ service?: ServiceSnapshot;
111
+ /** Override callJob behavior. Default: returns a static `{ ok: true }`. */
112
+ onCallJob?: CallJobHandler;
113
+ /** Surface a floating debug panel that lets the developer flip state at runtime. */
114
+ showDebugPanel?: boolean;
115
+ children: ReactNode;
116
+ };
117
+
118
+ /**
119
+ * Drop-in parent simulator for tests + storybook + standalone dev. Wraps
120
+ * children in a fake parent that:
121
+ *
122
+ * - Acks the iframe's handshake immediately
123
+ * - Broadcasts the configured wallet + service context on mount
124
+ * - Intercepts `callJob` requests and routes them through `onCallJob`
125
+ * - (Optional) Mounts a floating debug panel so the developer can
126
+ * mutate state at runtime: change account, switch chain, set
127
+ * serviceId, fire a custom job
128
+ *
129
+ * The harness runs in the same JS context as the iframe app — there's no
130
+ * cross-frame postMessage, just same-window event dispatch. That keeps it
131
+ * fully synchronous + assertable, but the messages still flow through the
132
+ * exact same protocol surface the production bridge uses.
133
+ *
134
+ * Usage:
135
+ *
136
+ * <TangleParentHarness wallet={mockWallet()} service={mockServiceContext()}>
137
+ * <TangleIframeProvider appId="my-app" mode="bridge" parentOrigin="harness://">
138
+ * <App />
139
+ * </TangleIframeProvider>
140
+ * </TangleParentHarness>
141
+ *
142
+ * Set `mode="bridge"` + `parentOrigin="harness://"` on the provider so it
143
+ * matches the harness's synthetic origin. In production, use `mode="auto"`
144
+ * (the default).
145
+ */
146
+ export const TangleParentHarness: FC<HarnessProps> = ({
147
+ appId = 'harness',
148
+ wallet = mockWallet(),
149
+ service = mockServiceContext(),
150
+ onCallJob,
151
+ showDebugPanel = false,
152
+ children,
153
+ }) => {
154
+ const [currentWallet, setCurrentWallet] = useState<WalletSnapshot>(wallet);
155
+ const [currentService, setCurrentService] =
156
+ useState<ServiceSnapshot>(service);
157
+ const [callLog, setCallLog] = useState<CallJobRequest[]>([]);
158
+ const callJobHandler = useRef<CallJobHandler | undefined>(onCallJob);
159
+ callJobHandler.current = onCallJob;
160
+ const seenHandshake = useRef(false);
161
+
162
+ // Listen for iframe → "parent" messages. Since the harness shares the
163
+ // window, `window.postMessage` with the synthetic origin is the easiest
164
+ // wire — the iframe SDK posts to `window.parent`, which in same-window
165
+ // mode IS this listener.
166
+ useEffect(() => {
167
+ const reply = (message: ParentMessage) => {
168
+ window.dispatchEvent(
169
+ new MessageEvent('message', {
170
+ data: message,
171
+ origin: HARNESS_ORIGIN,
172
+ }),
173
+ );
174
+ };
175
+
176
+ const broadcast = () => {
177
+ const broadcastMsg: ServiceContextBroadcast = {
178
+ kind: 'tangle.app.serviceContext',
179
+ blueprintId: currentService.blueprintId ?? '0',
180
+ serviceId: currentService.serviceId,
181
+ operators: currentService.operators,
182
+ jobs: currentService.jobs,
183
+ mode: currentService.mode,
184
+ ...(currentService.chain !== null
185
+ ? { chain: currentService.chain }
186
+ : {}),
187
+ };
188
+ reply(broadcastMsg);
189
+ // Also broadcast wallet — combined into accountChanged + chainChanged.
190
+ reply({
191
+ kind: 'tangle.app.accountChanged',
192
+ account: currentWallet.address,
193
+ });
194
+ if (currentWallet.chainId !== null) {
195
+ reply({
196
+ kind: 'tangle.app.chainChanged',
197
+ chainId: currentWallet.chainId,
198
+ });
199
+ }
200
+ };
201
+
202
+ const handler = async (event: MessageEvent) => {
203
+ // The iframe posts via `window.parent.postMessage(msg, parentOrigin)`.
204
+ // In same-window mode, that fires a message event on this same window
205
+ // with origin = parentOrigin. Filter out events the harness itself
206
+ // dispatched (origin === HARNESS_ORIGIN) — those are replies.
207
+ if (event.origin === HARNESS_ORIGIN) return;
208
+ const data = event.data;
209
+ if (typeof data !== 'object' || data === null) return;
210
+ const message = data as { kind?: string; correlationId?: string };
211
+
212
+ switch (message.kind) {
213
+ case 'tangle.app.handshake': {
214
+ if (!seenHandshake.current) {
215
+ seenHandshake.current = true;
216
+ reply({
217
+ kind: 'tangle.app.handshakeAck',
218
+ appId,
219
+ protocolVersion: '1',
220
+ });
221
+ broadcast();
222
+ }
223
+ return;
224
+ }
225
+ case 'tangle.app.readAccount': {
226
+ if (typeof message.correlationId !== 'string') return;
227
+ reply({
228
+ kind: 'tangle.app.readAccountResult',
229
+ correlationId: message.correlationId,
230
+ ok: true,
231
+ data: {
232
+ account:
233
+ currentWallet.address ??
234
+ ('0x0000000000000000000000000000000000000000' as Address),
235
+ chainId: currentWallet.chainId ?? 0,
236
+ },
237
+ });
238
+ return;
239
+ }
240
+ case 'tangle.app.callJob': {
241
+ if (typeof message.correlationId !== 'string') return;
242
+ const request = message as unknown as CallJobRequest;
243
+ setCallLog((prev) => [...prev, request]);
244
+ // Default behavior when no handler: emit a single `success` with
245
+ // a echo of the inputs so UIs render *something* in dev mode.
246
+ const handler = callJobHandler.current;
247
+ if (!handler) {
248
+ const result: JobResultEvent = {
249
+ kind: 'tangle.app.jobResult',
250
+ correlationId: request.correlationId,
251
+ status: 'success',
252
+ data: { echo: request.inputs },
253
+ };
254
+ reply(result);
255
+ return;
256
+ }
257
+ try {
258
+ const outcome = await handler(request);
259
+ for (const chunk of outcome.chunks ?? []) {
260
+ reply({
261
+ kind: 'tangle.app.jobResult',
262
+ correlationId: request.correlationId,
263
+ status: 'streaming',
264
+ chunk,
265
+ });
266
+ }
267
+ reply({
268
+ kind: 'tangle.app.jobResult',
269
+ correlationId: request.correlationId,
270
+ status: outcome.status,
271
+ ...(outcome.data !== undefined ? { data: outcome.data } : {}),
272
+ ...(outcome.error !== undefined ? { error: outcome.error } : {}),
273
+ });
274
+ } catch (err) {
275
+ reply({
276
+ kind: 'tangle.app.jobResult',
277
+ correlationId: request.correlationId,
278
+ status: 'error',
279
+ error: err instanceof Error ? err.message : String(err),
280
+ });
281
+ }
282
+ return;
283
+ }
284
+ // Wallet ops respond optimistically — tests that want to assert
285
+ // specific signatures should pre-set them via the dev handler.
286
+ case 'tangle.app.signMessage': {
287
+ if (typeof message.correlationId !== 'string') return;
288
+ reply({
289
+ kind: 'tangle.app.signMessageResult',
290
+ correlationId: message.correlationId,
291
+ ok: true,
292
+ data: { signature: '0xdeadbeef' as `0x${string}` },
293
+ });
294
+ return;
295
+ }
296
+ case 'tangle.app.signTypedData': {
297
+ if (typeof message.correlationId !== 'string') return;
298
+ // The harness signs deterministically — production parents show
299
+ // an approval modal first. Tests that need to assert the
300
+ // typed-data payload should inspect callLog (extend later if
301
+ // needed) or pass a custom onSignTypedData handler.
302
+ reply({
303
+ kind: 'tangle.app.signTypedDataResult',
304
+ correlationId: message.correlationId,
305
+ ok: true,
306
+ data: {
307
+ signature: ('0x' + '11'.repeat(65)) as `0x${string}`,
308
+ },
309
+ });
310
+ return;
311
+ }
312
+ case 'tangle.app.signTransaction': {
313
+ if (typeof message.correlationId !== 'string') return;
314
+ reply({
315
+ kind: 'tangle.app.signTransactionResult',
316
+ correlationId: message.correlationId,
317
+ ok: true,
318
+ data: { txHash: ('0x' + '00'.repeat(32)) as `0x${string}` },
319
+ });
320
+ return;
321
+ }
322
+ case 'tangle.app.switchChain': {
323
+ if (
324
+ typeof message.correlationId !== 'string' ||
325
+ typeof (message as unknown as { chainId?: number }).chainId !== 'number'
326
+ ) {
327
+ return;
328
+ }
329
+ const chainId = (message as unknown as { chainId: number }).chainId;
330
+ setCurrentWallet((w) => ({ ...w, chainId }));
331
+ reply({
332
+ kind: 'tangle.app.switchChainResult',
333
+ correlationId: message.correlationId,
334
+ ok: true,
335
+ data: { chainId },
336
+ });
337
+ return;
338
+ }
339
+ }
340
+ };
341
+ window.addEventListener('message', handler);
342
+ return () => window.removeEventListener('message', handler);
343
+ }, [appId, currentWallet, currentService]);
344
+
345
+ // Re-broadcast when state changes.
346
+ useEffect(() => {
347
+ if (!seenHandshake.current) return;
348
+ window.dispatchEvent(
349
+ new MessageEvent('message', {
350
+ data: {
351
+ kind: 'tangle.app.accountChanged',
352
+ account: currentWallet.address,
353
+ },
354
+ origin: HARNESS_ORIGIN,
355
+ }),
356
+ );
357
+ }, [currentWallet.address]);
358
+
359
+ useEffect(() => {
360
+ if (!seenHandshake.current || currentWallet.chainId === null) return;
361
+ window.dispatchEvent(
362
+ new MessageEvent('message', {
363
+ data: {
364
+ kind: 'tangle.app.chainChanged',
365
+ chainId: currentWallet.chainId,
366
+ },
367
+ origin: HARNESS_ORIGIN,
368
+ }),
369
+ );
370
+ }, [currentWallet.chainId]);
371
+
372
+ useEffect(() => {
373
+ if (!seenHandshake.current) return;
374
+ window.dispatchEvent(
375
+ new MessageEvent('message', {
376
+ data: {
377
+ kind: 'tangle.app.serviceContext',
378
+ blueprintId: currentService.blueprintId ?? '0',
379
+ serviceId: currentService.serviceId,
380
+ operators: currentService.operators,
381
+ jobs: currentService.jobs,
382
+ mode: currentService.mode,
383
+ ...(currentService.chain !== null
384
+ ? { chain: currentService.chain }
385
+ : {}),
386
+ },
387
+ origin: HARNESS_ORIGIN,
388
+ }),
389
+ );
390
+ }, [currentService]);
391
+
392
+ const debugApi = useMemo(
393
+ () => ({
394
+ setWallet: setCurrentWallet,
395
+ setService: setCurrentService,
396
+ callLog,
397
+ }),
398
+ [callLog],
399
+ );
400
+
401
+ return (
402
+ <>
403
+ {children}
404
+ {showDebugPanel && <DebugPanel api={debugApi} />}
405
+ </>
406
+ );
407
+ };
408
+
409
+ /**
410
+ * Synthetic origin every harness instance uses. Stable across tests so the
411
+ * iframe SDK + the harness can pin to the same string.
412
+ */
413
+ export const HARNESS_ORIGIN = 'harness://tangle.local';
414
+
415
+ // ── Debug panel ──────────────────────────────────────────────────────────────
416
+
417
+ const DebugPanel: FC<{
418
+ api: {
419
+ setWallet: (w: WalletSnapshot | ((prev: WalletSnapshot) => WalletSnapshot)) => void;
420
+ setService: (
421
+ s: ServiceSnapshot | ((prev: ServiceSnapshot) => ServiceSnapshot),
422
+ ) => void;
423
+ callLog: readonly CallJobRequest[];
424
+ };
425
+ }> = ({ api }) => {
426
+ const [open, setOpen] = useState(true);
427
+ const [tab, setTab] = useState<'wallet' | 'service' | 'log'>('wallet');
428
+ if (!open) {
429
+ return (
430
+ <button
431
+ type="button"
432
+ onClick={() => setOpen(true)}
433
+ style={debugStyles.collapsedTrigger}
434
+ >
435
+ Debug
436
+ </button>
437
+ );
438
+ }
439
+ return (
440
+ <div style={debugStyles.panel}>
441
+ <header style={debugStyles.header}>
442
+ <strong style={{ fontSize: 11 }}>TANGLE DEV HARNESS</strong>
443
+ <button
444
+ type="button"
445
+ onClick={() => setOpen(false)}
446
+ style={debugStyles.closeButton}
447
+ aria-label="Close debug panel"
448
+ >
449
+ ×
450
+ </button>
451
+ </header>
452
+ <nav style={debugStyles.tabs}>
453
+ {(['wallet', 'service', 'log'] as const).map((t) => (
454
+ <button
455
+ key={t}
456
+ type="button"
457
+ onClick={() => setTab(t)}
458
+ style={{
459
+ ...debugStyles.tab,
460
+ ...(tab === t ? debugStyles.tabActive : {}),
461
+ }}
462
+ >
463
+ {t}
464
+ </button>
465
+ ))}
466
+ </nav>
467
+ <div style={debugStyles.body}>
468
+ {tab === 'wallet' && <WalletTab api={api} />}
469
+ {tab === 'service' && <ServiceTab api={api} />}
470
+ {tab === 'log' && <CallLogTab callLog={api.callLog} />}
471
+ </div>
472
+ </div>
473
+ );
474
+ };
475
+
476
+ const WalletTab: FC<{
477
+ api: { setWallet: (w: WalletSnapshot | ((prev: WalletSnapshot) => WalletSnapshot)) => void };
478
+ }> = ({ api }) => {
479
+ const [address, setAddressInput] = useState(
480
+ '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
481
+ );
482
+ const [chainId, setChainIdInput] = useState('84532');
483
+ const applyConnect = useCallback(() => {
484
+ api.setWallet({
485
+ address: address as Address,
486
+ chainId: Number(chainId) || null,
487
+ isConnected: true,
488
+ });
489
+ }, [address, chainId, api]);
490
+ const disconnect = useCallback(() => {
491
+ api.setWallet({ address: null, chainId: null, isConnected: false });
492
+ }, [api]);
493
+ return (
494
+ <div>
495
+ <label style={debugStyles.label}>address</label>
496
+ <input
497
+ value={address}
498
+ onChange={(e) => setAddressInput(e.target.value)}
499
+ style={debugStyles.input}
500
+ />
501
+ <label style={debugStyles.label}>chain id</label>
502
+ <input
503
+ value={chainId}
504
+ onChange={(e) => setChainIdInput(e.target.value)}
505
+ style={debugStyles.input}
506
+ />
507
+ <div style={debugStyles.buttonRow}>
508
+ <button type="button" onClick={applyConnect} style={debugStyles.primary}>
509
+ Set connected
510
+ </button>
511
+ <button type="button" onClick={disconnect} style={debugStyles.secondary}>
512
+ Disconnect
513
+ </button>
514
+ </div>
515
+ </div>
516
+ );
517
+ };
518
+
519
+ const ServiceTab: FC<{
520
+ api: {
521
+ setService: (
522
+ s: ServiceSnapshot | ((prev: ServiceSnapshot) => ServiceSnapshot),
523
+ ) => void;
524
+ };
525
+ }> = ({ api }) => {
526
+ const [serviceId, setServiceIdInput] = useState('1');
527
+ const [blueprintId, setBlueprintIdInput] = useState('0');
528
+ const apply = useCallback(() => {
529
+ api.setService((prev) => ({
530
+ ...prev,
531
+ serviceId: serviceId || null,
532
+ blueprintId,
533
+ }));
534
+ }, [api, serviceId, blueprintId]);
535
+ const clearService = useCallback(() => {
536
+ api.setService((prev) => ({ ...prev, serviceId: null }));
537
+ }, [api]);
538
+ return (
539
+ <div>
540
+ <label style={debugStyles.label}>blueprint id</label>
541
+ <input
542
+ value={blueprintId}
543
+ onChange={(e) => setBlueprintIdInput(e.target.value)}
544
+ style={debugStyles.input}
545
+ />
546
+ <label style={debugStyles.label}>service id (empty = not deployed)</label>
547
+ <input
548
+ value={serviceId}
549
+ onChange={(e) => setServiceIdInput(e.target.value)}
550
+ style={debugStyles.input}
551
+ />
552
+ <div style={debugStyles.buttonRow}>
553
+ <button type="button" onClick={apply} style={debugStyles.primary}>
554
+ Apply
555
+ </button>
556
+ <button type="button" onClick={clearService} style={debugStyles.secondary}>
557
+ Clear service
558
+ </button>
559
+ </div>
560
+ </div>
561
+ );
562
+ };
563
+
564
+ const CallLogTab: FC<{ callLog: readonly CallJobRequest[] }> = ({ callLog }) => {
565
+ if (callLog.length === 0) {
566
+ return <p style={debugStyles.empty}>No callJob requests yet.</p>;
567
+ }
568
+ return (
569
+ <ol style={debugStyles.log}>
570
+ {callLog.map((entry) => (
571
+ <li key={entry.correlationId} style={debugStyles.logEntry}>
572
+ <strong>job {entry.jobIndex}</strong>
573
+ <pre style={debugStyles.pre}>
574
+ {JSON.stringify(entry.inputs, null, 2)}
575
+ </pre>
576
+ </li>
577
+ ))}
578
+ </ol>
579
+ );
580
+ };
581
+
582
+ // Inline styles keep the harness style-system-agnostic — consumers may not
583
+ // ship Tailwind, and the panel shouldn't add a dependency.
584
+ const debugStyles = {
585
+ panel: {
586
+ position: 'fixed' as const,
587
+ right: 12,
588
+ top: 12,
589
+ width: 280,
590
+ zIndex: 99999,
591
+ background: '#0b0b14',
592
+ color: '#fff',
593
+ border: '1px solid #3a3a52',
594
+ borderRadius: 10,
595
+ boxShadow: '0 14px 32px rgba(0,0,0,0.4)',
596
+ fontFamily:
597
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Code", monospace',
598
+ fontSize: 12,
599
+ },
600
+ header: {
601
+ display: 'flex',
602
+ alignItems: 'center',
603
+ justifyContent: 'space-between',
604
+ padding: '8px 10px',
605
+ borderBottom: '1px solid #2a2a3e',
606
+ },
607
+ closeButton: {
608
+ background: 'none',
609
+ border: 'none',
610
+ color: '#fff',
611
+ fontSize: 18,
612
+ cursor: 'pointer',
613
+ lineHeight: 1,
614
+ },
615
+ tabs: {
616
+ display: 'flex',
617
+ borderBottom: '1px solid #2a2a3e',
618
+ },
619
+ tab: {
620
+ flex: 1,
621
+ background: 'none',
622
+ border: 'none',
623
+ color: '#a0a0c0',
624
+ padding: '6px 8px',
625
+ cursor: 'pointer',
626
+ fontSize: 11,
627
+ textTransform: 'uppercase' as const,
628
+ },
629
+ tabActive: {
630
+ color: '#fff',
631
+ borderBottom: '2px solid #818cf8',
632
+ },
633
+ body: {
634
+ padding: 10,
635
+ maxHeight: 320,
636
+ overflow: 'auto' as const,
637
+ },
638
+ label: {
639
+ display: 'block',
640
+ color: '#a0a0c0',
641
+ fontSize: 10,
642
+ marginBottom: 4,
643
+ marginTop: 6,
644
+ textTransform: 'uppercase' as const,
645
+ },
646
+ input: {
647
+ width: '100%',
648
+ background: '#15152a',
649
+ border: '1px solid #2a2a3e',
650
+ color: '#fff',
651
+ padding: '6px 8px',
652
+ borderRadius: 4,
653
+ fontFamily: 'inherit',
654
+ fontSize: 11,
655
+ boxSizing: 'border-box' as const,
656
+ },
657
+ buttonRow: { display: 'flex', gap: 6, marginTop: 8 },
658
+ primary: {
659
+ flex: 1,
660
+ background: '#4f46e5',
661
+ color: '#fff',
662
+ border: 'none',
663
+ padding: '6px 8px',
664
+ borderRadius: 4,
665
+ cursor: 'pointer',
666
+ fontSize: 11,
667
+ fontFamily: 'inherit',
668
+ },
669
+ secondary: {
670
+ flex: 1,
671
+ background: 'transparent',
672
+ color: '#a0a0c0',
673
+ border: '1px solid #3a3a52',
674
+ padding: '6px 8px',
675
+ borderRadius: 4,
676
+ cursor: 'pointer',
677
+ fontSize: 11,
678
+ fontFamily: 'inherit',
679
+ },
680
+ collapsedTrigger: {
681
+ position: 'fixed' as const,
682
+ right: 12,
683
+ top: 12,
684
+ zIndex: 99999,
685
+ padding: '6px 10px',
686
+ background: '#0b0b14',
687
+ border: '1px solid #3a3a52',
688
+ color: '#fff',
689
+ borderRadius: 6,
690
+ fontFamily: 'inherit',
691
+ fontSize: 11,
692
+ cursor: 'pointer',
693
+ },
694
+ log: { listStyle: 'none', padding: 0, margin: 0 },
695
+ logEntry: {
696
+ padding: 6,
697
+ borderBottom: '1px solid #2a2a3e',
698
+ fontSize: 11,
699
+ },
700
+ pre: {
701
+ margin: '4px 0 0',
702
+ color: '#a0a0c0',
703
+ fontSize: 10,
704
+ whiteSpace: 'pre-wrap' as const,
705
+ wordBreak: 'break-word' as const,
706
+ },
707
+ empty: { color: '#a0a0c0', fontSize: 11, margin: 0 },
708
+ } as const;
709
+
710
+ export type { JobInputs };