this repo has no description
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { _, locale } from '../lib/i18n' 6 const auth = getAuthState() 7 type View = 'collections' | 'records' | 'record' | 'create' 8 let view = $state<View>('collections') 9 let collections = $state<string[]>([]) 10 let selectedCollection = $state<string | null>(null) 11 let records = $state<Array<{ uri: string; cid: string; value: unknown; rkey: string }>>([]) 12 let recordsCursor = $state<string | undefined>(undefined) 13 let selectedRecord = $state<{ uri: string; cid: string; value: unknown; rkey: string } | null>(null) 14 let loading = $state(true) 15 let loadingMore = $state(false) 16 let error = $state<{ code?: string; message: string } | null>(null) 17 let success = $state<string | null>(null) 18 function setError(e: unknown) { 19 if (e instanceof ApiError) { 20 error = { code: e.error, message: e.message } 21 } else if (e instanceof Error) { 22 error = { message: e.message } 23 } else { 24 error = { message: $_('repoExplorer.unknownError') } 25 } 26 } 27 let newCollection = $state('') 28 let newRkey = $state('') 29 let recordJson = $state('') 30 let jsonError = $state<string | null>(null) 31 let saving = $state(false) 32 let filter = $state('') 33 $effect(() => { 34 if (!auth.loading && !auth.session) { 35 navigate('/login') 36 } 37 }) 38 $effect(() => { 39 if (auth.session) { 40 loadCollections() 41 } 42 }) 43 async function loadCollections() { 44 if (!auth.session) return 45 loading = true 46 error = null 47 try { 48 const result = await api.describeRepo(auth.session.accessJwt, auth.session.did) 49 collections = result.collections.sort() 50 } catch (e) { 51 setError(e) 52 } finally { 53 loading = false 54 } 55 } 56 async function selectCollection(collection: string) { 57 if (!auth.session) return 58 selectedCollection = collection 59 records = [] 60 recordsCursor = undefined 61 view = 'records' 62 loading = true 63 error = null 64 try { 65 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 }) 66 records = result.records.map(r => ({ 67 ...r, 68 rkey: r.uri.split('/').pop()! 69 })) 70 recordsCursor = result.cursor 71 } catch (e) { 72 setError(e) 73 } finally { 74 loading = false 75 } 76 } 77 async function loadMoreRecords() { 78 if (!auth.session || !selectedCollection || !recordsCursor) return 79 loadingMore = true 80 try { 81 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, { 82 limit: 50, 83 cursor: recordsCursor 84 }) 85 records = [...records, ...result.records.map(r => ({ 86 ...r, 87 rkey: r.uri.split('/').pop()! 88 }))] 89 recordsCursor = result.cursor 90 } catch (e) { 91 setError(e) 92 } finally { 93 loadingMore = false 94 } 95 } 96 async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) { 97 selectedRecord = record 98 recordJson = JSON.stringify(record.value, null, 2) 99 jsonError = null 100 view = 'record' 101 } 102 function startCreate(collection?: string) { 103 newCollection = collection || 'app.bsky.feed.post' 104 newRkey = '' 105 const currentLocale = $locale?.split('-')[0] || 'en' 106 const exampleRecords: Record<string, unknown> = { 107 'app.bsky.feed.post': { 108 $type: 'app.bsky.feed.post', 109 text: $_('repoExplorer.demoPostText'), 110 langs: [currentLocale], 111 createdAt: new Date().toISOString(), 112 }, 113 'app.bsky.actor.profile': { 114 $type: 'app.bsky.actor.profile', 115 displayName: $_('repoExplorer.demoDisplayName'), 116 description: $_('repoExplorer.demoBio'), 117 }, 118 'app.bsky.graph.follow': { 119 $type: 'app.bsky.graph.follow', 120 subject: 'did:web:example.com', 121 createdAt: new Date().toISOString(), 122 }, 123 'app.bsky.feed.like': { 124 $type: 'app.bsky.feed.like', 125 subject: { 126 uri: 'at://did:web:example.com/app.bsky.feed.post/abc123', 127 cid: 'bafyreiabc123...', 128 }, 129 createdAt: new Date().toISOString(), 130 }, 131 } 132 const example = exampleRecords[collection || 'app.bsky.feed.post'] || { 133 $type: collection || 'app.bsky.feed.post', 134 } 135 recordJson = JSON.stringify(example, null, 2) 136 jsonError = null 137 view = 'create' 138 } 139 function validateJson(): unknown | null { 140 try { 141 const parsed = JSON.parse(recordJson) 142 jsonError = null 143 return parsed 144 } catch (e) { 145 jsonError = e instanceof Error ? e.message : $_('repoExplorer.invalidJson') 146 return null 147 } 148 } 149 async function handleCreate(e: Event) { 150 e.preventDefault() 151 if (!auth.session) return 152 const record = validateJson() 153 if (!record) return 154 if (!newCollection.trim()) { 155 error = { message: $_('repoExplorer.collectionRequired') } 156 return 157 } 158 saving = true 159 error = null 160 try { 161 const result = await api.createRecord( 162 auth.session.accessJwt, 163 auth.session.did, 164 newCollection.trim(), 165 record, 166 newRkey.trim() || undefined 167 ) 168 success = $_('repoExplorer.recordCreated', { values: { uri: result.uri } }) 169 await loadCollections() 170 await selectCollection(newCollection.trim()) 171 } catch (e) { 172 setError(e) 173 } finally { 174 saving = false 175 } 176 } 177 async function handleUpdate(e: Event) { 178 e.preventDefault() 179 if (!auth.session || !selectedRecord || !selectedCollection) return 180 const record = validateJson() 181 if (!record) return 182 saving = true 183 error = null 184 try { 185 await api.putRecord( 186 auth.session.accessJwt, 187 auth.session.did, 188 selectedCollection, 189 selectedRecord.rkey, 190 record 191 ) 192 success = $_('repoExplorer.recordUpdated') 193 const updated = await api.getRecord( 194 auth.session.accessJwt, 195 auth.session.did, 196 selectedCollection, 197 selectedRecord.rkey 198 ) 199 selectedRecord = { ...updated, rkey: selectedRecord.rkey } 200 recordJson = JSON.stringify(updated.value, null, 2) 201 } catch (e) { 202 setError(e) 203 } finally { 204 saving = false 205 } 206 } 207 async function handleDelete() { 208 if (!auth.session || !selectedRecord || !selectedCollection) return 209 if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return 210 saving = true 211 error = null 212 try { 213 await api.deleteRecord( 214 auth.session.accessJwt, 215 auth.session.did, 216 selectedCollection, 217 selectedRecord.rkey 218 ) 219 success = $_('repoExplorer.recordDeleted') 220 selectedRecord = null 221 await selectCollection(selectedCollection) 222 } catch (e) { 223 setError(e) 224 } finally { 225 saving = false 226 } 227 } 228 function goBack() { 229 if (view === 'record' || view === 'create') { 230 if (selectedCollection) { 231 view = 'records' 232 } else { 233 view = 'collections' 234 } 235 } else if (view === 'records') { 236 selectedCollection = null 237 view = 'collections' 238 } 239 error = null 240 success = null 241 } 242 let filteredCollections = $derived( 243 filter 244 ? collections.filter(c => c.toLowerCase().includes(filter.toLowerCase())) 245 : collections 246 ) 247 let filteredRecords = $derived( 248 filter 249 ? records.filter(r => 250 r.rkey.toLowerCase().includes(filter.toLowerCase()) || 251 JSON.stringify(r.value).toLowerCase().includes(filter.toLowerCase()) 252 ) 253 : records 254 ) 255 function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> { 256 const groups = new Map<string, string[]>() 257 for (const col of cols) { 258 const parts = col.split('.') 259 const authority = parts.slice(0, -1).join('.') 260 const name = parts[parts.length - 1] 261 if (!groups.has(authority)) { 262 groups.set(authority, []) 263 } 264 groups.get(authority)!.push(name) 265 } 266 return groups 267 } 268 let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections)) 269</script> 270<div class="page"> 271 <header> 272 <div class="breadcrumb"> 273 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 274 {#if view !== 'collections'} 275 <span class="sep">/</span> 276 <button class="breadcrumb-link" onclick={goBack}> 277 {view === 'records' || view === 'create' ? $_('repoExplorer.collections') : selectedCollection} 278 </button> 279 {/if} 280 {#if view === 'record' && selectedRecord} 281 <span class="sep">/</span> 282 <span class="current">{selectedRecord.rkey}</span> 283 {/if} 284 {#if view === 'create'} 285 <span class="sep">/</span> 286 <span class="current">{$_('repoExplorer.newRecord')}</span> 287 {/if} 288 </div> 289 <h1> 290 {#if view === 'collections'} 291 {$_('repoExplorer.title')} 292 {:else if view === 'records'} 293 {selectedCollection} 294 {:else if view === 'record'} 295 {$_('repoExplorer.recordDetails')} 296 {:else} 297 {$_('repoExplorer.createRecord')} 298 {/if} 299 </h1> 300 {#if auth.session} 301 <p class="did">{auth.session.did}</p> 302 {/if} 303 </header> 304 {#if error} 305 <div class="message error"> 306 {#if error.code} 307 <strong class="error-code">{error.code}</strong> 308 {/if} 309 <span class="error-message">{error.message}</span> 310 </div> 311 {/if} 312 {#if success} 313 <div class="message success">{success}</div> 314 {/if} 315 {#if loading} 316 <p class="loading-text">{$_('common.loading')}</p> 317 {:else if view === 'collections'} 318 <div class="toolbar"> 319 <input 320 type="text" 321 placeholder={$_('repoExplorer.filterCollections')} 322 bind:value={filter} 323 class="filter-input" 324 /> 325 <button class="primary" onclick={() => startCreate()}>{$_('repoExplorer.createRecord')}</button> 326 </div> 327 {#if collections.length === 0} 328 <p class="empty">{$_('repoExplorer.noCollectionsYet')}</p> 329 {:else} 330 <div class="collections"> 331 {#each [...groupedCollections.entries()] as [authority, nsids]} 332 <div class="collection-group"> 333 <h3 class="authority">{authority}</h3> 334 <ul class="nsid-list"> 335 {#each nsids as nsid} 336 <li> 337 <button class="collection-link" onclick={() => selectCollection(`${authority}.${nsid}`)}> 338 <span class="nsid">{nsid}</span> 339 <span class="arrow">&rarr;</span> 340 </button> 341 </li> 342 {/each} 343 </ul> 344 </div> 345 {/each} 346 </div> 347 {/if} 348 {:else if view === 'records'} 349 <div class="toolbar"> 350 <input 351 type="text" 352 placeholder={$_('repoExplorer.filterRecords')} 353 bind:value={filter} 354 class="filter-input" 355 /> 356 <button class="primary" onclick={() => startCreate(selectedCollection!)}>{$_('repoExplorer.createRecord')}</button> 357 </div> 358 {#if records.length === 0} 359 <p class="empty">{$_('repoExplorer.noRecords')}</p> 360 {:else} 361 <ul class="record-list"> 362 {#each filteredRecords as record} 363 <li> 364 <button class="record-item" onclick={() => selectRecord(record)}> 365 <div class="record-info"> 366 <span class="rkey">{record.rkey}</span> 367 <span class="cid" title={record.cid}>{record.cid.slice(0, 12)}...</span> 368 </div> 369 <pre class="record-preview">{JSON.stringify(record.value, null, 2).slice(0, 200)}{JSON.stringify(record.value).length > 200 ? '...' : ''}</pre> 370 </button> 371 </li> 372 {/each} 373 </ul> 374 {#if recordsCursor} 375 <div class="load-more"> 376 <button onclick={loadMoreRecords} disabled={loadingMore}> 377 {loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')} 378 </button> 379 </div> 380 {/if} 381 {/if} 382 {:else if view === 'record' && selectedRecord} 383 <div class="record-detail"> 384 <div class="record-meta"> 385 <dl> 386 <dt>URI</dt> 387 <dd class="mono">{selectedRecord.uri}</dd> 388 <dt>CID</dt> 389 <dd class="mono">{selectedRecord.cid}</dd> 390 </dl> 391 </div> 392 <form onsubmit={handleUpdate}> 393 <div class="editor-container"> 394 <label for="record-json">{$_('repoExplorer.recordJson')}</label> 395 <textarea 396 id="record-json" 397 bind:value={recordJson} 398 oninput={() => validateJson()} 399 class:has-error={jsonError} 400 spellcheck="false" 401 ></textarea> 402 {#if jsonError} 403 <p class="json-error">{jsonError}</p> 404 {/if} 405 </div> 406 <div class="actions"> 407 <button type="submit" class="primary" disabled={saving || !!jsonError}> 408 {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 409 </button> 410 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 411 {$_('common.delete')} 412 </button> 413 </div> 414 </form> 415 </div> 416 {:else if view === 'create'} 417 <form class="create-form" onsubmit={handleCreate}> 418 <div class="field"> 419 <label for="collection">{$_('repoExplorer.collectionNsid')}</label> 420 <input 421 id="collection" 422 type="text" 423 bind:value={newCollection} 424 placeholder="app.bsky.feed.post" 425 disabled={saving} 426 required 427 /> 428 </div> 429 <div class="field"> 430 <label for="rkey">{$_('repoExplorer.recordKeyOptional')}</label> 431 <input 432 id="rkey" 433 type="text" 434 bind:value={newRkey} 435 placeholder={$_('repoExplorer.autoGenerated')} 436 disabled={saving} 437 /> 438 <p class="hint">{$_('repoExplorer.autoGeneratedHint')}</p> 439 </div> 440 <div class="editor-container"> 441 <label for="new-record-json">{$_('repoExplorer.recordJson')}</label> 442 <textarea 443 id="new-record-json" 444 bind:value={recordJson} 445 oninput={() => validateJson()} 446 class:has-error={jsonError} 447 spellcheck="false" 448 ></textarea> 449 {#if jsonError} 450 <p class="json-error">{jsonError}</p> 451 {/if} 452 </div> 453 <div class="actions"> 454 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 455 {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 456 </button> 457 <button type="button" class="secondary" onclick={goBack}> 458 {$_('common.cancel')} 459 </button> 460 </div> 461 </form> 462 {/if} 463</div> 464<style> 465 .page { 466 max-width: var(--width-lg); 467 margin: 0 auto; 468 padding: var(--space-7); 469 } 470 471 header { 472 margin-bottom: var(--space-6); 473 } 474 475 .breadcrumb { 476 display: flex; 477 align-items: center; 478 gap: var(--space-2); 479 font-size: var(--text-sm); 480 margin-bottom: var(--space-2); 481 } 482 483 .back { 484 color: var(--text-secondary); 485 text-decoration: none; 486 } 487 488 .back:hover { 489 color: var(--accent); 490 } 491 492 .sep { 493 color: var(--text-muted); 494 } 495 496 .breadcrumb-link { 497 background: none; 498 border: none; 499 padding: 0; 500 color: var(--accent); 501 cursor: pointer; 502 font-size: inherit; 503 } 504 505 .breadcrumb-link:hover { 506 text-decoration: underline; 507 } 508 509 .current { 510 color: var(--text-secondary); 511 } 512 513 h1 { 514 margin: 0; 515 font-size: var(--text-xl); 516 } 517 518 .did { 519 margin: var(--space-1) 0 0 0; 520 font-family: monospace; 521 font-size: var(--text-xs); 522 color: var(--text-muted); 523 word-break: break-all; 524 } 525 526 .message { 527 padding: var(--space-4); 528 border-radius: var(--radius-xl); 529 margin-bottom: var(--space-4); 530 } 531 532 .message.error { 533 background: var(--error-bg); 534 border: 1px solid var(--error-border); 535 color: var(--error-text); 536 display: flex; 537 flex-direction: column; 538 gap: var(--space-1); 539 } 540 541 .error-code { 542 font-family: monospace; 543 font-size: var(--text-sm); 544 opacity: 0.9; 545 } 546 547 .error-message { 548 font-size: var(--text-sm); 549 line-height: 1.5; 550 } 551 552 .message.success { 553 background: var(--success-bg); 554 border: 1px solid var(--success-border); 555 color: var(--success-text); 556 } 557 558 .loading-text { 559 text-align: center; 560 color: var(--text-secondary); 561 padding: var(--space-7); 562 } 563 564 .toolbar { 565 display: flex; 566 gap: var(--space-2); 567 margin-bottom: var(--space-4); 568 } 569 570 .filter-input { 571 flex: 1; 572 padding: var(--space-2) var(--space-3); 573 border: 1px solid var(--border-color); 574 border-radius: var(--radius-md); 575 font-size: var(--text-sm); 576 background: var(--bg-input); 577 color: var(--text-primary); 578 } 579 580 .filter-input:focus { 581 outline: none; 582 border-color: var(--accent); 583 } 584 585 button.primary { 586 padding: var(--space-2) var(--space-4); 587 background: var(--accent); 588 color: var(--text-inverse); 589 border: none; 590 border-radius: var(--radius-md); 591 cursor: pointer; 592 font-size: var(--text-sm); 593 } 594 595 button.primary:hover:not(:disabled) { 596 background: var(--accent-hover); 597 } 598 599 button.primary:disabled { 600 opacity: 0.6; 601 cursor: not-allowed; 602 } 603 604 button.secondary { 605 padding: var(--space-2) var(--space-4); 606 background: transparent; 607 color: var(--text-secondary); 608 border: 1px solid var(--border-color); 609 border-radius: var(--radius-md); 610 cursor: pointer; 611 font-size: var(--text-sm); 612 } 613 614 button.secondary:hover:not(:disabled) { 615 background: var(--bg-secondary); 616 } 617 618 button.danger { 619 padding: var(--space-2) var(--space-4); 620 background: transparent; 621 color: var(--error-text); 622 border: 1px solid var(--error-text); 623 border-radius: var(--radius-md); 624 cursor: pointer; 625 font-size: var(--text-sm); 626 } 627 628 button.danger:hover:not(:disabled) { 629 background: var(--error-bg); 630 } 631 632 .empty { 633 text-align: center; 634 color: var(--text-secondary); 635 padding: var(--space-8); 636 background: var(--bg-secondary); 637 border-radius: var(--radius-xl); 638 } 639 640 .collections { 641 display: flex; 642 flex-direction: column; 643 gap: var(--space-4); 644 } 645 646 .collection-group { 647 background: var(--bg-secondary); 648 border-radius: var(--radius-xl); 649 padding: var(--space-4); 650 } 651 652 .authority { 653 margin: 0 0 var(--space-3) 0; 654 font-size: var(--text-sm); 655 color: var(--text-secondary); 656 font-weight: var(--font-medium); 657 } 658 659 .nsid-list { 660 list-style: none; 661 padding: 0; 662 margin: 0; 663 display: flex; 664 flex-direction: column; 665 gap: var(--space-1); 666 } 667 668 .collection-link { 669 display: flex; 670 justify-content: space-between; 671 align-items: center; 672 width: 100%; 673 padding: var(--space-3); 674 background: var(--bg-card); 675 border: 1px solid var(--border-color); 676 border-radius: var(--radius-md); 677 cursor: pointer; 678 text-align: left; 679 color: var(--text-primary); 680 transition: border-color var(--transition-fast); 681 } 682 683 .collection-link:hover { 684 border-color: var(--accent); 685 } 686 687 .nsid { 688 font-weight: var(--font-medium); 689 color: var(--accent); 690 } 691 692 .arrow { 693 color: var(--text-muted); 694 } 695 696 .record-list { 697 list-style: none; 698 padding: 0; 699 margin: 0; 700 display: flex; 701 flex-direction: column; 702 gap: var(--space-2); 703 } 704 705 .record-item { 706 display: block; 707 width: 100%; 708 padding: var(--space-4); 709 background: var(--bg-card); 710 border: 1px solid var(--border-color); 711 border-radius: var(--radius-md); 712 cursor: pointer; 713 text-align: left; 714 color: var(--text-primary); 715 transition: border-color var(--transition-fast); 716 } 717 718 .record-item:hover { 719 border-color: var(--accent); 720 } 721 722 .record-info { 723 display: flex; 724 justify-content: space-between; 725 margin-bottom: var(--space-2); 726 } 727 728 .rkey { 729 font-family: monospace; 730 font-weight: var(--font-medium); 731 color: var(--accent); 732 } 733 734 .cid { 735 font-family: monospace; 736 font-size: var(--text-xs); 737 color: var(--text-muted); 738 } 739 740 .record-preview { 741 margin: 0; 742 padding: var(--space-2); 743 background: var(--bg-secondary); 744 border-radius: var(--radius-md); 745 font-family: monospace; 746 font-size: var(--text-xs); 747 color: var(--text-secondary); 748 white-space: pre-wrap; 749 word-break: break-word; 750 max-height: 100px; 751 overflow: hidden; 752 } 753 754 .load-more { 755 text-align: center; 756 padding: var(--space-4); 757 } 758 759 .load-more button { 760 padding: var(--space-2) var(--space-7); 761 background: var(--bg-secondary); 762 border: 1px solid var(--border-color); 763 border-radius: var(--radius-md); 764 cursor: pointer; 765 color: var(--text-primary); 766 } 767 768 .load-more button:hover:not(:disabled) { 769 background: var(--bg-card); 770 } 771 772 .record-detail { 773 display: flex; 774 flex-direction: column; 775 gap: var(--space-6); 776 } 777 778 .record-meta { 779 background: var(--bg-secondary); 780 padding: var(--space-4); 781 border-radius: var(--radius-xl); 782 } 783 784 .record-meta dl { 785 display: grid; 786 grid-template-columns: auto 1fr; 787 gap: var(--space-2) var(--space-4); 788 margin: 0; 789 } 790 791 .record-meta dt { 792 font-weight: var(--font-medium); 793 color: var(--text-secondary); 794 } 795 796 .record-meta dd { 797 margin: 0; 798 } 799 800 .mono { 801 font-family: monospace; 802 font-size: var(--text-xs); 803 word-break: break-all; 804 } 805 806 .field { 807 margin-bottom: var(--space-4); 808 } 809 810 .field label { 811 display: block; 812 font-size: var(--text-sm); 813 font-weight: var(--font-medium); 814 margin-bottom: var(--space-1); 815 } 816 817 .field input { 818 width: 100%; 819 padding: var(--space-3); 820 border: 1px solid var(--border-color); 821 border-radius: var(--radius-md); 822 font-size: var(--text-base); 823 background: var(--bg-input); 824 color: var(--text-primary); 825 box-sizing: border-box; 826 } 827 828 .field input:focus { 829 outline: none; 830 border-color: var(--accent); 831 } 832 833 .hint { 834 font-size: var(--text-xs); 835 color: var(--text-muted); 836 margin: var(--space-1) 0 0 0; 837 } 838 839 .editor-container { 840 margin-bottom: var(--space-4); 841 } 842 843 .editor-container label { 844 display: block; 845 font-size: var(--text-sm); 846 font-weight: var(--font-medium); 847 margin-bottom: var(--space-1); 848 } 849 850 textarea { 851 width: 100%; 852 min-height: 300px; 853 padding: var(--space-4); 854 border: 1px solid var(--border-color); 855 border-radius: var(--radius-md); 856 font-family: monospace; 857 font-size: var(--text-sm); 858 background: var(--bg-input); 859 color: var(--text-primary); 860 resize: vertical; 861 box-sizing: border-box; 862 } 863 864 textarea:focus { 865 outline: none; 866 border-color: var(--accent); 867 } 868 869 textarea.has-error { 870 border-color: var(--error-text); 871 } 872 873 .json-error { 874 margin: var(--space-1) 0 0 0; 875 font-size: var(--text-xs); 876 color: var(--error-text); 877 } 878 879 .actions { 880 display: flex; 881 gap: var(--space-2); 882 } 883 884 .create-form { 885 background: var(--bg-secondary); 886 padding: var(--space-6); 887 border-radius: var(--radius-xl); 888 } 889</style>