@meddleware/seal-ui 0.0.16 → 0.0.18
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.
- package/package.json +2 -2
- package/src/components/SealView.vue +231 -132
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/seal-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"homepage": "https://docs.meddleware.co.uk/blockchain/sui/sealed-storage/",
|
|
5
5
|
"description": "Standalone Vue 3 SPA for Sealed Storage — client-side Seal encryption + access-gated, decentralised Walrus storage on Sui.",
|
|
6
6
|
"author": "MeddleWare <meddleware@proton.me>",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@meddleware/design-tokens": "^0.1.5",
|
|
44
44
|
"@meddleware/seal-client": "^0.0.5",
|
|
45
|
-
"@meddleware/ui": "^0.1.
|
|
45
|
+
"@meddleware/ui": "^0.1.21",
|
|
46
46
|
"@meddleware/wallet-adapter": "^0.0.7",
|
|
47
47
|
"@mysten/sui": "^2.31.0",
|
|
48
48
|
"@mysten/wallet-standard": "^0.21.0",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// global stylesheet (which restyles body / #app / bare inputs). The standalone app keeps those
|
|
9
9
|
// globals via main.ts → styles.css.
|
|
10
10
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
11
|
-
import { AppTabNav, type AppTab } from '@meddleware/ui'
|
|
11
|
+
import { AppTabNav, UiStepper, type AppTab, type StepperStep } from '@meddleware/ui'
|
|
12
12
|
import { Transaction } from '@mysten/sui/transactions'
|
|
13
13
|
import {
|
|
14
14
|
buildPublishSealedContentTx,
|
|
@@ -37,6 +37,30 @@ const TABS: AppTab[] = [
|
|
|
37
37
|
]
|
|
38
38
|
const tab = ref<string>('encrypt')
|
|
39
39
|
|
|
40
|
+
const ENC_STEPS: StepperStep[] = [
|
|
41
|
+
{ id: 'policy', label: 'Policy' },
|
|
42
|
+
{ id: 'details', label: 'Details' },
|
|
43
|
+
{ id: 'confirm', label: 'Confirm' },
|
|
44
|
+
]
|
|
45
|
+
const DEC_STEPS: StepperStep[] = [
|
|
46
|
+
{ id: 'manifest', label: 'Manifest' },
|
|
47
|
+
{ id: 'decrypt', label: 'Decrypt' },
|
|
48
|
+
]
|
|
49
|
+
const UNLOCK_STEPS: StepperStep[] = [
|
|
50
|
+
{ id: 'gate', label: 'Gate' },
|
|
51
|
+
{ id: 'unlock', label: 'Unlock' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const encStep = ref(0)
|
|
55
|
+
const decStep = ref(0)
|
|
56
|
+
const unlockStep = ref(0)
|
|
57
|
+
|
|
58
|
+
watch(tab, () => {
|
|
59
|
+
encStep.value = 0
|
|
60
|
+
decStep.value = 0
|
|
61
|
+
unlockStep.value = 0
|
|
62
|
+
})
|
|
63
|
+
|
|
40
64
|
// Deep-link from access-gate-ui: ?gate=<id> preselects the nft-gate policy + gate.
|
|
41
65
|
// Force manual mode so the pre-set ID renders in the text input immediately, before suggestions load.
|
|
42
66
|
onMounted(() => {
|
|
@@ -160,6 +184,7 @@ async function performEncrypt(): Promise<void> {
|
|
|
160
184
|
label: encLabel.value || encFile.value.name,
|
|
161
185
|
}
|
|
162
186
|
status.value = 'Sealed. Keep the manifest below — you need it to decrypt.'
|
|
187
|
+
encStep.value = 2
|
|
163
188
|
} catch (e) {
|
|
164
189
|
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
165
190
|
} finally {
|
|
@@ -285,6 +310,7 @@ async function performDiscover(): Promise<void> {
|
|
|
285
310
|
status.value = discovered.value.length
|
|
286
311
|
? `Found ${discovered.value.length} item(s).`
|
|
287
312
|
: 'No sealed content published for this gate.'
|
|
313
|
+
if (discovered.value.length) unlockStep.value = 1
|
|
288
314
|
} catch (e) {
|
|
289
315
|
errorMsg.value = e instanceof Error ? e.message : String(e)
|
|
290
316
|
} finally {
|
|
@@ -349,157 +375,214 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
|
|
|
349
375
|
|
|
350
376
|
<!-- Encrypt -->
|
|
351
377
|
<section v-show="tab === 'encrypt'" class="card">
|
|
352
|
-
<
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
</
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
<
|
|
376
|
-
<
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
378
|
+
<UiStepper :steps="ENC_STEPS" v-model="encStep" />
|
|
379
|
+
|
|
380
|
+
<!-- Step 0: Policy -->
|
|
381
|
+
<template v-if="encStep === 0">
|
|
382
|
+
<label class="field">
|
|
383
|
+
<span>Policy</span>
|
|
384
|
+
<select v-model="encType" :disabled="disabled">
|
|
385
|
+
<option v-for="p in providers" :key="p.type" :value="p.type">
|
|
386
|
+
{{ p.describe().label }}
|
|
387
|
+
</option>
|
|
388
|
+
</select>
|
|
389
|
+
</label>
|
|
390
|
+
<p v-if="encProvider" class="muted">{{ encProvider.describe().help }}</p>
|
|
391
|
+
<div class="nav-row">
|
|
392
|
+
<button type="button" class="primary" :disabled="disabled || !encProvider" @click="encStep++">Next</button>
|
|
393
|
+
</div>
|
|
394
|
+
</template>
|
|
395
|
+
|
|
396
|
+
<!-- Step 1: Details (policy fields + file + label) -->
|
|
397
|
+
<template v-else-if="encStep === 1">
|
|
398
|
+
<template v-if="encProvider">
|
|
399
|
+
<template v-for="f in encProvider.describe().encryptFields" :key="f.name">
|
|
400
|
+
<!-- Gate ID field: show a picker when wallet-owned gates are available -->
|
|
401
|
+
<label v-if="f.name === 'gateId'" class="field">
|
|
402
|
+
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
403
|
+
<span v-if="encGateLoading" class="muted" style="font-size:0.8rem">Loading your gates…</span>
|
|
404
|
+
<template v-else-if="encGateSuggestions.length && !encGateManual">
|
|
405
|
+
<select v-model="encValues[f.name]">
|
|
406
|
+
<option value="">Select a gate…</option>
|
|
407
|
+
<option v-for="s in encGateSuggestions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
|
408
|
+
</select>
|
|
409
|
+
<button class="link" style="margin-top:0.25rem" @click="encGateManual = true; encValues[f.name] = ''">Enter ID manually</button>
|
|
410
|
+
</template>
|
|
411
|
+
<template v-else>
|
|
412
|
+
<input type="text" v-model="encValues[f.name]" :placeholder="f.help" />
|
|
413
|
+
<button v-if="encGateSuggestions.length" class="link" style="margin-top:0.25rem" @click="encGateManual = false; encValues[f.name] = ''">← Back to picker</button>
|
|
414
|
+
</template>
|
|
415
|
+
</label>
|
|
416
|
+
<!-- All other fields: generic rendering -->
|
|
417
|
+
<label v-else class="field" :class="{ checkbox: f.kind === 'boolean' }">
|
|
418
|
+
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
419
|
+
<input v-if="f.kind === 'datetime'" type="datetime-local" v-model="encValues[f.name]" />
|
|
420
|
+
<input v-else-if="f.kind === 'boolean'" type="checkbox" v-model="encValues[f.name]" />
|
|
421
|
+
<input v-else type="text" v-model="encValues[f.name]" :placeholder="f.help" />
|
|
422
|
+
</label>
|
|
423
|
+
</template>
|
|
387
424
|
</template>
|
|
425
|
+
|
|
426
|
+
<label class="field">
|
|
427
|
+
<span>Label (optional)</span>
|
|
428
|
+
<input type="text" v-model="encLabel" placeholder="A name to recognise this later" />
|
|
429
|
+
</label>
|
|
430
|
+
|
|
431
|
+
<label class="field">
|
|
432
|
+
<span>File</span>
|
|
433
|
+
<input type="file" @change="onEncFile" />
|
|
434
|
+
</label>
|
|
435
|
+
|
|
436
|
+
<div class="nav-row">
|
|
437
|
+
<button type="button" class="link" @click="encStep--">Back</button>
|
|
438
|
+
<button type="button" class="primary" :disabled="disabled || !encFile" @click="encStep++">Next</button>
|
|
439
|
+
</div>
|
|
388
440
|
</template>
|
|
389
441
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
<
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
442
|
+
<!-- Step 2: Confirm + encrypt -->
|
|
443
|
+
<template v-else-if="encStep === 2">
|
|
444
|
+
<div v-if="!manifest" class="confirm-summary">
|
|
445
|
+
<p class="muted">Policy: <strong>{{ encProvider?.describe().label }}</strong></p>
|
|
446
|
+
<p class="muted">File: <strong>{{ encFile?.name ?? '—' }}</strong></p>
|
|
447
|
+
<p v-if="encLabel" class="muted">Label: <strong>{{ encLabel }}</strong></p>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<button v-if="!manifest" class="primary" :disabled="busy || disabled" @click="performEncrypt">
|
|
451
|
+
{{ busy ? 'Working…' : 'Encrypt & store' }}
|
|
452
|
+
</button>
|
|
453
|
+
|
|
454
|
+
<div v-if="manifest" style="margin-top:1rem">
|
|
455
|
+
<p class="muted">Manifest — save this; it's required to decrypt (it holds no secrets):</p>
|
|
456
|
+
<pre class="manifest">{{ JSON.stringify(manifest, null, 2) }}</pre>
|
|
457
|
+
<button class="link" @click="downloadManifest">Download manifest</button>
|
|
458
|
+
|
|
459
|
+
<div v-if="manifest.policyType === 'nft-gate'" style="margin-top:0.75rem">
|
|
460
|
+
<p class="muted">
|
|
461
|
+
Optional: publish an on-chain pointer so this gate's pass-holders can discover and unlock
|
|
462
|
+
this content in the Unlock tab (no need to share the manifest).
|
|
463
|
+
</p>
|
|
464
|
+
<button class="primary" :disabled="busy || !account" @click="performPublish">
|
|
465
|
+
{{ busy ? 'Working…' : 'Publish discovery pointer' }}
|
|
466
|
+
</button>
|
|
467
|
+
<span v-if="!account" class="muted"> — connect a wallet first.</span>
|
|
468
|
+
<p v-if="publishDigest" class="muted">Published in tx {{ publishDigest.slice(0, 10) }}…</p>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<div v-if="!manifest" class="nav-row">
|
|
473
|
+
<button type="button" class="link" @click="encStep--">Back</button>
|
|
419
474
|
</div>
|
|
420
|
-
</
|
|
475
|
+
</template>
|
|
421
476
|
</section>
|
|
422
477
|
|
|
423
478
|
<!-- Unlock -->
|
|
424
479
|
<section v-show="tab === 'unlock'" class="card">
|
|
425
|
-
<
|
|
426
|
-
Discover content sealed to an access gate and decrypt it with a pass you hold.
|
|
427
|
-
</p>
|
|
428
|
-
<label class="field">
|
|
429
|
-
<span>Access gate ID</span>
|
|
430
|
-
<span v-if="unlockGateLoading" class="muted" style="font-size:0.8rem">Loading your gates…</span>
|
|
431
|
-
<template v-else-if="unlockGateSuggestions.length && !unlockGateManual">
|
|
432
|
-
<select v-model="unlockGateId">
|
|
433
|
-
<option value="">Select a gate…</option>
|
|
434
|
-
<option v-for="s in unlockGateSuggestions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
|
435
|
-
</select>
|
|
436
|
-
<button class="link" style="margin-top:0.25rem" @click="unlockGateManual = true; unlockGateId = ''">Enter ID manually</button>
|
|
437
|
-
</template>
|
|
438
|
-
<template v-else>
|
|
439
|
-
<input type="text" v-model="unlockGateId" placeholder="0x… gate object id" />
|
|
440
|
-
<button v-if="unlockGateSuggestions.length" class="link" style="margin-top:0.25rem" @click="unlockGateManual = false; unlockGateId = ''">← Back to picker</button>
|
|
441
|
-
</template>
|
|
442
|
-
</label>
|
|
443
|
-
<button class="primary" :disabled="busy || !unlockGateId" @click="performDiscover">
|
|
444
|
-
{{ busy ? 'Working…' : 'Find sealed content' }}
|
|
445
|
-
</button>
|
|
480
|
+
<UiStepper :steps="UNLOCK_STEPS" v-model="unlockStep" />
|
|
446
481
|
|
|
447
|
-
|
|
482
|
+
<!-- Step 0: Gate selector -->
|
|
483
|
+
<template v-if="unlockStep === 0">
|
|
484
|
+
<p class="muted">
|
|
485
|
+
Discover content sealed to an access gate and decrypt it with a pass you hold.
|
|
486
|
+
</p>
|
|
448
487
|
<label class="field">
|
|
449
|
-
<span>
|
|
450
|
-
<
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
488
|
+
<span>Access gate ID</span>
|
|
489
|
+
<span v-if="unlockGateLoading" class="muted" style="font-size:0.8rem">Loading your gates…</span>
|
|
490
|
+
<template v-else-if="unlockGateSuggestions.length && !unlockGateManual">
|
|
491
|
+
<select v-model="unlockGateId">
|
|
492
|
+
<option value="">Select a gate…</option>
|
|
493
|
+
<option v-for="s in unlockGateSuggestions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
|
494
|
+
</select>
|
|
495
|
+
<button class="link" style="margin-top:0.25rem" @click="unlockGateManual = true; unlockGateId = ''">Enter ID manually</button>
|
|
496
|
+
</template>
|
|
497
|
+
<template v-else>
|
|
498
|
+
<input type="text" v-model="unlockGateId" placeholder="0x… gate object id" />
|
|
499
|
+
<button v-if="unlockGateSuggestions.length" class="link" style="margin-top:0.25rem" @click="unlockGateManual = false; unlockGateId = ''">← Back to picker</button>
|
|
500
|
+
</template>
|
|
455
501
|
</label>
|
|
502
|
+
<button class="primary" :disabled="busy || !unlockGateId" @click="performDiscover">
|
|
503
|
+
{{ busy ? 'Working…' : 'Find sealed content' }}
|
|
504
|
+
</button>
|
|
505
|
+
</template>
|
|
456
506
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
507
|
+
<!-- Step 1: Results + unlock -->
|
|
508
|
+
<template v-else-if="unlockStep === 1">
|
|
509
|
+
<div v-if="!discovered.length" class="muted">No sealed content found for this gate.</div>
|
|
510
|
+
|
|
511
|
+
<div v-if="discovered.length">
|
|
512
|
+
<label class="field">
|
|
513
|
+
<span>Your pass NFT ID *</span>
|
|
514
|
+
<input type="text" v-model="unlockNftId" placeholder="0x… your AccessNFT for this gate" />
|
|
515
|
+
</label>
|
|
516
|
+
<label class="field checkbox">
|
|
517
|
+
<input type="checkbox" v-model="unlockSoulbound" />
|
|
518
|
+
<span>Pass is soulbound</span>
|
|
519
|
+
</label>
|
|
520
|
+
|
|
521
|
+
<div v-for="item in discovered" :key="item.contentId" class="card" style="margin:0.5rem 0">
|
|
522
|
+
<strong>{{ item.label || '(untitled)' }}</strong>
|
|
523
|
+
<p class="muted" style="word-break:break-all">blob {{ item.blobId }}</p>
|
|
524
|
+
<button class="primary" :disabled="busy || !account || !unlockNftId" @click="performUnlock(item)">
|
|
525
|
+
Unlock & download
|
|
526
|
+
</button>
|
|
527
|
+
<span v-if="!account" class="muted"> — connect a wallet to decrypt.</span>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<div class="nav-row">
|
|
532
|
+
<button type="button" class="link" @click="unlockStep--; discovered = []">Back</button>
|
|
464
533
|
</div>
|
|
465
|
-
</
|
|
534
|
+
</template>
|
|
466
535
|
</section>
|
|
467
536
|
|
|
468
537
|
<!-- Decrypt -->
|
|
469
538
|
<section v-show="tab === 'decrypt'" class="card">
|
|
470
|
-
<
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
<p class="muted" style="margin-top:0.75rem">
|
|
478
|
-
Policy: {{ decProvider.describe().label }} — {{ decProvider.describe().help }}
|
|
479
|
-
</p>
|
|
480
|
-
<label
|
|
481
|
-
v-for="f in decProvider.describe().decryptFields"
|
|
482
|
-
:key="f.name"
|
|
483
|
-
class="field"
|
|
484
|
-
:class="{ checkbox: f.kind === 'boolean' }"
|
|
485
|
-
>
|
|
486
|
-
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
487
|
-
<input v-if="f.kind === 'datetime'" type="datetime-local" v-model="decValues[f.name]" />
|
|
488
|
-
<input v-else-if="f.kind === 'boolean'" type="checkbox" v-model="decValues[f.name]" />
|
|
489
|
-
<input
|
|
490
|
-
v-else
|
|
491
|
-
type="text"
|
|
492
|
-
v-model="decValues[f.name]"
|
|
493
|
-
:placeholder="decManifest?.params?.[f.name] != null ? String(decManifest.params[f.name]) : f.help"
|
|
494
|
-
/>
|
|
539
|
+
<UiStepper :steps="DEC_STEPS" v-model="decStep" />
|
|
540
|
+
|
|
541
|
+
<!-- Step 0: Manifest input -->
|
|
542
|
+
<template v-if="decStep === 0">
|
|
543
|
+
<label class="field">
|
|
544
|
+
<span>Manifest (paste JSON or upload)</span>
|
|
545
|
+
<textarea v-model="decManifestText" placeholder='{ "policyType": "...", "id": "...", "blobId": "..." }'></textarea>
|
|
495
546
|
</label>
|
|
547
|
+
<input type="file" accept="application/json,.json" @change="onManifestFile" />
|
|
548
|
+
<p v-if="decManifestError" class="muted">{{ decManifestError }}</p>
|
|
549
|
+
<p v-else-if="decManifestText.trim() && !decManifest" class="muted">Unrecognised or invalid manifest.</p>
|
|
550
|
+
<div class="nav-row">
|
|
551
|
+
<button type="button" class="primary" :disabled="!decManifest" @click="decStep++">Next</button>
|
|
552
|
+
</div>
|
|
496
553
|
</template>
|
|
497
|
-
<p v-else-if="decManifestError" class="muted">{{ decManifestError }}</p>
|
|
498
|
-
<p v-else-if="decManifestText.trim()" class="muted">Unrecognised or invalid manifest.</p>
|
|
499
554
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
555
|
+
<!-- Step 1: Policy fields + decrypt -->
|
|
556
|
+
<template v-else-if="decStep === 1">
|
|
557
|
+
<template v-if="decProvider">
|
|
558
|
+
<p class="muted" style="margin-top:0.75rem">
|
|
559
|
+
Policy: {{ decProvider.describe().label }} — {{ decProvider.describe().help }}
|
|
560
|
+
</p>
|
|
561
|
+
<label
|
|
562
|
+
v-for="f in decProvider.describe().decryptFields"
|
|
563
|
+
:key="f.name"
|
|
564
|
+
class="field"
|
|
565
|
+
:class="{ checkbox: f.kind === 'boolean' }"
|
|
566
|
+
>
|
|
567
|
+
<span>{{ f.label }}<template v-if="f.required"> *</template></span>
|
|
568
|
+
<input v-if="f.kind === 'datetime'" type="datetime-local" v-model="decValues[f.name]" />
|
|
569
|
+
<input v-else-if="f.kind === 'boolean'" type="checkbox" v-model="decValues[f.name]" />
|
|
570
|
+
<input
|
|
571
|
+
v-else
|
|
572
|
+
type="text"
|
|
573
|
+
v-model="decValues[f.name]"
|
|
574
|
+
:placeholder="decManifest?.params?.[f.name] != null ? String(decManifest.params[f.name]) : f.help"
|
|
575
|
+
/>
|
|
576
|
+
</label>
|
|
577
|
+
</template>
|
|
578
|
+
|
|
579
|
+
<div class="nav-row">
|
|
580
|
+
<button type="button" class="link" @click="decStep--">Back</button>
|
|
581
|
+
<button class="primary" :disabled="busy || !decProvider" @click="performDecrypt">
|
|
582
|
+
{{ busy ? 'Working…' : 'Decrypt' }}
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
</template>
|
|
503
586
|
</section>
|
|
504
587
|
|
|
505
588
|
<p v-if="status" class="status muted">{{ status }}</p>
|
|
@@ -594,6 +677,22 @@ pre.manifest {
|
|
|
594
677
|
font-size: 0.75rem;
|
|
595
678
|
}
|
|
596
679
|
|
|
680
|
+
.confirm-summary {
|
|
681
|
+
padding: 0.6rem 0;
|
|
682
|
+
display: flex;
|
|
683
|
+
flex-direction: column;
|
|
684
|
+
gap: 0.2rem;
|
|
685
|
+
margin-bottom: 0.75rem;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.nav-row {
|
|
689
|
+
display: flex;
|
|
690
|
+
gap: 0.6rem;
|
|
691
|
+
justify-content: flex-end;
|
|
692
|
+
align-items: center;
|
|
693
|
+
margin-top: 0.75rem;
|
|
694
|
+
}
|
|
695
|
+
|
|
597
696
|
.muted { color: var(--muted, #a89b96); font-size: 0.85rem; }
|
|
598
697
|
.status { font-size: 0.85rem; margin-top: 0.5rem; }
|
|
599
698
|
.disclaimer { margin-top: 2rem; }
|