@albionlabs/chat-widget 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.
@@ -0,0 +1,824 @@
1
+ <script lang="ts">
2
+ import type { DisplayMessage, TxSignRequestPayload } from '../types.js';
3
+ import type { SigningBundle, SigningCompleteResponse } from '../services/gateway-types.js';
4
+ import { sendMessage } from '../stores/chat.js';
5
+ import { signTransactionRequest, waitForTransactionConfirmation } from '../stores/wallet.js';
6
+ import { fetchSigningBundle, completeSigningBundle } from '../services/gateway-api.js';
7
+
8
+ let { message }: { message: DisplayMessage } = $props();
9
+
10
+ type RainlangReviewPayload = {
11
+ title: string;
12
+ rainlang: string;
13
+ contentWithoutReview: string;
14
+ };
15
+
16
+ const DEFAULT_REVIEW_TITLE = 'Rainlang Strategy Review';
17
+ const taggedReviewRegex = /<rainlang-review(?:\s+title="([^"]*)")?>([\s\S]*?)<\/rainlang-review>/i;
18
+ const fencedReviewRegex = /```rainlang\s*([\s\S]*?)```/i;
19
+ const txSignRequestRegex = /<(tx-sign-request|signature_request)>([\s\S]*?)<\/\1>/gi;
20
+ const txSignRefRegex = /<tx-sign\s+id="([^"]+)">([\s\S]*?)<\/tx-sign>/gi;
21
+
22
+ type TxSignRequestView = {
23
+ requests: Array<{
24
+ id: string;
25
+ request: TxSignRequestPayload;
26
+ }>;
27
+ contentWithoutRequests: string;
28
+ };
29
+
30
+ type TxSignRefView = {
31
+ refs: Array<{ signingId: string; summary: string }>;
32
+ contentWithoutRefs: string;
33
+ };
34
+
35
+ type BundleSignState = {
36
+ status: 'idle' | 'loading' | 'ready' | 'signing' | 'completed' | 'error';
37
+ bundle?: SigningBundle;
38
+ currentTxIndex: number;
39
+ txHashes: string[];
40
+ error?: string;
41
+ completionResult?: SigningCompleteResponse;
42
+ };
43
+
44
+ type TxSignState = {
45
+ isSigning: boolean;
46
+ signedTxHash: string | null;
47
+ signingError: string | null;
48
+ waitingForConfirmation: boolean;
49
+ confirmationError: string | null;
50
+ autoProceedSent: boolean;
51
+ };
52
+
53
+ function isTxSignRequestPayload(value: unknown): value is TxSignRequestPayload {
54
+ if (!value || typeof value !== 'object') return false;
55
+ const candidate = value as Record<string, unknown>;
56
+ return (
57
+ candidate.kind === 'evm_send_transaction'
58
+ && typeof candidate.chainId === 'number'
59
+ && typeof candidate.from === 'string'
60
+ && typeof candidate.to === 'string'
61
+ && typeof candidate.data === 'string'
62
+ && typeof candidate.value === 'string'
63
+ );
64
+ }
65
+
66
+ function parseTxSignRequests(content: string): TxSignRequestView {
67
+ const requests: TxSignRequestView['requests'] = [];
68
+ let contentWithoutRequests = content;
69
+ txSignRequestRegex.lastIndex = 0;
70
+ let match: RegExpExecArray | null = txSignRequestRegex.exec(content);
71
+ let requestIndex = 0;
72
+
73
+ while (match) {
74
+ const [fullMatch, _tagName, jsonPayload] = match;
75
+ try {
76
+ const parsed = JSON.parse((jsonPayload ?? '').trim()) as unknown;
77
+ if (isTxSignRequestPayload(parsed)) {
78
+ requests.push({
79
+ id: `${message.id}:${requestIndex}`,
80
+ request: parsed
81
+ });
82
+ contentWithoutRequests = contentWithoutRequests.replace(fullMatch, '');
83
+ }
84
+ } catch {
85
+ // Keep malformed tags in message text for visibility.
86
+ }
87
+ requestIndex += 1;
88
+ match = txSignRequestRegex.exec(content);
89
+ }
90
+
91
+ return {
92
+ requests,
93
+ contentWithoutRequests: contentWithoutRequests.trim()
94
+ };
95
+ }
96
+
97
+ function parseTxSignRefs(content: string): TxSignRefView {
98
+ const refs: TxSignRefView['refs'] = [];
99
+ let contentWithoutRefs = content;
100
+ txSignRefRegex.lastIndex = 0;
101
+ let match: RegExpExecArray | null = txSignRefRegex.exec(content);
102
+
103
+ while (match) {
104
+ const [fullMatch, signingId, summary] = match;
105
+ if (signingId) {
106
+ refs.push({ signingId, summary: (summary ?? '').trim() });
107
+ contentWithoutRefs = contentWithoutRefs.replace(fullMatch, '');
108
+ }
109
+ match = txSignRefRegex.exec(content);
110
+ }
111
+
112
+ return { refs, contentWithoutRefs: contentWithoutRefs.trim() };
113
+ }
114
+
115
+ function shortenHex(value: string, head = 12, tail = 10): string {
116
+ if (!value.startsWith('0x')) return value;
117
+ if (value.length <= head + tail + 3) return value;
118
+ return `${value.slice(0, head)}...${value.slice(-tail)}`;
119
+ }
120
+
121
+ function basescanTxUrl(chainId: number, txHash: string): string {
122
+ const hash = txHash.trim();
123
+ if (chainId === 84532) {
124
+ return `https://sepolia.basescan.org/tx/${hash}`;
125
+ }
126
+ return `https://basescan.org/tx/${hash}`;
127
+ }
128
+
129
+ const DEFAULT_CHAIN_ID = 8453;
130
+
131
+ function escapeHtml(text: string): string {
132
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
133
+ }
134
+
135
+ function renderContentWithHashLinks(content: string): string {
136
+ const escaped = escapeHtml(content);
137
+ return escaped.replace(
138
+ /\b(0x[a-fA-F0-9]{64})\b/g,
139
+ (hash) =>
140
+ `<a class="inline-hash" href="${basescanTxUrl(DEFAULT_CHAIN_ID, hash)}" target="_blank" rel="noopener noreferrer">${shortenHex(hash, 10, 8)}</a>`
141
+ );
142
+ }
143
+
144
+ function parseRainlangReview(content: string): RainlangReviewPayload | null {
145
+ const taggedMatch = content.match(taggedReviewRegex);
146
+ if (taggedMatch) {
147
+ const [fullMatch, title, rainlang] = taggedMatch;
148
+ return {
149
+ title: title?.trim() || DEFAULT_REVIEW_TITLE,
150
+ rainlang: (rainlang ?? '').trim(),
151
+ contentWithoutReview: content.replace(fullMatch, '').trim()
152
+ };
153
+ }
154
+
155
+ const fencedMatch = content.match(fencedReviewRegex);
156
+ if (fencedMatch) {
157
+ const [fullMatch, rainlang] = fencedMatch;
158
+ return {
159
+ title: DEFAULT_REVIEW_TITLE,
160
+ rainlang: (rainlang ?? '').trim(),
161
+ contentWithoutReview: content.replace(fullMatch, '').trim()
162
+ };
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ let isReviewModalOpen = $state(false);
169
+ let txSignStates = $state<Record<string, TxSignState>>({});
170
+ let bundleStates = $state<Record<string, BundleSignState>>({});
171
+
172
+ function getBundleState(signingId: string): BundleSignState {
173
+ return bundleStates[signingId] ?? {
174
+ status: 'idle',
175
+ currentTxIndex: 0,
176
+ txHashes: []
177
+ };
178
+ }
179
+
180
+ function patchBundleState(signingId: string, patch: Partial<BundleSignState>): void {
181
+ bundleStates = {
182
+ ...bundleStates,
183
+ [signingId]: { ...getBundleState(signingId), ...patch }
184
+ };
185
+ }
186
+
187
+ async function handleSignBundle(signingId: string) {
188
+ const current = getBundleState(signingId);
189
+ if (current.status === 'loading' || current.status === 'signing' || current.status === 'completed') return;
190
+
191
+ patchBundleState(signingId, { status: 'loading', error: undefined });
192
+
193
+ let bundle: SigningBundle;
194
+ try {
195
+ bundle = await fetchSigningBundle(signingId);
196
+ } catch (error) {
197
+ patchBundleState(signingId, {
198
+ status: 'error',
199
+ error: error instanceof Error ? error.message : 'Failed to load signing bundle'
200
+ });
201
+ return;
202
+ }
203
+
204
+ patchBundleState(signingId, { status: 'ready', bundle });
205
+
206
+ // If there's composedRainlang in metadata, show review modal
207
+ if (bundle.metadata?.composedRainlang) {
208
+ isReviewModalOpen = true;
209
+ }
210
+ }
211
+
212
+ async function executeBundleSigning(signingId: string) {
213
+ const state = getBundleState(signingId);
214
+ if (!state.bundle || state.status === 'signing' || state.status === 'completed') return;
215
+
216
+ const bundle = state.bundle;
217
+ patchBundleState(signingId, { status: 'signing', currentTxIndex: 0, txHashes: [] });
218
+
219
+ const collectedHashes: string[] = [];
220
+ for (let i = 0; i < bundle.transactions.length; i++) {
221
+ patchBundleState(signingId, { currentTxIndex: i });
222
+
223
+ const tx = bundle.transactions[i];
224
+ const payload: TxSignRequestPayload = {
225
+ kind: 'evm_send_transaction',
226
+ chainId: bundle.chainId,
227
+ from: bundle.from,
228
+ to: tx.to,
229
+ data: tx.data,
230
+ value: tx.value ?? '0'
231
+ };
232
+
233
+ let hash: string;
234
+ try {
235
+ hash = await signTransactionRequest(payload);
236
+ } catch (error) {
237
+ patchBundleState(signingId, {
238
+ status: 'error',
239
+ error: `Failed signing tx ${i + 1} (${tx.label}): ${error instanceof Error ? error.message : 'Unknown error'}`,
240
+ txHashes: collectedHashes
241
+ });
242
+ return;
243
+ }
244
+
245
+ collectedHashes.push(hash);
246
+ patchBundleState(signingId, { txHashes: [...collectedHashes] });
247
+
248
+ // Wait for confirmation before proceeding to next tx
249
+ try {
250
+ const confirmed = await waitForTransactionConfirmation(hash);
251
+ if (!confirmed) {
252
+ patchBundleState(signingId, {
253
+ status: 'error',
254
+ error: `Tx ${i + 1} (${tx.label}) timed out waiting for confirmation`,
255
+ txHashes: collectedHashes
256
+ });
257
+ return;
258
+ }
259
+ } catch (error) {
260
+ patchBundleState(signingId, {
261
+ status: 'error',
262
+ error: `Tx ${i + 1} confirmation error: ${error instanceof Error ? error.message : 'Unknown'}`,
263
+ txHashes: collectedHashes
264
+ });
265
+ return;
266
+ }
267
+ }
268
+
269
+ // All signed and confirmed — call completion endpoint
270
+ try {
271
+ const result = await completeSigningBundle(signingId, collectedHashes);
272
+ patchBundleState(signingId, {
273
+ status: 'completed',
274
+ txHashes: collectedHashes,
275
+ completionResult: result
276
+ });
277
+ } catch (error) {
278
+ // Transactions are on-chain, just completion lookup failed
279
+ patchBundleState(signingId, {
280
+ status: 'completed',
281
+ txHashes: collectedHashes,
282
+ error: `Completion lookup failed: ${error instanceof Error ? error.message : 'Unknown'}`
283
+ });
284
+ }
285
+ }
286
+
287
+ function createInitialTxSignState(): TxSignState {
288
+ return {
289
+ isSigning: false,
290
+ signedTxHash: null,
291
+ signingError: null,
292
+ waitingForConfirmation: false,
293
+ confirmationError: null,
294
+ autoProceedSent: false
295
+ };
296
+ }
297
+
298
+ function getTxSignState(requestId: string): TxSignState {
299
+ return txSignStates[requestId] ?? createInitialTxSignState();
300
+ }
301
+
302
+ function patchTxSignState(requestId: string, patch: Partial<TxSignState>): void {
303
+ txSignStates = {
304
+ ...txSignStates,
305
+ [requestId]: {
306
+ ...getTxSignState(requestId),
307
+ ...patch
308
+ }
309
+ };
310
+ }
311
+
312
+ const isUser = $derived(message.role === 'user');
313
+ const isSystem = $derived(message.role === 'system');
314
+ const txSignRequestView = $derived(parseTxSignRequests(message.content));
315
+ const txSignRequests = $derived(txSignRequestView.requests);
316
+ const txSignRefView = $derived(parseTxSignRefs(txSignRequestView.contentWithoutRequests));
317
+ const txSignRefs = $derived(txSignRefView.refs);
318
+ const contentWithoutTxRequest = $derived(txSignRefView.contentWithoutRefs);
319
+ const rainlangReview = $derived(parseRainlangReview(contentWithoutTxRequest));
320
+ const bundleRainlangReview = $derived.by(() => {
321
+ for (const ref of txSignRefs) {
322
+ const state = getBundleState(ref.signingId);
323
+ if (state.bundle?.metadata?.composedRainlang) {
324
+ return {
325
+ title: DEFAULT_REVIEW_TITLE,
326
+ rainlang: state.bundle.metadata.composedRainlang,
327
+ contentWithoutReview: ''
328
+ };
329
+ }
330
+ }
331
+ return null;
332
+ });
333
+ const displayContent = $derived(rainlangReview?.contentWithoutReview ?? contentWithoutTxRequest);
334
+ const renderedContent = $derived(renderContentWithHashLinks(displayContent));
335
+ const activeReview = $derived(rainlangReview ?? bundleRainlangReview);
336
+ const timeStr = $derived(new Date(message.timestamp).toLocaleTimeString());
337
+
338
+ async function handleSignTxRequest(requestId: string, requestPayload: TxSignRequestPayload, position: number) {
339
+ const currentState = getTxSignState(requestId);
340
+ if (currentState.isSigning || currentState.signedTxHash) return;
341
+
342
+ patchTxSignState(requestId, {
343
+ isSigning: true,
344
+ signingError: null,
345
+ confirmationError: null
346
+ });
347
+ try {
348
+ const hash = await signTransactionRequest(requestPayload);
349
+ patchTxSignState(requestId, {
350
+ signedTxHash: hash,
351
+ waitingForConfirmation: true
352
+ });
353
+
354
+ const isConfirmed = await waitForTransactionConfirmation(hash);
355
+ if (!isConfirmed) {
356
+ patchTxSignState(requestId, {
357
+ waitingForConfirmation: false,
358
+ confirmationError: 'Timed out waiting for confirmation. You can still continue manually.'
359
+ });
360
+ return;
361
+ }
362
+
363
+ patchTxSignState(requestId, {
364
+ waitingForConfirmation: false
365
+ });
366
+
367
+ if (!getTxSignState(requestId).autoProceedSent) {
368
+ patchTxSignState(requestId, { autoProceedSent: true });
369
+ const transactionLabel = txSignRequests.length > 1 ? `Transaction ${position + 1}` : 'Transaction';
370
+ sendMessage(`${transactionLabel} confirmed on-chain: ${hash}. Continue.`);
371
+ }
372
+ } catch (error) {
373
+ patchTxSignState(requestId, {
374
+ signingError: error instanceof Error ? error.message : 'Failed to sign transaction',
375
+ waitingForConfirmation: false
376
+ });
377
+ } finally {
378
+ patchTxSignState(requestId, { isSigning: false });
379
+ }
380
+ }
381
+ </script>
382
+
383
+ {#snippet txHashBox(hash: string, index: number, chainId: number)}
384
+ <div class="hash-link-box">
385
+ <span class="hash-label">Tx {index + 1}</span>
386
+ <code>{shortenHex(hash, 10, 8)}</code>
387
+ <a class="hash-explorer-link" href={basescanTxUrl(chainId, hash)} target="_blank" rel="noopener noreferrer">BaseScan ↗</a>
388
+ </div>
389
+ {/snippet}
390
+
391
+ {#snippet orderHashBox(hash: string, url: string)}
392
+ <div class="hash-link-box order">
393
+ <span class="hash-label">Order</span>
394
+ <code>{shortenHex(hash, 10, 8)}</code>
395
+ <a class="hash-explorer-link" href={url} target="_blank" rel="noopener noreferrer">Raindex ↗</a>
396
+ </div>
397
+ {/snippet}
398
+
399
+ <div class="chat-bubble" class:user={isUser} class:assistant={!isUser && !isSystem} class:system={isSystem}>
400
+ <div class="bubble-content">
401
+ {#if displayContent}
402
+ <div class="message-text">{@html renderedContent}</div>
403
+ {/if}
404
+ {#if rainlangReview}
405
+ <button class="review-btn" onclick={() => (isReviewModalOpen = true)}>Review Rainlang Strategy</button>
406
+ {/if}
407
+ {#if txSignRequests.length > 0 && !isUser && !isSystem}
408
+ {#each txSignRequests as txRequest, index (txRequest.id)}
409
+ {@const txState = getTxSignState(txRequest.id)}
410
+ <div class="tx-request-card">
411
+ <button
412
+ class="sign-btn"
413
+ onclick={() => handleSignTxRequest(txRequest.id, txRequest.request, index)}
414
+ disabled={txState.isSigning || !!txState.signedTxHash}
415
+ >
416
+ {#if txState.signedTxHash}
417
+ Transaction Submitted
418
+ {:else if txState.isSigning}
419
+ Waiting for Signature...
420
+ {:else if txSignRequests.length > 1}
421
+ Sign Transaction {index + 1}
422
+ {:else}
423
+ Sign Transaction
424
+ {/if}
425
+ </button>
426
+ {#if txState.signedTxHash}
427
+ <div class="sign-status success">
428
+ Tx Hash: <code>{txState.signedTxHash}</code>
429
+ <a
430
+ class="tx-link"
431
+ href={basescanTxUrl(txRequest.request.chainId, txState.signedTxHash)}
432
+ target="_blank"
433
+ rel="noopener noreferrer"
434
+ >
435
+ View on BaseScan
436
+ </a>
437
+ </div>
438
+ {/if}
439
+ {#if txState.waitingForConfirmation}
440
+ <div class="sign-status pending">Waiting for on-chain confirmation...</div>
441
+ {/if}
442
+ {#if txState.autoProceedSent}
443
+ <div class="sign-status success">Confirmed on-chain. Bot notified to proceed.</div>
444
+ {/if}
445
+ {#if txState.confirmationError}
446
+ <div class="sign-status error">{txState.confirmationError}</div>
447
+ {/if}
448
+ {#if txState.signingError}
449
+ <div class="sign-status error">{txState.signingError}</div>
450
+ {/if}
451
+ <details class="tx-details">
452
+ <summary>Transaction Details{txSignRequests.length > 1 ? ` (${index + 1})` : ''}</summary>
453
+ <div class="tx-row"><span>From</span><code>{txRequest.request.from}</code></div>
454
+ <div class="tx-row"><span>To</span><code>{txRequest.request.to}</code></div>
455
+ <div class="tx-row"><span>Value (wei)</span><code>{txRequest.request.value}</code></div>
456
+ <div class="tx-row"><span>Chain</span><code>{txRequest.request.chainId}</code></div>
457
+ <div class="tx-row"><span>Calldata</span><code>{shortenHex(txRequest.request.data, 14, 12)}</code></div>
458
+ </details>
459
+ </div>
460
+ {/each}
461
+ {/if}
462
+ {#if txSignRefs.length > 0 && !isUser && !isSystem}
463
+ {#each txSignRefs as ref (ref.signingId)}
464
+ {@const bState = getBundleState(ref.signingId)}
465
+ <div class="tx-request-card">
466
+ <div class="bundle-summary">{ref.summary}</div>
467
+ {#if bState.status === 'idle'}
468
+ <button class="sign-btn" onclick={() => handleSignBundle(ref.signingId)}>
469
+ Prepare Signing
470
+ </button>
471
+ {:else if bState.status === 'loading'}
472
+ <button class="sign-btn" disabled>Loading bundle...</button>
473
+ {:else if bState.status === 'ready'}
474
+ {#if bundleRainlangReview}
475
+ <button class="review-btn" onclick={() => (isReviewModalOpen = true)}>Review Rainlang Strategy</button>
476
+ {/if}
477
+ <button class="sign-btn" onclick={() => executeBundleSigning(ref.signingId)}>
478
+ Sign {bState.bundle?.transactions.length ?? 0} Transaction{(bState.bundle?.transactions.length ?? 0) === 1 ? '' : 's'}
479
+ </button>
480
+ {:else if bState.status === 'signing'}
481
+ <button class="sign-btn" disabled>
482
+ Signing {bState.currentTxIndex + 1} of {bState.bundle?.transactions.length ?? 0}: {bState.bundle?.transactions[bState.currentTxIndex]?.label ?? ''}...
483
+ </button>
484
+ {#each bState.txHashes as hash, i}
485
+ {@render txHashBox(hash, i, bState.bundle?.chainId ?? DEFAULT_CHAIN_ID)}
486
+ {/each}
487
+ {:else if bState.status === 'completed'}
488
+ {#each bState.txHashes as hash, i}
489
+ {@render txHashBox(hash, i, bState.bundle?.chainId ?? DEFAULT_CHAIN_ID)}
490
+ {/each}
491
+ {#if bState.completionResult?.raindexUrl}
492
+ {@render orderHashBox(bState.completionResult.orderHash ?? '', bState.completionResult.raindexUrl)}
493
+ {/if}
494
+ {:else if bState.status === 'error'}
495
+ <div class="sign-status error">{bState.error}</div>
496
+ {#each bState.txHashes as hash, i}
497
+ {@render txHashBox(hash, i, bState.bundle?.chainId ?? DEFAULT_CHAIN_ID)}
498
+ {/each}
499
+ {#if bState.error?.includes('not found or expired')}
500
+ <div class="sign-status pending">Bundle expired — ask the agent to prepare again.</div>
501
+ {/if}
502
+ {/if}
503
+ </div>
504
+ {/each}
505
+ {/if}
506
+ </div>
507
+ <div class="bubble-time">{timeStr}</div>
508
+ </div>
509
+
510
+ {#if isReviewModalOpen && activeReview}
511
+ <div
512
+ class="review-modal-backdrop"
513
+ role="button"
514
+ tabindex="0"
515
+ aria-label="Close Rainlang strategy review modal"
516
+ onclick={(event) => {
517
+ if (event.target === event.currentTarget) {
518
+ isReviewModalOpen = false;
519
+ }
520
+ }}
521
+ onkeydown={(event) => {
522
+ if (event.key === 'Escape') {
523
+ isReviewModalOpen = false;
524
+ }
525
+ }}
526
+ >
527
+ <div class="review-modal" role="dialog" aria-modal="true" aria-labelledby={`rainlang-review-title-${message.id}`}>
528
+ <div class="review-modal-header">
529
+ <h3 id={`rainlang-review-title-${message.id}`}>{activeReview.title}</h3>
530
+ <button class="close-btn" aria-label="Close Rainlang review modal" onclick={() => (isReviewModalOpen = false)}>
531
+ Close
532
+ </button>
533
+ </div>
534
+ <pre class="rainlang-code"><code>{activeReview.rainlang}</code></pre>
535
+ </div>
536
+ </div>
537
+ {/if}
538
+
539
+ <style>
540
+ .chat-bubble {
541
+ max-width: 80%;
542
+ padding: 0.75rem 1rem;
543
+ border-radius: 1rem;
544
+ margin-bottom: 0.5rem;
545
+ word-wrap: break-word;
546
+ }
547
+
548
+ .user {
549
+ align-self: flex-end;
550
+ background: #3b82f6;
551
+ color: white;
552
+ border-bottom-right-radius: 0.25rem;
553
+ }
554
+
555
+ .assistant {
556
+ align-self: flex-start;
557
+ background: #f3f4f6;
558
+ color: #1f2937;
559
+ border-bottom-left-radius: 0.25rem;
560
+ }
561
+
562
+ .system {
563
+ align-self: center;
564
+ background: #fef3c7;
565
+ color: #92400e;
566
+ font-size: 0.875rem;
567
+ border-radius: 0.5rem;
568
+ }
569
+
570
+ .bubble-time {
571
+ font-size: 0.7rem;
572
+ opacity: 0.6;
573
+ margin-top: 0.25rem;
574
+ }
575
+
576
+ .bubble-content {
577
+ display: flex;
578
+ flex-direction: column;
579
+ gap: 0.5rem;
580
+ }
581
+
582
+ .message-text {
583
+ white-space: pre-wrap;
584
+ }
585
+
586
+ .review-btn {
587
+ align-self: flex-start;
588
+ padding: 0.35rem 0.6rem;
589
+ border-radius: 0.4rem;
590
+ border: 1px solid rgba(31, 41, 55, 0.2);
591
+ background: rgba(255, 255, 255, 0.75);
592
+ color: #111827;
593
+ font-size: 0.78rem;
594
+ font-weight: 600;
595
+ cursor: pointer;
596
+ }
597
+
598
+ .user .review-btn {
599
+ background: rgba(255, 255, 255, 0.2);
600
+ color: #ffffff;
601
+ border-color: rgba(255, 255, 255, 0.35);
602
+ }
603
+
604
+ .review-btn:hover {
605
+ filter: brightness(0.95);
606
+ }
607
+
608
+ .sign-btn {
609
+ align-self: flex-start;
610
+ padding: 0.45rem 0.7rem;
611
+ border-radius: 0.45rem;
612
+ border: 1px solid #2563eb;
613
+ background: #2563eb;
614
+ color: #ffffff;
615
+ font-size: 0.8rem;
616
+ font-weight: 600;
617
+ cursor: pointer;
618
+ }
619
+
620
+ .sign-btn:disabled {
621
+ cursor: not-allowed;
622
+ opacity: 0.7;
623
+ }
624
+
625
+ .bundle-summary {
626
+ font-size: 0.8rem;
627
+ color: #374151;
628
+ line-height: 1.4;
629
+ }
630
+
631
+ .tx-request-card + .tx-request-card {
632
+ border-top: 1px dashed #d1d5db;
633
+ padding-top: 0.5rem;
634
+ }
635
+
636
+ .sign-status {
637
+ font-size: 0.76rem;
638
+ padding: 0.35rem 0.5rem;
639
+ border-radius: 0.4rem;
640
+ }
641
+
642
+ .sign-status.success {
643
+ background: #ecfdf3;
644
+ color: #166534;
645
+ }
646
+
647
+ .hash-link-box {
648
+ display: flex;
649
+ align-items: center;
650
+ gap: 0.4rem;
651
+ padding: 0.35rem 0.5rem;
652
+ border-radius: 0.4rem;
653
+ background: #ecfdf3;
654
+ font-size: 0.76rem;
655
+ }
656
+
657
+ .hash-link-box.order {
658
+ background: #eff6ff;
659
+ }
660
+
661
+ .hash-link-box .hash-label {
662
+ color: #166534;
663
+ font-weight: 600;
664
+ white-space: nowrap;
665
+ }
666
+
667
+ .hash-link-box.order .hash-label {
668
+ color: #1d4ed8;
669
+ }
670
+
671
+ .hash-link-box code {
672
+ color: #166534;
673
+ word-break: break-all;
674
+ }
675
+
676
+ .hash-link-box.order code {
677
+ color: #1e40af;
678
+ }
679
+
680
+ .hash-explorer-link {
681
+ margin-left: auto;
682
+ color: #065f46;
683
+ font-weight: 600;
684
+ text-decoration: none;
685
+ white-space: nowrap;
686
+ }
687
+
688
+ .hash-explorer-link:hover {
689
+ text-decoration: underline;
690
+ }
691
+
692
+ .hash-link-box.order .hash-explorer-link {
693
+ color: #1e40af;
694
+ }
695
+
696
+ :global(.inline-hash) {
697
+ background: #f0fdf4;
698
+ padding: 0.1rem 0.3rem;
699
+ border-radius: 0.25rem;
700
+ color: #166534;
701
+ text-decoration: none;
702
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
703
+ font-size: 0.85em;
704
+ }
705
+
706
+ :global(.inline-hash:hover) {
707
+ text-decoration: underline;
708
+ background: #dcfce7;
709
+ }
710
+
711
+ .user :global(.inline-hash) {
712
+ background: rgba(255, 255, 255, 0.2);
713
+ color: #ffffff;
714
+ }
715
+
716
+ .user :global(.inline-hash:hover) {
717
+ background: rgba(255, 255, 255, 0.3);
718
+ }
719
+
720
+ .tx-link {
721
+ margin-left: 0.5rem;
722
+ font-weight: 600;
723
+ color: #065f46;
724
+ text-decoration: underline;
725
+ }
726
+
727
+ .sign-status.error {
728
+ background: #fef2f2;
729
+ color: #991b1b;
730
+ }
731
+
732
+ .sign-status.pending {
733
+ background: #eff6ff;
734
+ color: #1d4ed8;
735
+ }
736
+
737
+ .tx-details {
738
+ font-size: 0.75rem;
739
+ border: 1px solid #d1d5db;
740
+ border-radius: 0.4rem;
741
+ padding: 0.35rem 0.5rem;
742
+ background: #f8fafc;
743
+ }
744
+
745
+ .tx-details summary {
746
+ cursor: pointer;
747
+ font-weight: 600;
748
+ color: #334155;
749
+ }
750
+
751
+ .tx-row {
752
+ display: flex;
753
+ gap: 0.4rem;
754
+ margin-top: 0.3rem;
755
+ align-items: baseline;
756
+ }
757
+
758
+ .tx-row span {
759
+ color: #475569;
760
+ min-width: 4.5rem;
761
+ }
762
+
763
+ .tx-row code {
764
+ word-break: break-all;
765
+ }
766
+
767
+ .review-modal-backdrop {
768
+ position: fixed;
769
+ inset: 0;
770
+ background: rgba(17, 24, 39, 0.45);
771
+ display: flex;
772
+ justify-content: center;
773
+ align-items: center;
774
+ padding: 1rem;
775
+ z-index: 10001;
776
+ }
777
+
778
+ .review-modal {
779
+ width: min(900px, 95vw);
780
+ max-height: min(80vh, 760px);
781
+ background: #ffffff;
782
+ border-radius: 0.75rem;
783
+ border: 1px solid #d1d5db;
784
+ display: flex;
785
+ flex-direction: column;
786
+ overflow: hidden;
787
+ }
788
+
789
+ .review-modal-header {
790
+ display: flex;
791
+ align-items: center;
792
+ justify-content: space-between;
793
+ padding: 0.8rem 1rem;
794
+ border-bottom: 1px solid #e5e7eb;
795
+ }
796
+
797
+ .review-modal-header h3 {
798
+ margin: 0;
799
+ font-size: 0.95rem;
800
+ color: #111827;
801
+ }
802
+
803
+ .close-btn {
804
+ border: 1px solid #d1d5db;
805
+ background: #f9fafb;
806
+ color: #111827;
807
+ border-radius: 0.4rem;
808
+ padding: 0.35rem 0.55rem;
809
+ font-size: 0.78rem;
810
+ font-weight: 600;
811
+ cursor: pointer;
812
+ }
813
+
814
+ .rainlang-code {
815
+ margin: 0;
816
+ padding: 1rem;
817
+ overflow: auto;
818
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
819
+ font-size: 0.8rem;
820
+ line-height: 1.45;
821
+ background: #f8fafc;
822
+ color: #0f172a;
823
+ }
824
+ </style>