@meddleware/walrus-storage-ui 0.1.65 → 0.1.67

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to `@meddleware/walrus-ui` are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [Semantic Versioning](https://semver.org/). Before 0.2.0, breaking changes ship as patch releases.
6
6
 
7
+ ## [0.1.67] - 2026-11-05
8
+
9
+ ### Security
10
+
11
+ - CI and the image build scan the production bundle for the e2e harness (`check-prod-bundle` from `@meddleware/frontend-config`), CI proves a production build with `VITE_E2E=1` is refused, and `src/e2e*.ts` is no longer in the published package.
12
+
13
+ ### Changed
14
+
15
+ - Dependency alignment: `@meddleware/ui` ^0.1.38 (form-field edge contrast, sticky-offset and focus-not-obscured fixes), `@meddleware/design-tokens` ^0.1.13, `@meddleware/web-utils` ^0.0.2 and `@meddleware/frontend-config` ^0.0.2 (the shared sweep now also checks that focus is not hidden behind fixed content).
16
+
17
+ ## [0.1.66] - 2026-10-31
18
+
19
+ - Gated upload composable, blob table split, accessibility gates
20
+
7
21
  ## [0.1.65] - 2026-10-30
8
22
 
9
23
  - Dependency updates (minor and patch groups) and workflow action bumps. No source change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-storage-ui",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "homepage": "https://docs.meddleware.co.uk/blockchain/sui/walrus-storage/",
5
5
  "description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
6
6
  "author": "Meddleware <dev@meddleware.co.uk>",
@@ -26,11 +26,13 @@
26
26
  "index.html",
27
27
  "vite.config.ts",
28
28
  "tsconfig.json",
29
- "CHANGELOG.md"
29
+ "CHANGELOG.md",
30
+ "!src/e2e*.ts"
30
31
  ],
31
32
  "scripts": {
32
33
  "dev": "vite",
33
34
  "build": "vue-tsc --noEmit && vite build",
35
+ "build:e2e": "VITE_E2E=1 vite build --mode e2e",
34
36
  "preview": "vite preview",
35
37
  "type-check": "vue-tsc --noEmit",
36
38
  "test": "vitest run",
@@ -41,16 +43,20 @@
41
43
  "lint": "npm run lint:css && npm run lint:js && npm run lint:html",
42
44
  "lint:html": "html-validate \"src/**/*.vue\"",
43
45
  "licenses": "node scripts/third-party-licenses.mjs",
44
- "check:licenses": "node scripts/third-party-licenses.mjs --check"
46
+ "check:licenses": "node scripts/third-party-licenses.mjs --check",
47
+ "shots": "SHOTS=1 playwright test",
48
+ "lhci": "npm run build && lhci autorun",
49
+ "check:bundle": "check-prod-bundle dist"
45
50
  },
46
51
  "publishConfig": {
47
52
  "access": "public"
48
53
  },
49
54
  "dependencies": {
50
- "@meddleware/design-tokens": "^0.1.11",
51
- "@meddleware/ui": "^0.1.35",
52
- "@meddleware/walrus-storage-client": "^0.0.33",
53
- "@meddleware/walrus-storage-kit": "^0.1.35",
55
+ "@meddleware/design-tokens": "^0.1.13",
56
+ "@meddleware/ui": "^0.1.38",
57
+ "@meddleware/walrus-storage-client": "^0.0.34",
58
+ "@meddleware/walrus-storage-kit": "^0.1.37",
59
+ "@meddleware/web-utils": "^0.0.2",
54
60
  "@mysten/sui": "^2.33.2",
55
61
  "@mysten/walrus": "~1.2.32",
56
62
  "@mysten/walrus-wasm": "~0.3.1",
@@ -60,13 +66,15 @@
60
66
  "@meddleware/wallet-adapter": ">=0.0.12 <0.2.0"
61
67
  },
62
68
  "devDependencies": {
69
+ "@lhci/cli": "^0.15.1",
63
70
  "@meddleware/access-gate-client": "^0.0.13",
64
- "@meddleware/eslint-config": "^0.0.4",
65
- "@meddleware/wallet-adapter": "^0.0.18",
71
+ "@meddleware/frontend-config": "^0.0.2",
72
+ "@meddleware/wallet-adapter": "^0.0.21",
66
73
  "@playwright/test": "~1.64.1",
67
74
  "@types/node": "~26.6.5",
68
75
  "@vitejs/plugin-vue": "^6.0.8",
69
76
  "@vue/eslint-config-typescript": "^14.9.0",
77
+ "@vue/test-utils": "^2.5.1",
70
78
  "autoprefixer": "^10.4.20",
71
79
  "eslint": "^10.11.0",
72
80
  "eslint-plugin-vue": "^10.11.1",
@@ -74,6 +82,7 @@
74
82
  "html-validate": "^11.16.1",
75
83
  "html-validate-vue": "^9.3.1",
76
84
  "jiti": "^2.7.0",
85
+ "jsdom": "^30.1.0",
77
86
  "postcss": "^8.4.49",
78
87
  "postcss-html": "^2.0.0",
79
88
  "stylelint": "^17.15.0",
@@ -88,8 +97,11 @@
88
97
  "node": "^22.18.0 || >=24.12.0"
89
98
  },
90
99
  "browserslist": [
91
- "> 0.5%",
92
- "last 2 versions",
93
- "not dead"
94
- ]
100
+ "extends @meddleware/frontend-config/browserslist-config"
101
+ ],
102
+ "overrides": {
103
+ "basic-ftp": "^6.2.4",
104
+ "tmp": "^0.2.7",
105
+ "zod": "^4.6.7"
106
+ }
95
107
  }
package/src/App.vue CHANGED
@@ -1,56 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  // Standalone shell for the Walrus SPA: app header + footer wrapping the core tool view.
3
3
  // The core UI lives in WalrusStorageView.vue (also exported for inline embedding in the dashboard).
4
- import { AppHeader, AppFooter, ColorModeControl, useColorMode } from '@meddleware/ui'
4
+ import { AppFooter, AppHeader, ColorModeControl, CopyrightLine, UiAppShell, useColorMode } from '@meddleware/ui'
5
5
  import { TipConfigBadge } from '@meddleware/walrus-storage-kit'
6
6
  import { walrusNetwork, OPERATOR_RELAY_HOSTS } from './config.js'
7
7
  import WalrusStorageView from './components/WalrusStorageView.vue'
8
8
 
9
9
  const { mode, set } = useColorMode('dark')
10
10
  const DOCS_URL = import.meta.env.VITE_DOCS_URL || 'https://docs.meddleware.co.uk/blockchain/sui/walrus-storage/'
11
- const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://meddleware.dev/sui/walrus-storage/'
11
+ const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://meddleware.dev/sui/walrus-storage/'
12
12
  </script>
13
13
 
14
14
  <template>
15
- <div class="app">
16
- <AppHeader variant="dark">
17
- <template #brand>
18
- <h1 class="brand-title">Walrus Assets</h1>
19
- </template>
20
- <template #actions>
21
- <TipConfigBadge v-if="walrusNetwork" :host="OPERATOR_RELAY_HOSTS[walrusNetwork]" />
22
- <ColorModeControl :model-value="mode" @update:model-value="set" />
23
- </template>
24
- </AppHeader>
15
+ <UiAppShell contained>
16
+ <template #header>
17
+ <AppHeader variant="dark">
18
+ <template #brand>Walrus Assets</template>
19
+ <template #actions>
20
+ <TipConfigBadge v-if="walrusNetwork" :host="OPERATOR_RELAY_HOSTS[walrusNetwork]" />
21
+ <ColorModeControl :model-value="mode" @update:model-value="set" />
22
+ </template>
23
+ </AppHeader>
24
+ </template>
25
25
 
26
- <main class="app__content">
27
- <WalrusStorageView />
28
- </main>
26
+ <h1 class="mw-visually-hidden">Walrus Assets</h1>
27
+ <WalrusStorageView />
29
28
 
30
- <AppFooter :docs-url="DOCS_URL" :dev-url="DEV_URL" />
31
- </div>
29
+ <template #footer>
30
+ <AppFooter :docs-url="DOCS_URL" :dev-url="DEV_URL">
31
+ <CopyrightLine symbol-variant="kopimi" organisation-name="Meddleware" rights-statement="jam" />
32
+ </AppFooter>
33
+ </template>
34
+ </UiAppShell>
32
35
  </template>
33
-
34
- <style scoped>
35
- .app {
36
- min-height: 100vh;
37
- display: flex;
38
- flex-direction: column;
39
- }
40
-
41
- /* Centre the tool at the shared tool-content width when running standalone. The dashboard
42
- supplies its own width container, so this lives in the shell, not WalrusStorageView. */
43
- .app__content {
44
- flex: 1;
45
- width: 100%;
46
- max-width: var(--mw-tool-content-max);
47
- margin: 0 auto;
48
- box-sizing: border-box;
49
- padding: 1.5rem 1.25rem 4rem;
50
- }
51
-
52
- .brand-title {
53
- font: inherit;
54
- margin: 0;
55
- }
56
- </style>
@@ -0,0 +1,133 @@
1
+ <script setup lang="ts">
2
+ // One blob's rows in the My Blobs table: the group row (re-uploads of the same content are collapsed into it), and, when expanded, one
3
+ // row per underlying copy so a specific pending copy can be certified. Presentational: the parent owns the data and runs the actions.
4
+ import { computed } from 'vue'
5
+ import { CopyableAddress, ExplorerLink, UiBadge, UiButton, UiLiveStatus } from '@meddleware/ui'
6
+ import { formatBytes, truncateMiddle, walruscanBlobUrl } from '@meddleware/web-utils'
7
+ import type { WalrusNetwork } from '@meddleware/walrus-storage-kit'
8
+ import type { OwnedBlob } from '@meddleware/walrus-storage-client'
9
+ import type { PendingCertify } from '@meddleware/walrus-storage-client/flow'
10
+ import { expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
11
+ import type { EstimateExtendCost } from '../composables/useExtendQuotes.js'
12
+ import ExtendControl from './ExtendControl.vue'
13
+
14
+ /** Expiring within this many epochs is flagged. */
15
+ const SOON_EPOCHS = 10
16
+
17
+ const props = defineProps<{
18
+ group: BlobGroup
19
+ network: WalrusNetwork
20
+ currentEpoch: number
21
+ /** The longest reservation the chain allows (epochs ahead). */
22
+ maxReservation: number
23
+ /** Routed here from the duplicate-upload dialog. */
24
+ highlighted: boolean
25
+ expanded: boolean
26
+ /** Stored certificates of uploaded-but-uncertified copies, by Blob object id. */
27
+ pending: Record<string, PendingCertify>
28
+ /** The Blob object id whose action is running, or null. */
29
+ busy: string | null
30
+ /** The last action's outcome, by Blob object id. */
31
+ statuses: Record<string, string>
32
+ estimate: EstimateExtendCost
33
+ }>()
34
+ const emit = defineEmits<{ toggle: []; extend: [blob: OwnedBlob, epochs: number]; certify: [blob: OwnedBlob] }>()
35
+
36
+ const representative = computed(() => props.group.representative)
37
+ const certifiable = computed(() => props.group.copies.find((c) => !c.certified && props.pending[c.objectId]) ?? null)
38
+ const certification = computed<'certified' | 'pending' | 'none'>(() => (props.group.anyCertified ? 'certified' : certifiable.value ? 'pending' : 'none'))
39
+ const maxAddable = computed(() => maxExtendableEpochs(representative.value.endEpoch, props.currentEpoch, props.maxReservation))
40
+ const expiresSoon = computed(() => props.group.maxEndEpoch - props.currentEpoch < SOON_EPOCHS)
41
+ const shownCopies = computed(() => (props.expanded ? props.group.copies : []))
42
+ </script>
43
+
44
+ <template>
45
+ <tr :id="`blob-row-${group.blobId}`" :class="{ highlight: highlighted }" :aria-current="highlighted || undefined">
46
+ <th scope="row">
47
+ <CopyableAddress :address="group.blobId" label="Copy blob ID">
48
+ <ExplorerLink :href="walruscanBlobUrl(network, group.blobId)" :value="group.blobId" :chars="[8, 6]" />
49
+ </CopyableAddress>
50
+ <UiButton v-if="group.copies.length > 1" class="toggle" variant="link" size="sm" :aria-expanded="expanded" @click="emit('toggle')">
51
+ <span aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span> {{ group.copies.length }} copies
52
+ </UiButton>
53
+ </th>
54
+ <td class="size">{{ formatBytes(group.size) }}</td>
55
+ <td>
56
+ {{ expiryLabel(group.maxEndEpoch, currentEpoch) }}
57
+ <UiBadge v-if="expiresSoon" tone="warn">Expiring soon</UiBadge>
58
+ </td>
59
+ <td>
60
+ <UiBadge v-if="certification === 'certified'" tone="ok">Certified</UiBadge>
61
+ <UiBadge v-else-if="certification === 'pending'" tone="warn">Uploaded, not certified</UiBadge>
62
+ <UiBadge v-else tone="neutral" :symbol="false">Not certified</UiBadge>
63
+ </td>
64
+ <td class="actions">
65
+ <UiButton v-if="certifiable" size="sm" variant="secondary" :disabled="busy !== null" :busy="busy === certifiable.objectId" @click="emit('certify', certifiable)">
66
+ Certify
67
+ </UiButton>
68
+ <p v-if="maxAddable === 0" class="at-max">At max lifetime</p>
69
+ <ExtendControl
70
+ v-else
71
+ :blob="representative"
72
+ :max="maxAddable"
73
+ :disabled="busy !== null"
74
+ :busy="busy === representative.objectId"
75
+ :estimate="estimate"
76
+ @extend="(epochs) => emit('extend', representative, epochs)"
77
+ />
78
+ <UiLiveStatus label="Result" :message="statuses[representative.objectId] ?? ''" />
79
+ </td>
80
+ </tr>
81
+
82
+ <!-- Per-copy detail for grouped duplicates: certify a specific pending copy. -->
83
+ <tr v-for="c in shownCopies" :key="c.objectId" class="copy-row">
84
+ <th scope="row">
85
+ <CopyableAddress :address="c.objectId" label="Copy object ID">{{ truncateMiddle(c.objectId, [10, 0]) }}</CopyableAddress>
86
+ </th>
87
+ <td></td>
88
+ <td>{{ expiryLabel(c.endEpoch, currentEpoch) }}</td>
89
+ <td>
90
+ <UiBadge v-if="c.certified" tone="ok">Certified</UiBadge>
91
+ <UiBadge v-else-if="pending[c.objectId]" tone="warn">Uploaded, not certified</UiBadge>
92
+ <UiBadge v-else tone="neutral" :symbol="false">Not certified</UiBadge>
93
+ </td>
94
+ <td class="actions">
95
+ <UiButton v-if="!c.certified && pending[c.objectId]" size="sm" variant="secondary" :disabled="busy !== null" :busy="busy === c.objectId" @click="emit('certify', c)">
96
+ Certify
97
+ </UiButton>
98
+ <UiLiveStatus label="Result" :message="statuses[c.objectId] ?? ''" />
99
+ </td>
100
+ </tr>
101
+ </template>
102
+
103
+ <style scoped>
104
+ /* A truncated id and a size stay on one line each; the table scrolls inside its own box when the screen is narrower. */
105
+ th {
106
+ white-space: nowrap;
107
+ }
108
+ .size {
109
+ white-space: nowrap;
110
+ }
111
+ .highlight > * {
112
+ background: color-mix(in srgb, var(--accent) 14%, var(--surface));
113
+ }
114
+ .copy-row > * {
115
+ background: var(--lift);
116
+ font-size: var(--font-size-xs);
117
+ }
118
+ .actions {
119
+ min-width: 14em;
120
+ }
121
+ /* The toggle sits on its own line under the blob link (the cell does not wrap, so left inline it would spill into the next column), and
122
+ * the two targets stay 24px apart (WCAG 2.5.8). */
123
+ .toggle {
124
+ display: flex;
125
+ inline-size: fit-content;
126
+ margin-block-start: var(--space-2xs);
127
+ }
128
+ .at-max {
129
+ margin: 0;
130
+ color: var(--muted);
131
+ font-size: var(--font-size-sm);
132
+ }
133
+ </style>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ // Extend one blob's storage: choose how many epochs to add (clamped to what the chain still allows), see what that costs in WAL, then
3
+ // extend. The wallet opens only for an amount whose cost has been shown (audit F12): the first press prices the amount, the second
4
+ // confirms. The quote is an estimate (≈); the wallet and the chain decide what is actually paid.
5
+ import { computed, ref, watch } from 'vue'
6
+ import { UiButton, UiInput, UiLiveStatus } from '@meddleware/ui'
7
+ import { truncateMiddle } from '@meddleware/web-utils'
8
+ import { formatCoinAmount } from '@meddleware/walrus-storage-kit'
9
+ import type { OwnedBlob } from '@meddleware/walrus-storage-client'
10
+ import { useExtendQuotes, type EstimateExtendCost } from '../composables/useExtendQuotes.js'
11
+
12
+ const props = defineProps<{
13
+ /** The blob (the group's furthest-expiry copy) to extend. */
14
+ blob: OwnedBlob
15
+ /** Epochs that can still be added; the control is not shown at 0. */
16
+ max: number
17
+ /** Another action is running. */
18
+ disabled: boolean
19
+ /** This blob's extension is running. */
20
+ busy: boolean
21
+ /** Prices storing `size` bytes for `epochs` (WAL, FROST). */
22
+ estimate: EstimateExtendCost
23
+ }>()
24
+ const emit = defineEmits<{ extend: [epochs: number] }>()
25
+
26
+ const quotes = useExtendQuotes(props.estimate)
27
+ // Seeded with a sensible default (+10, clamped) without pricing it: the estimate is fetched on the first interaction, not for every row.
28
+ const amount = ref(Math.min(10, props.max))
29
+
30
+ function clamp(value: number | string): number {
31
+ return Math.min(props.max, Math.max(1, Math.floor(Number(value) || 1)))
32
+ }
33
+
34
+ /** The person picked an amount: clamp it and (re)price it. */
35
+ function pick(value: number | string): void {
36
+ amount.value = clamp(value)
37
+ void quotes.price(props.blob.objectId, props.blob.size, amount.value)
38
+ }
39
+
40
+ const quote = computed(() => quotes.quoteFor(props.blob.objectId, amount.value))
41
+ const quoteText = computed(() => {
42
+ const q = quote.value
43
+ if (q?.state === 'ready') return `≈${formatCoinAmount(q.costFrost, 'WAL')} for +${q.epochs} epochs`
44
+ if (q?.state === 'unavailable') return 'price unavailable'
45
+ return quotes.pricing.value[props.blob.objectId] === amount.value ? 'pricing…' : ''
46
+ })
47
+
48
+ async function extend(): Promise<void> {
49
+ if (props.disabled || props.busy) return
50
+ if (await quotes.shownBeforeSigning(props.blob.objectId, props.blob.size, amount.value)) emit('extend', amount.value)
51
+ }
52
+
53
+ // An extension changes the blob's end epoch (and what it may still add): the old quote no longer applies.
54
+ watch(
55
+ () => props.blob.endEpoch,
56
+ () => {
57
+ quotes.clear(props.blob.objectId)
58
+ amount.value = clamp(amount.value)
59
+ },
60
+ )
61
+ watch(
62
+ () => props.max,
63
+ (max) => {
64
+ if (amount.value > max) amount.value = Math.max(1, max)
65
+ },
66
+ )
67
+ </script>
68
+
69
+ <template>
70
+ <fieldset class="extend">
71
+ <legend class="mw-visually-hidden">Extend {{ truncateMiddle(blob.blobId, [8, 6]) }}</legend>
72
+ <UiInput
73
+ type="number"
74
+ min="1"
75
+ :max="max"
76
+ :model-value="amount"
77
+ :aria-label="`Epochs to add to ${truncateMiddle(blob.blobId, [8, 6])}`"
78
+ @update:model-value="pick($event ?? 1)"
79
+ />
80
+ <UiButton variant="ghost" size="sm" @click="pick(10)">+10</UiButton>
81
+ <UiButton variant="ghost" size="sm" @click="pick(25)">+25</UiButton>
82
+ <UiButton variant="ghost" size="sm" @click="pick(max)">Max</UiButton>
83
+ <UiButton size="sm" :disabled="disabled" :busy="busy" @click="extend">{{ quote ? 'Extend' : 'Show cost' }}</UiButton>
84
+ <UiLiveStatus label="Extension cost" :message="quoteText" />
85
+ </fieldset>
86
+ </template>
87
+
88
+ <style scoped>
89
+ .extend {
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ align-items: center;
93
+ gap: var(--space-3xs);
94
+ min-width: 0;
95
+ margin: 0;
96
+ padding: 0;
97
+ border: 0;
98
+ }
99
+ .extend :deep(.mw-input) {
100
+ width: 5em;
101
+ }
102
+ </style>