@meddleware/token-deployer-ui 0.0.1

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 (103) hide show
  1. package/.claude/move-language-reference/SKILL.md +445 -0
  2. package/.claude/skills/move-language-reference/SKILL.md +445 -0
  3. package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
  4. package/.claude/sui-move-bootcamp/SKILL.md +128 -0
  5. package/.editorconfig +8 -0
  6. package/.env.example +75 -0
  7. package/.env.production +7 -0
  8. package/.gitattributes +1 -0
  9. package/.github/workflows/ci.yml +34 -0
  10. package/.github/workflows/token-deployer-e2e.yml +64 -0
  11. package/.oxfmtrc.json +5 -0
  12. package/.oxlintrc.json +10 -0
  13. package/.vscode/extensions.json +9 -0
  14. package/AGENTS.md +39 -0
  15. package/CLAUDE.md +159 -0
  16. package/LICENSE +121 -0
  17. package/README.md +234 -0
  18. package/env.d.ts +13 -0
  19. package/index.html +13 -0
  20. package/package.json +67 -0
  21. package/playwright.config.ts +30 -0
  22. package/public/CNAME +1 -0
  23. package/public/_headers +12 -0
  24. package/public/_redirects +1 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/favicon.svg +5 -0
  27. package/public/og-image.png +0 -0
  28. package/public/robots.txt +3 -0
  29. package/public/sitemap.xml +8 -0
  30. package/scripts/e2e-browser.mjs +15 -0
  31. package/scripts/e2e-deploy.mjs +222 -0
  32. package/scripts/e2e-localnet.mjs +65 -0
  33. package/scripts/e2e-walrus-browser.mjs +115 -0
  34. package/scripts/gen-og-image.mjs +31 -0
  35. package/scripts/publish-localnet.mjs +55 -0
  36. package/scripts/regen-template.sh +45 -0
  37. package/scripts/sync-template-src.sh +36 -0
  38. package/scripts/verify-bytecode.mjs +49 -0
  39. package/src/App.vue +41 -0
  40. package/src/__tests__/App.spec.js +11 -0
  41. package/src/components/AppNotice.vue +15 -0
  42. package/src/components/ColorModeControl.vue +81 -0
  43. package/src/components/ConfigForm.vue +192 -0
  44. package/src/components/DeployProgress.vue +46 -0
  45. package/src/components/FieldHint.vue +79 -0
  46. package/src/components/FormField.vue +28 -0
  47. package/src/components/FormSelect.vue +43 -0
  48. package/src/components/GithubPush.vue +100 -0
  49. package/src/components/IconPicker.vue +410 -0
  50. package/src/components/LicensePicker.vue +182 -0
  51. package/src/components/ResultPanel.vue +88 -0
  52. package/src/components/ReviewPanel.vue +69 -0
  53. package/src/components/TermsDialog.vue +81 -0
  54. package/src/components/TokenDeployerView.vue +115 -0
  55. package/src/components/WalletBar.vue +108 -0
  56. package/src/components/WalrusBlobBrowser.vue +188 -0
  57. package/src/components/WalrusIconUpload.vue +205 -0
  58. package/src/composables/useAccessGate.ts +122 -0
  59. package/src/composables/useColorMode.ts +76 -0
  60. package/src/composables/useWalrusRelay.ts +21 -0
  61. package/src/config.ts +185 -0
  62. package/src/index.ts +1 -0
  63. package/src/lib/accessGate.ts +141 -0
  64. package/src/lib/buildPublishTx.ts +192 -0
  65. package/src/lib/deploy.ts +168 -0
  66. package/src/lib/deployExecutor.ts +61 -0
  67. package/src/lib/errors.ts +21 -0
  68. package/src/lib/form.ts +75 -0
  69. package/src/lib/generatePackage.ts +312 -0
  70. package/src/lib/github.ts +146 -0
  71. package/src/lib/licenses.ts +122 -0
  72. package/src/lib/readClient.ts +17 -0
  73. package/src/lib/template.ts +131 -0
  74. package/src/lib/types.ts +69 -0
  75. package/src/lib/validation.ts +172 -0
  76. package/src/lib/walrus-constants.ts +15 -0
  77. package/src/lib/walrus.ts +144 -0
  78. package/src/main.ts +154 -0
  79. package/src/move-template/sui_token_template.mv +0 -0
  80. package/src/move-template/template.ts +31 -0
  81. package/src/styles.css +384 -0
  82. package/src/template-src/files.json +10 -0
  83. package/test-results/.last-run.json +4 -0
  84. package/tests/accessGate.test.ts +108 -0
  85. package/tests/buildPublishTx.test.ts +243 -0
  86. package/tests/deploy.test.ts +81 -0
  87. package/tests/errors.test.ts +32 -0
  88. package/tests/generatePackage.test.ts +182 -0
  89. package/tests/github.test.ts +61 -0
  90. package/tests/licenses.test.ts +78 -0
  91. package/tests/template.test.ts +93 -0
  92. package/tests/templateArtifact.test.ts +61 -0
  93. package/tests/templateParity.test.ts +55 -0
  94. package/tests/useAccessGate.test.ts +101 -0
  95. package/tests/useWallet.test.ts +74 -0
  96. package/tests/useWalrusRelayGating.test.ts +30 -0
  97. package/tests/validation.test.ts +117 -0
  98. package/tests/walrusUpload.test.ts +93 -0
  99. package/tests/walrus_url_patch.test.ts +26 -0
  100. package/tsconfig.json +19 -0
  101. package/typedoc.json +34 -0
  102. package/vite.config.ts +65 -0
  103. package/vitest.config.ts +10 -0
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import type { TokenConfig, Network } from '../lib/types.js'
3
+ import { FEE_MIST } from '../config.js'
4
+ import AppNotice from './AppNotice.vue'
5
+
6
+ const props = defineProps<{ config: TokenConfig; network: Network }>()
7
+ defineEmits<{ (e: 'back'): void; (e: 'confirm'): void }>()
8
+
9
+ const feeSui = Number(FEE_MIST) / 1e9
10
+ </script>
11
+
12
+ <template>
13
+ <section class="card" aria-labelledby="review-h">
14
+ <h2 id="review-h">Review — deploying to {{ props.network }}</h2>
15
+
16
+ <AppNotice>
17
+ These values are <strong>permanent</strong> once published: symbol
18
+ <span class="mono">{{ config.symbol }}</span
19
+ >, name <span class="mono">{{ config.name }}</span
20
+ >, decimals <span class="mono">{{ config.decimals }}</span
21
+ >, and the coin type
22
+ <span class="mono">&lt;package&gt;::{{ config.moduleName }}::{{ config.structName }}</span
23
+ >.
24
+ </AppNotice>
25
+
26
+ <dl class="grid2" v-once>
27
+ <div>
28
+ <dt>Initial supply</dt>
29
+ <dd>{{ config.initialSupply.toString() }}</dd>
30
+ </div>
31
+ <div>
32
+ <dt>Supply policy</dt>
33
+ <dd>{{ config.supplyPolicy }}</dd>
34
+ </div>
35
+ <div>
36
+ <dt>Metadata</dt>
37
+ <dd>{{ config.metadataPolicy }}</dd>
38
+ </div>
39
+ <div>
40
+ <dt>Package code</dt>
41
+ <dd>{{ config.packagePolicy }}</dd>
42
+ </div>
43
+ <div>
44
+ <dt>License (source only)</dt>
45
+ <dd>{{ config.license }}</dd>
46
+ </div>
47
+ <div>
48
+ <dt>Recipient</dt>
49
+ <dd class="mono">{{ config.recipient || 'your wallet' }}</dd>
50
+ </div>
51
+ </dl>
52
+
53
+ <p v-once class="hint">
54
+ Your wallet signs and pays gas. A one-off fee of <strong>{{ feeSui }} SUI</strong> is included
55
+ in the publish transaction. Nothing is compiled or signed on a server.
56
+ </p>
57
+
58
+ <p v-once class="hint">
59
+ You are the sole author of this token and fully responsible for its details and use. This tool
60
+ does not review, verify, or endorse tokens or their creators, and takes no custody of your
61
+ token or caps.
62
+ </p>
63
+
64
+ <div class="row" style="margin-top: 1rem">
65
+ <button type="button" @click="$emit('back')">Back</button>
66
+ <button type="button" class="primary" @click="$emit('confirm')">Confirm &amp; deploy</button>
67
+ </div>
68
+ </section>
69
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+
4
+ const props = defineProps<{ open: boolean }>()
5
+ const emit = defineEmits<{
6
+ (e: 'accept'): void
7
+ (e: 'cancel'): void
8
+ }>()
9
+
10
+ const el = ref<HTMLDialogElement>()
11
+
12
+ watch(
13
+ () => props.open,
14
+ (v) => (v ? el.value?.showModal() : el.value?.close()),
15
+ )
16
+ </script>
17
+
18
+ <template>
19
+ <dialog ref="el" @click.self="$emit('cancel')" @cancel.prevent="$emit('cancel')">
20
+ <div class="dialog-header">
21
+ <h2>Before you deploy</h2>
22
+ </div>
23
+ <ul>
24
+ <li>
25
+ All operations — bytecode compilation, transaction construction, and signing — take place
26
+ entirely <strong>client-side</strong>, meaning on your computer. No code runs on our
27
+ servers, and no server has any visibility into or control over what happens on your machine.
28
+ </li>
29
+ <li>
30
+ This is a neutral, self-service tool. The operator does not review, approve, or endorse any
31
+ token deployed with it.
32
+ </li>
33
+ <li>
34
+ You are solely responsible for your token, its content, and its use — including compliance
35
+ with all applicable laws in your jurisdiction.
36
+ </li>
37
+ <li>
38
+ Please don't create tokens that misrepresent projects, impersonate other assets, or
39
+ facilitate fraud. We can't actually see what you're doing from here, but we thought we'd
40
+ mention it. Bad vibes.
41
+ </li>
42
+ <li>
43
+ On-chain transactions are irreversible. Review your configuration carefully before
44
+ confirming.
45
+ </li>
46
+ <li>
47
+ The operator accepts no liability for financial loss, legal consequences, or any harm
48
+ arising from your use of this tool.
49
+ </li>
50
+ </ul>
51
+ <p style="margin-bottom: 1.25rem; font-size: 0.9rem; color: var(--muted)">
52
+ By continuing, you confirm that you have read and accept these terms.
53
+ </p>
54
+ <div class="terms-actions">
55
+ <button type="button" class="primary" @click="$emit('accept')">I understand — continue</button>
56
+ <button type="button" @click="$emit('cancel')">Cancel</button>
57
+ </div>
58
+ </dialog>
59
+ </template>
60
+
61
+ <style scoped>
62
+ ul {
63
+ padding-left: 1.25rem;
64
+ margin: 0 0 1rem;
65
+ line-height: 1.6;
66
+ }
67
+
68
+ ul li {
69
+ margin-bottom: 0.6rem;
70
+ }
71
+
72
+ ul li:last-child {
73
+ margin-bottom: 0;
74
+ }
75
+
76
+ .terms-actions {
77
+ display: flex;
78
+ gap: 0.75rem;
79
+ justify-content: flex-end;
80
+ }
81
+ </style>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref } from 'vue'
3
+ import { useWallet, useNetwork, WalletGuard } from '@meddleware/wallet-adapter'
4
+ import ConfigForm from './ConfigForm.vue'
5
+ import ReviewPanel from './ReviewPanel.vue'
6
+ import DeployProgress from './DeployProgress.vue'
7
+ import ResultPanel from './ResultPanel.vue'
8
+ import TermsDialog from './TermsDialog.vue'
9
+ import AppNotice from './AppNotice.vue'
10
+ import { emptyForm, toTokenConfig } from '../lib/form.js'
11
+ import { validateForm, isValid } from '../lib/validation.js'
12
+ import { deployToken } from '../lib/deploy.js'
13
+ import type { DeployStep } from '../lib/deploy.js'
14
+ import { buildDeployExecutor } from '../lib/deployExecutor.js'
15
+ import { FEE_MIST, FEE_TREASURY, PUBLISH_GAS_BUDGET, isFeeConfigured } from '../config.js'
16
+ import { extractErrorMessage } from '../lib/errors.js'
17
+ import type { Network, PublishResult } from '../lib/types.js'
18
+
19
+ type Step = 'form' | 'review' | 'deploying' | 'done'
20
+
21
+ const { account } = useWallet()
22
+ const { network } = useNetwork()
23
+
24
+ const form = reactive(emptyForm())
25
+ const step = ref<Step>('form')
26
+ const deployStep = ref<DeployStep | null>(null)
27
+ const deployError = ref<string | null>(null)
28
+ const result = ref<PublishResult | null>(null)
29
+ const termsAccepted = ref(localStorage.getItem('sui-deployer:terms-accepted') === '1')
30
+ const showTerms = ref(false)
31
+
32
+ const errors = computed(() => validateForm(form))
33
+ const config = computed(() => toTokenConfig(form))
34
+ const connected = computed(() => Boolean(account.value))
35
+ const canProceed = computed(() => isValid(errors.value) && connected.value)
36
+
37
+ function goReview(): void {
38
+ if (!canProceed.value) return
39
+ if (!termsAccepted.value) { showTerms.value = true; return }
40
+ step.value = 'review'
41
+ }
42
+
43
+ function onTermsAccept(): void {
44
+ localStorage.setItem('sui-deployer:terms-accepted', '1')
45
+ termsAccepted.value = true
46
+ showTerms.value = false
47
+ step.value = 'review'
48
+ }
49
+
50
+ function onTermsCancel(): void {
51
+ showTerms.value = false
52
+ }
53
+
54
+ async function confirmDeploy(): Promise<void> {
55
+ const sender = account.value?.address
56
+ if (!sender) { deployError.value = 'Connect a wallet first.'; return }
57
+ deployError.value = null
58
+ step.value = 'deploying'
59
+ deployStep.value = null
60
+ try {
61
+ const net = network.value as Network
62
+ const feeConfigured = isFeeConfigured(net)
63
+ result.value = await deployToken({
64
+ config: config.value,
65
+ network: net,
66
+ sender,
67
+ feeMist: feeConfigured ? FEE_MIST : 0n,
68
+ feeTreasury: FEE_TREASURY[net],
69
+ gasBudget: PUBLISH_GAS_BUDGET,
70
+ executor: await buildDeployExecutor(net),
71
+ onStep: (s) => (deployStep.value = s),
72
+ })
73
+ step.value = 'done'
74
+ } catch (e) {
75
+ deployError.value = extractErrorMessage(e)
76
+ step.value = 'review'
77
+ }
78
+ }
79
+
80
+ function restart(): void {
81
+ result.value = null
82
+ deployError.value = null
83
+ step.value = 'form'
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <WalletGuard message="Connect a Sui wallet to deploy your token.">
89
+ <ConfigForm
90
+ v-if="step === 'form'"
91
+ :form="form"
92
+ :errors="errors"
93
+ :can-proceed="canProceed"
94
+ :network="network"
95
+ :connected="connected"
96
+ @submit="goReview"
97
+ />
98
+ <ReviewPanel
99
+ v-if="step === 'review'"
100
+ :config="config"
101
+ :network="network"
102
+ @back="step = 'form'"
103
+ @confirm="confirmDeploy"
104
+ />
105
+ <DeployProgress v-if="step === 'deploying'" :step="deployStep" />
106
+ <ResultPanel
107
+ v-if="step === 'done' && result"
108
+ :result="result"
109
+ :config="config"
110
+ @restart="restart"
111
+ />
112
+ <AppNotice v-if="deployError" type="error">{{ deployError }}</AppNotice>
113
+ <TermsDialog :open="showTerms" @accept="onTermsAccept" @cancel="onTermsCancel" />
114
+ </WalletGuard>
115
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useWallet, useNetwork } from '@meddleware/wallet-adapter'
4
+ import { SELECTABLE_NETWORKS } from '../config.js'
5
+ import type { Network } from '../lib/types.js'
6
+ import FormSelect from './FormSelect.vue'
7
+ import AppNotice from './AppNotice.vue'
8
+
9
+ const { wallets, currentWallet, account, connecting, error, connect, disconnect } = useWallet()
10
+ const { network, setNetwork } = useNetwork()
11
+
12
+ const dialogEl = ref<HTMLDialogElement>()
13
+
14
+ const shortAddress = computed(() => {
15
+ const a = account.value?.address
16
+ return a ? `${a.slice(0, 6)}…${a.slice(-4)}` : ''
17
+ })
18
+
19
+ const networkModel = computed({
20
+ get: () => network.value,
21
+ set: (v: string) => setNetwork(v as Network),
22
+ })
23
+
24
+ function openDialog(): void {
25
+ dialogEl.value?.showModal()
26
+ }
27
+
28
+ function closeDialog(): void {
29
+ dialogEl.value?.close()
30
+ }
31
+
32
+ watch(() => account.value, (acc) => {
33
+ if (acc) {
34
+ closeDialog()
35
+ }
36
+ })
37
+ </script>
38
+
39
+ <template>
40
+ <div class="card" role="region" aria-label="Wallet and network">
41
+ <template v-if="account">
42
+ <div class="row" style="justify-content: space-between">
43
+ <div class="row">
44
+ <label for="network-select" style="margin: 0">Network</label>
45
+ <FormSelect id="network-select" v-model="networkModel">
46
+ <option v-for="n in SELECTABLE_NETWORKS" :key="n" :value="n">{{ n }}</option>
47
+ </FormSelect>
48
+ </div>
49
+
50
+ <div class="row">
51
+ <span class="badge" aria-label="Connected wallet">
52
+ {{ currentWallet?.name }} · <span class="mono">{{ shortAddress }}</span>
53
+ </span>
54
+ <button type="button" @click="disconnect">Disconnect</button>
55
+ </div>
56
+ </div>
57
+ </template>
58
+ <template v-else>
59
+ <div class="row">
60
+ <div v-if="!wallets.length" style="width: 100%">
61
+ <p class="hint" style="margin: 0">
62
+ No Sui wallet detected. Install a Sui wallet extension to continue.
63
+ </p>
64
+ </div>
65
+ <button v-else type="button" class="primary" @click="openDialog">
66
+ Connect Wallet
67
+ </button>
68
+ </div>
69
+ </template>
70
+
71
+ <AppNotice v-if="error" type="error" style="margin-top: 0.75rem">{{ error }}</AppNotice>
72
+ </div>
73
+
74
+ <dialog ref="dialogEl" aria-labelledby="wallet-dialog-title" @click.self="closeDialog">
75
+ <div class="dialog-header">
76
+ <h2 id="wallet-dialog-title">Connect Wallet</h2>
77
+ <button
78
+ type="button"
79
+ aria-label="Close"
80
+ style="background: none; border: none; font-size: 1.5rem; padding: 0; cursor: pointer; color: var(--text)"
81
+ @click="closeDialog"
82
+ >
83
+ ×
84
+ </button>
85
+ </div>
86
+
87
+ <div>
88
+ <label for="dialog-network-select">Network</label>
89
+ <FormSelect id="dialog-network-select" v-model="networkModel">
90
+ <option v-for="n in SELECTABLE_NETWORKS" :key="n" :value="n">{{ n }}</option>
91
+ </FormSelect>
92
+ </div>
93
+
94
+ <div class="wallet-list">
95
+ <button
96
+ v-for="w in wallets"
97
+ :key="w.name"
98
+ type="button"
99
+ class="primary"
100
+ :disabled="connecting"
101
+ @click="connect(w)"
102
+ >
103
+ <img v-if="w.icon" :src="w.icon" alt="" width="20" height="20" />
104
+ {{ w.name }}
105
+ </button>
106
+ </div>
107
+ </dialog>
108
+ </template>
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import { useWallet } from '@meddleware/wallet-adapter'
4
+ import { getReadClient } from '../lib/readClient.js'
5
+ import type { OwnedBlob, WalrusNetwork } from '../lib/walrus.js'
6
+ import type { Network } from '../lib/types.js'
7
+ import AppNotice from './AppNotice.vue'
8
+
9
+ const props = defineProps<{ network: WalrusNetwork; connected: boolean }>()
10
+ const emit = defineEmits<{ (e: 'select', url: string): void }>()
11
+
12
+ const { account } = useWallet()
13
+
14
+ const addressInput = ref(account.value?.address ?? '')
15
+ watch(
16
+ () => account.value?.address,
17
+ (addr) => {
18
+ if (addr && !addressInput.value) addressInput.value = addr
19
+ },
20
+ )
21
+
22
+ interface BlobItem extends OwnedBlob {
23
+ url: string
24
+ }
25
+
26
+ const blobs = ref<BlobItem[]>([])
27
+ const loading = ref(false)
28
+ const loadError = ref<string | null>(null)
29
+ const selected = ref('')
30
+ const hasLoaded = ref(false)
31
+
32
+ function formatSize(bytes: number): string {
33
+ if (bytes < 1024) return `${bytes} B`
34
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
35
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
36
+ }
37
+
38
+ async function load(): Promise<void> {
39
+ const addr = addressInput.value.trim() || account.value?.address
40
+ if (!addr) {
41
+ loadError.value = 'Enter an address or connect your wallet.'
42
+ return
43
+ }
44
+ loading.value = true
45
+ loadError.value = null
46
+ blobs.value = []
47
+ hasLoaded.value = false
48
+ try {
49
+ const { createWalrusClient, walrusBlobUrl, fetchOwnedWalrusBlobs } = await import(
50
+ '../lib/walrus.js'
51
+ )
52
+ const suiClient = getReadClient(props.network as Network)
53
+ const client = createWalrusClient(props.network)
54
+ const raw = await fetchOwnedWalrusBlobs(suiClient, client, addr)
55
+ blobs.value = raw.map((b) => ({ ...b, url: walrusBlobUrl(props.network, b.blobId) }))
56
+ hasLoaded.value = true
57
+ } catch (e) {
58
+ loadError.value = e instanceof Error ? e.message : String(e)
59
+ } finally {
60
+ loading.value = false
61
+ }
62
+ }
63
+
64
+ function selectBlob(item: BlobItem): void {
65
+ selected.value = item.blobId
66
+ emit('select', item.url)
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <div class="blob-browser">
72
+ <div class="row">
73
+ <input
74
+ v-model="addressInput"
75
+ type="text"
76
+ placeholder="0x… or leave blank for your wallet"
77
+ aria-label="Wallet address to browse"
78
+ style="flex: 1"
79
+ />
80
+ <button type="button" :disabled="loading || (!connected && !addressInput.trim())" @click="load">
81
+ <span v-if="loading" class="spinner" aria-hidden="true"></span>
82
+ Load
83
+ </button>
84
+ </div>
85
+ <p class="hint">Browse Walrus blobs owned by this address. Only image blobs show a preview.</p>
86
+
87
+ <AppNotice v-if="loadError" type="error">{{ loadError }}</AppNotice>
88
+
89
+ <p v-if="hasLoaded && blobs.length === 0 && !loading" class="hint">
90
+ No Walrus blobs found at this address.
91
+ </p>
92
+
93
+ <div v-if="blobs.length > 0" class="blob-grid" role="listbox" aria-label="Walrus blobs">
94
+ <button
95
+ v-for="b in blobs"
96
+ :key="b.objectId"
97
+ type="button"
98
+ class="blob-thumb"
99
+ :class="{ selected: selected === b.blobId }"
100
+ role="option"
101
+ :aria-selected="selected === b.blobId"
102
+ @click="selectBlob(b)"
103
+ >
104
+ <img
105
+ :src="b.url"
106
+ :alt="`Blob ${b.blobId.slice(0, 8)}…`"
107
+ loading="lazy"
108
+ @error="(e) => (e.target as HTMLElement).parentElement?.classList.add('no-img')"
109
+ />
110
+ <span class="blob-meta">{{ formatSize(b.size) }}</span>
111
+ </button>
112
+ </div>
113
+ </div>
114
+ </template>
115
+
116
+ <style scoped>
117
+ .blob-browser {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 0.5rem;
121
+ }
122
+
123
+ .blob-grid {
124
+ display: grid;
125
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
126
+ gap: 0.5rem;
127
+ margin-top: 0.25rem;
128
+ }
129
+
130
+ .blob-thumb {
131
+ display: flex;
132
+ flex-direction: column;
133
+ align-items: center;
134
+ gap: 0.25rem;
135
+ padding: 0.25rem;
136
+ border: 2px solid var(--border);
137
+ border-radius: 6px;
138
+ background: transparent;
139
+ cursor: pointer;
140
+ overflow: hidden;
141
+ transition: border-color 0.15s;
142
+ }
143
+
144
+ .blob-thumb:hover {
145
+ border-color: var(--accent);
146
+ }
147
+
148
+ .blob-thumb.selected {
149
+ border-color: var(--accent);
150
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
151
+ }
152
+
153
+ .blob-thumb img {
154
+ width: 100%;
155
+ aspect-ratio: 1;
156
+ object-fit: cover;
157
+ border-radius: 3px;
158
+ display: block;
159
+ }
160
+
161
+ .blob-thumb.no-img img {
162
+ height: 0;
163
+ width: 0;
164
+ visibility: hidden;
165
+ }
166
+
167
+ .blob-thumb.no-img::before {
168
+ content: '?';
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ width: 100%;
173
+ aspect-ratio: 1;
174
+ font-size: 1.25rem;
175
+ color: var(--muted);
176
+ background: var(--surface);
177
+ border-radius: 3px;
178
+ }
179
+
180
+ .blob-meta {
181
+ font-size: 0.65rem;
182
+ color: var(--muted);
183
+ white-space: nowrap;
184
+ overflow: hidden;
185
+ text-overflow: ellipsis;
186
+ max-width: 100%;
187
+ }
188
+ </style>