From 90c1423b40f67c90af2eca80b5fcc5e8cfd5ea48 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 05:10:35 +0000
Subject: [PATCH 01/21] Initial plan
From f1df3ce6dc6764673aa7f025abd9170ecafb4cad Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 05:13:44 +0000
Subject: [PATCH 02/21] Install web dependencies: pinia, vueuse, tailwindcss,
lucide-vue-next, date-fns
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/package.json | 10 +-
pnpm-lock.yaml | 633 ++++++++++++++++++++++++++++++++++++++----
2 files changed, 582 insertions(+), 61 deletions(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 9bfdbc0..975ce8c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,13 +11,19 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
- "vue": "^3.5.24",
- "@uts/sdk": "workspace:*"
+ "@uts/sdk": "workspace:*",
+ "@vueuse/core": "^14.2.1",
+ "date-fns": "^4.1.0",
+ "lucide-vue-next": "^0.575.0",
+ "pinia": "^3.0.4",
+ "vue": "^3.5.24"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
+ "tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c86bffd..80da8bf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,19 +10,19 @@ importers:
devDependencies:
eslint:
specifier: ^9.38.0
- version: 9.39.3
+ version: 9.39.3(jiti@2.6.1)
eslint-import-resolver-typescript:
specifier: ^4.4.4
- version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3)
+ version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-import-x:
specifier: ^4.16.1
- version: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)
+ version: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-unicorn:
specifier: ^63.0.0
- version: 63.0.0(eslint@9.39.3)
+ version: 63.0.0(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-unused-imports:
specifier: ^4.4.1
- version: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)
+ version: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))
pnpm:
specifier: ^10.26.2
version: 10.26.2
@@ -31,32 +31,50 @@ importers:
version: 3.8.1
typescript-eslint:
specifier: ^8.56.1
- version: 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
apps/web:
dependencies:
'@uts/sdk':
specifier: workspace:*
version: link:../../packages/sdk
+ '@vueuse/core':
+ specifier: ^14.2.1
+ version: 14.2.1(vue@3.5.26(typescript@5.9.3))
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
+ lucide-vue-next:
+ specifier: ^0.575.0
+ version: 0.575.0(vue@3.5.26(typescript@5.9.3))
+ pinia:
+ specifier: ^3.0.4
+ version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
vue:
specifier: ^3.5.24
version: 3.5.26(typescript@5.9.3)
devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.2.1
+ version: 4.2.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))
'@types/node':
specifier: ^24.10.1
version: 24.10.4
'@vitejs/plugin-vue':
specifier: ^6.0.1
- version: 6.0.3(vite@7.3.0(@types/node@24.10.4))(vue@3.5.26(typescript@5.9.3))
+ version: 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vue@3.5.26(typescript@5.9.3))
'@vue/tsconfig':
specifier: ^0.8.1
version: 0.8.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
+ tailwindcss:
+ specifier: ^4.2.1
+ version: 4.2.1
typescript:
specifier: ~5.9.3
version: 5.9.3
vite:
specifier: ^7.2.4
- version: 7.3.0(@types/node@24.10.4)
+ version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)
vue-tsc:
specifier: ^3.1.4
version: 3.2.1(typescript@5.9.3)
@@ -87,7 +105,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
- version: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)
+ version: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)
packages:
@@ -330,9 +348,22 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -646,6 +677,100 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@tailwindcss/node@4.2.1':
+ resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.1':
+ resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.2.1':
+ resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -670,6 +795,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+ '@types/web-bluetooth@0.0.21':
+ resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
+
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -900,6 +1028,15 @@ packages:
'@vue/compiler-ssr@3.5.26':
resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==}
+ '@vue/devtools-api@7.7.9':
+ resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
+
+ '@vue/devtools-kit@7.7.9':
+ resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
+
+ '@vue/devtools-shared@7.7.9':
+ resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
+
'@vue/language-core@3.2.1':
resolution: {integrity: sha512-g6oSenpnGMtpxHGAwKuu7HJJkNZpemK/zg3vZzZbJ6cnnXq1ssxuNrXSsAHYM3NvH8p4IkTw+NLmuxyeYz4r8A==}
@@ -931,6 +1068,19 @@ packages:
vue:
optional: true
+ '@vueuse/core@14.2.1':
+ resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ '@vueuse/metadata@14.2.1':
+ resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
+
+ '@vueuse/shared@14.2.1':
+ resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
+ peerDependencies:
+ vue: ^3.5.0
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -973,6 +1123,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ birpc@2.9.0:
+ resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -1029,6 +1182,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ copy-anything@4.0.5:
+ resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
+ engines: {node: '>=18'}
+
core-js-compat@3.48.0:
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
@@ -1039,6 +1196,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ date-fns@4.1.0:
+ resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1055,9 +1215,17 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
electron-to-chromium@1.5.302:
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
+ enhanced-resolve@5.19.0:
+ resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
+ engines: {node: '>=10.13.0'}
+
entities@7.0.0:
resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==}
engines: {node: '>=0.12'}
@@ -1261,6 +1429,9 @@ packages:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'}
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1269,6 +1440,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hookable@5.5.3:
+ resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1311,9 +1485,17 @@ packages:
is-module@1.0.0:
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+ is-what@5.5.0:
+ resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
+ engines: {node: '>=18'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -1339,6 +1521,80 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lightningcss-android-arm64@1.31.1:
+ resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.31.1:
+ resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.31.1:
+ resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.31.1:
+ resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.31.1:
+ resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.31.1:
+ resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
+ engines: {node: '>= 12.0.0'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1350,6 +1606,11 @@ packages:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
+ lucide-vue-next@0.575.0:
+ resolution: {integrity: sha512-UHzA3cYMCgBLyGay5R9IQaidwV0NLocx7cIBnFt8vJ9Xhl6IM/oKD0fUhoCUuouFta15SX1rLXVoko9s3TzWMA==}
+ peerDependencies:
+ vue: '>=3.0.1'
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1364,6 +1625,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -1433,6 +1697,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1440,6 +1707,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ pinia@3.0.4:
+ resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
+ peerDependencies:
+ typescript: '>=4.5.0'
+ vue: ^3.5.11
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
pixelmatch@7.1.0:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
@@ -1504,6 +1780,9 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
+ rfdc@1.4.1:
+ resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
rimraf@6.1.3:
resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
engines: {node: 20 || >=22}
@@ -1543,6 +1822,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ speakingurl@14.0.1:
+ resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
+ engines: {node: '>=0.10.0'}
+
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -1561,6 +1844,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ superjson@2.2.6:
+ resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
+ engines: {node: '>=16'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1569,6 +1856,13 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tailwindcss@4.2.1:
+ resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1875,9 +2169,9 @@ snapshots:
'@esbuild/win32-x64@0.27.2':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3)':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))':
dependencies:
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -1932,8 +2226,25 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -2124,6 +2435,74 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@tailwindcss/node@4.2.1':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.19.0
+ jiti: 2.6.1
+ lightningcss: 1.31.1
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.1
+
+ '@tailwindcss/oxide-android-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.1':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-arm64': 4.2.1
+ '@tailwindcss/oxide-darwin-x64': 4.2.1
+ '@tailwindcss/oxide-freebsd-x64': 4.2.1
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.1
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.1
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
+
+ '@tailwindcss/vite@4.2.1(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))':
+ dependencies:
+ '@tailwindcss/node': 4.2.1
+ '@tailwindcss/oxide': 4.2.1
+ tailwindcss: 4.2.1
+ vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.7.0
@@ -2150,15 +2529,17 @@ snapshots:
'@types/resolve@1.20.2': {}
- '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)':
+ '@types/web-bluetooth@0.0.21': {}
+
+ '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.56.1
- '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -2166,14 +2547,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -2196,13 +2577,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -2225,13 +2606,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -2300,19 +2681,19 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
- '@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4))(vue@3.5.26(typescript@5.9.3))':
+ '@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
- vite: 7.3.0(@types/node@24.10.4)
+ vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)
vue: 3.5.26(typescript@5.9.3)
- '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.0(@types/node@24.10.4))(vitest@4.0.18)':
+ '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)':
dependencies:
- '@vitest/browser': 4.0.18(vite@7.3.0(@types/node@24.10.4))(vitest@4.0.18)
- '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4))
+ '@vitest/browser': 4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)
+ '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))
playwright: 1.58.2
tinyrainbow: 3.0.3
- vitest: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)
+ vitest: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies:
- bufferutil
- msw
@@ -2320,16 +2701,16 @@ snapshots:
- vite
optional: true
- '@vitest/browser@4.0.18(vite@7.3.0(@types/node@24.10.4))(vitest@4.0.18)':
+ '@vitest/browser@4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)':
dependencies:
- '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4))
+ '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
- vitest: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)
+ vitest: 4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -2347,13 +2728,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
- '@vitest/mocker@4.0.18(vite@7.3.0(@types/node@24.10.4))':
+ '@vitest/mocker@4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.0(@types/node@24.10.4)
+ vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -2419,6 +2800,24 @@ snapshots:
'@vue/compiler-dom': 3.5.26
'@vue/shared': 3.5.26
+ '@vue/devtools-api@7.7.9':
+ dependencies:
+ '@vue/devtools-kit': 7.7.9
+
+ '@vue/devtools-kit@7.7.9':
+ dependencies:
+ '@vue/devtools-shared': 7.7.9
+ birpc: 2.9.0
+ hookable: 5.5.3
+ mitt: 3.0.1
+ perfect-debounce: 1.0.0
+ speakingurl: 14.0.1
+ superjson: 2.2.6
+
+ '@vue/devtools-shared@7.7.9':
+ dependencies:
+ rfdc: 1.4.1
+
'@vue/language-core@3.2.1':
dependencies:
'@volar/language-core': 2.4.27
@@ -2458,6 +2857,19 @@ snapshots:
typescript: 5.9.3
vue: 3.5.26(typescript@5.9.3)
+ '@vueuse/core@14.2.1(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ '@types/web-bluetooth': 0.0.21
+ '@vueuse/metadata': 14.2.1
+ '@vueuse/shared': 14.2.1(vue@3.5.26(typescript@5.9.3))
+ vue: 3.5.26(typescript@5.9.3)
+
+ '@vueuse/metadata@14.2.1': {}
+
+ '@vueuse/shared@14.2.1(vue@3.5.26(typescript@5.9.3))':
+ dependencies:
+ vue: 3.5.26(typescript@5.9.3)
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -2489,6 +2901,8 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
+ birpc@2.9.0: {}
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -2537,6 +2951,10 @@ snapshots:
concat-map@0.0.1: {}
+ copy-anything@4.0.5:
+ dependencies:
+ is-what: 5.5.0
+
core-js-compat@3.48.0:
dependencies:
browserslist: 4.28.1
@@ -2549,6 +2967,8 @@ snapshots:
csstype@3.2.3: {}
+ date-fns@4.1.0: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2557,8 +2977,15 @@ snapshots:
deepmerge@4.3.1: {}
+ detect-libc@2.1.2: {}
+
electron-to-chromium@1.5.302: {}
+ enhanced-resolve@5.19.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
entities@7.0.0: {}
es-module-lexer@1.7.0: {}
@@ -2605,10 +3032,10 @@ snapshots:
optionalDependencies:
unrs-resolver: 1.11.1
- eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3):
+ eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)):
dependencies:
debug: 4.4.3
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
get-tsconfig: 4.13.6
is-bun-module: 2.0.0
@@ -2616,16 +3043,16 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)
+ eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3):
+ eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.56.1
comment-parser: 1.4.5
debug: 4.4.3
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.2.2
@@ -2633,19 +3060,19 @@ snapshots:
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
- eslint-plugin-unicorn@63.0.0(eslint@9.39.3):
+ eslint-plugin-unicorn@63.0.0(eslint@9.39.3(jiti@2.6.1)):
dependencies:
'@babel/helper-validator-identifier': 7.28.5
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
change-case: 5.4.4
ci-info: 4.4.0
clean-regexp: 1.0.0
core-js-compat: 3.48.0
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
find-up-simple: 1.0.1
globals: 16.5.0
indent-string: 5.0.0
@@ -2657,11 +3084,11 @@ snapshots:
semver: 7.7.4
strip-indent: 4.1.1
- eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3):
+ eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)):
dependencies:
- eslint: 9.39.3
+ eslint: 9.39.3(jiti@2.6.1)
optionalDependencies:
- '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
eslint-scope@8.4.0:
dependencies:
@@ -2674,9 +3101,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.3:
+ eslint@9.39.3(jiti@2.6.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
@@ -2710,6 +3137,8 @@ snapshots:
minimatch: 3.1.4
natural-compare: 1.4.0
optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
transitivePeerDependencies:
- supports-color
@@ -2806,12 +3235,16 @@ snapshots:
globals@16.5.0: {}
+ graceful-fs@4.2.11: {}
+
has-flag@4.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
+ hookable@5.5.3: {}
+
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -2845,8 +3278,12 @@ snapshots:
is-module@1.0.0: {}
+ is-what@5.5.0: {}
+
isexe@2.0.0: {}
+ jiti@2.6.1: {}
+
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -2868,6 +3305,55 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lightningcss-android-arm64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-arm64@1.31.1:
+ optional: true
+
+ lightningcss-darwin-x64@1.31.1:
+ optional: true
+
+ lightningcss-freebsd-x64@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.31.1:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.31.1:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.31.1:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.31.1:
+ optional: true
+
+ lightningcss@1.31.1:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.31.1
+ lightningcss-darwin-arm64: 1.31.1
+ lightningcss-darwin-x64: 1.31.1
+ lightningcss-freebsd-x64: 1.31.1
+ lightningcss-linux-arm-gnueabihf: 1.31.1
+ lightningcss-linux-arm64-gnu: 1.31.1
+ lightningcss-linux-arm64-musl: 1.31.1
+ lightningcss-linux-x64-gnu: 1.31.1
+ lightningcss-linux-x64-musl: 1.31.1
+ lightningcss-win32-arm64-msvc: 1.31.1
+ lightningcss-win32-x64-msvc: 1.31.1
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -2876,6 +3362,10 @@ snapshots:
lru-cache@11.2.6: {}
+ lucide-vue-next@0.575.0(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ vue: 3.5.26(typescript@5.9.3)
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2890,6 +3380,8 @@ snapshots:
minipass@7.1.3: {}
+ mitt@3.0.1: {}
+
mrmime@2.0.1:
optional: true
@@ -2945,10 +3437,19 @@ snapshots:
pathe@2.0.3: {}
+ perfect-debounce@1.0.0: {}
+
picocolors@1.1.1: {}
picomatch@4.0.3: {}
+ pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 7.7.9
+ vue: 3.5.26(typescript@5.9.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
@@ -2999,6 +3500,8 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
+ rfdc@1.4.1: {}
+
rimraf@6.1.3:
dependencies:
glob: 13.0.6
@@ -3082,6 +3585,8 @@ snapshots:
source-map-js@1.2.1: {}
+ speakingurl@14.0.1: {}
+
stable-hash-x@0.2.0: {}
stackback@0.0.2: {}
@@ -3092,12 +3597,20 @@ snapshots:
strip-json-comments@3.1.1: {}
+ superjson@2.2.6:
+ dependencies:
+ copy-anything: 4.0.5
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
+ tailwindcss@4.2.1: {}
+
+ tapable@2.3.0: {}
+
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -3122,13 +3635,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.56.1(eslint@9.39.3)(typescript@5.9.3):
+ typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)
- '@typescript-eslint/parser': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
- eslint: 9.39.3
+ '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3173,7 +3686,7 @@ snapshots:
dependencies:
punycode: 2.3.1
- vite@7.3.0(@types/node@24.10.4):
+ vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -3184,11 +3697,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.31.1
- vitest@4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18):
+ vitest@4.0.18(@types/node@24.10.4)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
'@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4))
+ '@vitest/mocker': 4.0.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@@ -3205,11 +3720,11 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vite: 7.3.0(@types/node@24.10.4)
+ vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.10.4
- '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.0(@types/node@24.10.4))(vitest@4.0.18)
+ '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1))(vitest@4.0.18)
transitivePeerDependencies:
- jiti
- less
From 1a67d4c18c38109473da0e1992d4073e9141c0b5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 05:23:18 +0000
Subject: [PATCH 03/21] feat(web): build cyberpunk DApp UI with file digest,
stamping, verification, and live feed
- Configure Vite with path aliases and Tailwind CSS v4
- Create cyberpunk theme (neon colors, glassmorphism, scanlines, animations)
- Add Pinia store for calendar status and recent stamps
- Add useFileDigest composable: pick file from local FS and compute SHA-256/Keccak-256
- Add useTimestampSDK composable: stamp, verify, upgrade, decode .ots files
- Add useWebSocketFeed composable: simulated real-time attestation feed
- Create base components: GlassCard, BaseButton, StatusBadge
- Create HeroTerminal with drag-and-drop file picker and hash input
- Create StampingWorkflow with step-by-step pipeline visualization
- Create VerificationResult with .ots upload and MerkleTreeViz (recursive)
- Create LiveFeed with real-time scrolling entries
- Create HomeView assembling all components with tab navigation
- Export SDK class, DEFAULT_CALENDARS, enums as runtime values from SDK index
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/index.html | 12 +-
apps/web/package.json | 2 +
apps/web/src/App.vue | 27 +--
apps/web/src/components/HelloWorld.vue | 45 ----
apps/web/src/components/base/BaseButton.vue | 49 +++++
apps/web/src/components/base/GlassCard.vue | 17 ++
apps/web/src/components/base/StatusBadge.vue | 46 ++++
apps/web/src/components/feed/LiveFeed.vue | 94 ++++++++
.../src/components/stamp/StampingWorkflow.vue | 138 ++++++++++++
.../src/components/terminal/HeroTerminal.vue | 200 +++++++++++++++++
.../src/components/verify/MerkleTreeViz.vue | 115 ++++++++++
.../components/verify/VerificationResult.vue | 174 +++++++++++++++
apps/web/src/composables/useFileDigest.ts | 106 +++++++++
apps/web/src/composables/useTimestampSDK.ts | 130 +++++++++++
apps/web/src/composables/useWebSocketFeed.ts | 81 +++++++
apps/web/src/main.ts | 5 +-
apps/web/src/stores/app.ts | 60 +++++
apps/web/src/style.css | 205 +++++++++++++-----
apps/web/src/views/HomeView.vue | 173 +++++++++++++++
apps/web/tsconfig.app.json | 3 +
apps/web/vite.config.ts | 9 +-
packages/sdk/src/index.ts | 10 +
pnpm-lock.yaml | 6 +
23 files changed, 1574 insertions(+), 133 deletions(-)
delete mode 100644 apps/web/src/components/HelloWorld.vue
create mode 100644 apps/web/src/components/base/BaseButton.vue
create mode 100644 apps/web/src/components/base/GlassCard.vue
create mode 100644 apps/web/src/components/base/StatusBadge.vue
create mode 100644 apps/web/src/components/feed/LiveFeed.vue
create mode 100644 apps/web/src/components/stamp/StampingWorkflow.vue
create mode 100644 apps/web/src/components/terminal/HeroTerminal.vue
create mode 100644 apps/web/src/components/verify/MerkleTreeViz.vue
create mode 100644 apps/web/src/components/verify/VerificationResult.vue
create mode 100644 apps/web/src/composables/useFileDigest.ts
create mode 100644 apps/web/src/composables/useTimestampSDK.ts
create mode 100644 apps/web/src/composables/useWebSocketFeed.ts
create mode 100644 apps/web/src/stores/app.ts
create mode 100644 apps/web/src/views/HomeView.vue
diff --git a/apps/web/index.html b/apps/web/index.html
index 508eabd..bca08bf 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -1,12 +1,18 @@
-
+
- web
+ UTS — Universal Timestamps
+
+
+
-
+
diff --git a/apps/web/package.json b/apps/web/package.json
index 975ce8c..de618f2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,9 +11,11 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
+ "@noble/hashes": "^2.0.1",
"@uts/sdk": "workspace:*",
"@vueuse/core": "^14.2.1",
"date-fns": "^4.1.0",
+ "ethers": "^6.16.0",
"lucide-vue-next": "^0.575.0",
"pinia": "^3.0.4",
"vue": "^3.5.24"
diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue
index aa54efc..9239bff 100644
--- a/apps/web/src/App.vue
+++ b/apps/web/src/App.vue
@@ -1,30 +1,7 @@
-
-
+
-
-
diff --git a/apps/web/src/components/HelloWorld.vue b/apps/web/src/components/HelloWorld.vue
deleted file mode 100644
index 390dd42..0000000
--- a/apps/web/src/components/HelloWorld.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
- {{ msg }}
-
-
-
-
- Edit
- components/HelloWorld.vue to test HMR
-
-
-
-
- Check out
- create-vue, the official Vue + Vite starter
-
-
- Learn more about IDE Support for Vue in the
- Vue Docs Scaling up Guide.
-
- Click on the Vite and Vue logos to learn more
-
-
-
diff --git a/apps/web/src/components/base/BaseButton.vue b/apps/web/src/components/base/BaseButton.vue
new file mode 100644
index 0000000..66e3a34
--- /dev/null
+++ b/apps/web/src/components/base/BaseButton.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/apps/web/src/components/base/GlassCard.vue b/apps/web/src/components/base/GlassCard.vue
new file mode 100644
index 0000000..71d946d
--- /dev/null
+++ b/apps/web/src/components/base/GlassCard.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/base/StatusBadge.vue b/apps/web/src/components/base/StatusBadge.vue
new file mode 100644
index 0000000..9a329f3
--- /dev/null
+++ b/apps/web/src/components/base/StatusBadge.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+ {{ labels[status] }}
+
+
diff --git a/apps/web/src/components/feed/LiveFeed.vue b/apps/web/src/components/feed/LiveFeed.vue
new file mode 100644
index 0000000..29bd340
--- /dev/null
+++ b/apps/web/src/components/feed/LiveFeed.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+ {{ isConnected ? 'CONNECTED' : 'DISCONNECTED' }}
+
+
+
+
+
+
+
+
+
+
+ {{ truncate(entry.hash) }}
+
+
+ {{ entry.chain }}
+ #{{ entry.blockHeight }}
+
+
+
+ {{ formatDistanceToNow(entry.timestamp, { addSuffix: true }) }}
+
+
+
+
+
+ Waiting for attestation events...
+
+
+
+
diff --git a/apps/web/src/components/stamp/StampingWorkflow.vue b/apps/web/src/components/stamp/StampingWorkflow.vue
new file mode 100644
index 0000000..9318c2c
--- /dev/null
+++ b/apps/web/src/components/stamp/StampingWorkflow.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
Stamping Pipeline
+
+
+
+
+
+
+
+
+
+
+ {{ step.label }}
+
+
{{ step.description }}
+
+
+
+
+
+
+
+
+
+ > {{ error }}
+
+
+
diff --git a/apps/web/src/components/terminal/HeroTerminal.vue b/apps/web/src/components/terminal/HeroTerminal.vue
new file mode 100644
index 0000000..c0a59ba
--- /dev/null
+++ b/apps/web/src/components/terminal/HeroTerminal.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
uts://terminal
+
+
+
+
+
+
+
+
+
+
+
+ > Computing {{ selectedAlgorithm }} digest...
+
+
+
{{ progress.toFixed(0) }}%
+
+
+
+
+
+
+ File Digested
+
+
+
+ {{ digestResult.fileName }} ({{ formatSize(digestResult.fileSize) }})
+
+
+ {{ digestResult.digest }}
+
+
+ {{ digestResult.algorithm }}
+
+
+
+
+
+
+
+
+ Drop a file here to compute its digest
+
+
or
+
+
+
+ Choose File
+
+
+
+
+
+
+
+
+ > Error: {{ digestError }}
+
+
+
+
+
+ >
+ Or paste a hash directly:
+
+
+
+
+
+ Stamp
+
+
+
+
+
diff --git a/apps/web/src/components/verify/MerkleTreeViz.vue b/apps/web/src/components/verify/MerkleTreeViz.vue
new file mode 100644
index 0000000..aac011b
--- /dev/null
+++ b/apps/web/src/components/verify/MerkleTreeViz.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+ {{ formatOp(step) }}
+
+
+
+
+
+
+ FORK ({{ step.steps.length }} branches)
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/verify/VerificationResult.vue b/apps/web/src/components/verify/VerificationResult.vue
new file mode 100644
index 0000000..de80d81
--- /dev/null
+++ b/apps/web/src/components/verify/VerificationResult.vue
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
Verification Dashboard
+
+
+
+
+
+ > Upload a .ots file to verify a timestamp proof
+
+
+
+
+ Upload .ots
+
+
+
+
+
+
+
+
+
+
+
+
+ Verifying proof chain...
+
+
+
+
+
+
+ Original Digest ({{ loadedTimestamp.header.kind }})
+
+
+ {{ hexlify(loadedTimestamp.header.digest as Uint8Array) }}
+
+
+
+
+
+
+ Attestations ({{ verifyAttestations.length }})
+
+
+
+
+
+
+ Bitcoin block #{{ att.attestation.height }}
+
+
+ Ethereum (chain {{ att.attestation.chain }}) block #{{ att.attestation.height }}
+
+
+ Pending → {{ att.attestation.url }}
+
+ Unknown attestation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ > {{ verifyError }}
+
+
+
+
+ Reset
+
+
+
+
diff --git a/apps/web/src/composables/useFileDigest.ts b/apps/web/src/composables/useFileDigest.ts
new file mode 100644
index 0000000..f035c98
--- /dev/null
+++ b/apps/web/src/composables/useFileDigest.ts
@@ -0,0 +1,106 @@
+import { ref } from 'vue'
+import { sha256 } from '@noble/hashes/sha2.js'
+import { keccak_256 } from '@noble/hashes/sha3.js'
+import { hexlify } from 'ethers/utils'
+import type { SecureDigestOp, DigestHeader } from '@uts/sdk'
+
+export interface FileDigestResult {
+ fileName: string
+ fileSize: number
+ algorithm: SecureDigestOp
+ digest: string
+ header: DigestHeader
+}
+
+const CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming hash
+
+export function useFileDigest() {
+ const isDigesting = ref(false)
+ const progress = ref(0)
+ const error = ref(null)
+ const result = ref(null)
+
+ async function digestFile(
+ file: File,
+ algorithm: SecureDigestOp = 'SHA256',
+ ): Promise {
+ isDigesting.value = true
+ progress.value = 0
+ error.value = null
+ result.value = null
+
+ try {
+ const factory = algorithm === 'KECCAK256' ? keccak_256 : sha256
+ const hasher = factory.create()
+ const totalSize = file.size
+ let processed = 0
+
+ const reader = file.stream().getReader()
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ // Process in sub-chunks for progress reporting
+ let offset = 0
+ while (offset < value.length) {
+ const end = Math.min(offset + CHUNK_SIZE, value.length)
+ hasher.update(value.subarray(offset, end))
+ offset = end
+ processed += end - offset || (end - (offset - (end - offset)))
+ }
+ processed = Math.min(
+ processed,
+ totalSize,
+ )
+ // Recalculate based on actual bytes seen
+ progress.value = totalSize > 0 ? Math.min((processed / totalSize) * 100, 100) : 100
+
+ // Yield to main thread periodically
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ // Fix progress tracking: just use processed bytes
+ progress.value = 100
+
+ const digestBytes = hasher.digest()
+ const digestHex = hexlify(digestBytes)
+
+ const digestResult: FileDigestResult = {
+ fileName: file.name,
+ fileSize: file.size,
+ algorithm,
+ digest: digestHex,
+ header: {
+ kind: algorithm,
+ digest: digestBytes,
+ },
+ }
+
+ result.value = digestResult
+ return digestResult
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to digest file'
+ error.value = msg
+ throw new Error(msg)
+ } finally {
+ isDigesting.value = false
+ }
+ }
+
+ function reset() {
+ isDigesting.value = false
+ progress.value = 0
+ error.value = null
+ result.value = null
+ }
+
+ return {
+ isDigesting,
+ progress,
+ error,
+ result,
+ digestFile,
+ reset,
+ }
+}
diff --git a/apps/web/src/composables/useTimestampSDK.ts b/apps/web/src/composables/useTimestampSDK.ts
new file mode 100644
index 0000000..7583eeb
--- /dev/null
+++ b/apps/web/src/composables/useTimestampSDK.ts
@@ -0,0 +1,130 @@
+import { ref, shallowRef } from 'vue'
+import {
+ SDK,
+ VerifyStatus,
+ Decoder,
+} from '@uts/sdk'
+import type {
+ DetachedTimestamp,
+ AttestationStatus,
+ UpgradeResult,
+ DigestHeader,
+} from '@uts/sdk'
+
+export type StampPhase =
+ | 'idle'
+ | 'hashing'
+ | 'generating-nonce'
+ | 'building-merkle-tree'
+ | 'broadcasting'
+ | 'waiting-attestation'
+ | 'complete'
+ | 'error'
+
+export function useTimestampSDK() {
+ const sdk = new SDK({ timeout: 15000 })
+
+ const stampPhase = ref('idle')
+ const stampError = ref(null)
+ const stampResult = shallowRef(null)
+
+ const verifyStatus = ref(null)
+ const verifyAttestations = shallowRef([])
+ const isVerifying = ref(false)
+ const verifyError = ref(null)
+
+ async function stamp(digests: DigestHeader[]): Promise {
+ stampPhase.value = 'hashing'
+ stampError.value = null
+ stampResult.value = null
+
+ try {
+ await delay(400)
+ stampPhase.value = 'generating-nonce'
+ await delay(300)
+ stampPhase.value = 'building-merkle-tree'
+ await delay(300)
+ stampPhase.value = 'broadcasting'
+
+ const results = await sdk.stamp(digests)
+
+ stampPhase.value = 'waiting-attestation'
+ await delay(500)
+ stampPhase.value = 'complete'
+ stampResult.value = results
+ return results
+ } catch (e) {
+ stampPhase.value = 'error'
+ stampError.value = e instanceof Error ? e.message : 'Stamping failed'
+ throw e
+ }
+ }
+
+ async function verify(
+ stamp: DetachedTimestamp,
+ ): Promise<{ status: VerifyStatus; attestations: AttestationStatus[] }> {
+ isVerifying.value = true
+ verifyError.value = null
+ verifyStatus.value = null
+ verifyAttestations.value = []
+
+ try {
+ const attestations = await sdk.verify(stamp)
+ const status = sdk.transformResult(attestations)
+
+ verifyStatus.value = status
+ verifyAttestations.value = attestations
+ return { status, attestations }
+ } catch (e) {
+ verifyError.value = e instanceof Error ? e.message : 'Verification failed'
+ throw e
+ } finally {
+ isVerifying.value = false
+ }
+ }
+
+ async function upgrade(
+ detached: DetachedTimestamp,
+ ): Promise {
+ return sdk.upgrade(detached)
+ }
+
+ function decodeOtsFile(data: Uint8Array): DetachedTimestamp {
+ const decoder = new Decoder(data)
+ return decoder.readDetachedTimestamp()
+ }
+
+ function resetStamp() {
+ stampPhase.value = 'idle'
+ stampError.value = null
+ stampResult.value = null
+ }
+
+ function resetVerify() {
+ verifyStatus.value = null
+ verifyAttestations.value = []
+ isVerifying.value = false
+ verifyError.value = null
+ }
+
+ return {
+ stampPhase,
+ stampError,
+ stampResult,
+ stamp,
+ resetStamp,
+
+ verifyStatus,
+ verifyAttestations,
+ isVerifying,
+ verifyError,
+ verify,
+ upgrade,
+ decodeOtsFile,
+ resetVerify,
+ }
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/apps/web/src/composables/useWebSocketFeed.ts b/apps/web/src/composables/useWebSocketFeed.ts
new file mode 100644
index 0000000..bee9b89
--- /dev/null
+++ b/apps/web/src/composables/useWebSocketFeed.ts
@@ -0,0 +1,81 @@
+import { ref, onUnmounted } from 'vue'
+
+export interface FeedEntry {
+ id: string
+ hash: string
+ type: 'bitcoin' | 'ethereum' | 'pending'
+ chain?: string
+ blockHeight?: number
+ timestamp: number
+}
+
+export function useWebSocketFeed() {
+ const entries = ref([])
+ const isConnected = ref(false)
+ let intervalId: ReturnType | null = null
+
+ const MOCK_CHAINS = ['Bitcoin', 'Ethereum', 'Scroll', 'Sepolia']
+ const MOCK_TYPES: FeedEntry['type'][] = ['bitcoin', 'ethereum', 'pending']
+
+ function randomHex(len: number): string {
+ const bytes = new Uint8Array(len)
+ crypto.getRandomValues(bytes)
+ return (
+ '0x' +
+ Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+ )
+ }
+
+ function generateMockEntry(): FeedEntry {
+ const type = MOCK_TYPES[Math.floor(Math.random() * MOCK_TYPES.length)]!
+ return {
+ id: crypto.randomUUID(),
+ hash: randomHex(32),
+ type,
+ chain: type !== 'pending' ? MOCK_CHAINS[Math.floor(Math.random() * MOCK_CHAINS.length)] : undefined,
+ blockHeight:
+ type !== 'pending'
+ ? Math.floor(Math.random() * 1000000) + 19000000
+ : undefined,
+ timestamp: Date.now(),
+ }
+ }
+
+ function connect() {
+ isConnected.value = true
+
+ // Seed initial entries
+ for (let i = 0; i < 8; i++) {
+ entries.value.push(generateMockEntry())
+ }
+
+ // Simulate live feed
+ intervalId = setInterval(() => {
+ entries.value.unshift(generateMockEntry())
+ if (entries.value.length > 50) {
+ entries.value.pop()
+ }
+ }, 3000 + Math.random() * 2000)
+ }
+
+ function disconnect() {
+ isConnected.value = false
+ if (intervalId) {
+ clearInterval(intervalId)
+ intervalId = null
+ }
+ }
+
+ onUnmounted(() => {
+ disconnect()
+ })
+
+ return {
+ entries,
+ isConnected,
+ connect,
+ disconnect,
+ }
+}
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts
index 2425c0f..a2cedb6 100644
--- a/apps/web/src/main.ts
+++ b/apps/web/src/main.ts
@@ -1,5 +1,8 @@
import { createApp } from 'vue'
+import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
-createApp(App).mount('#app')
+const app = createApp(App)
+app.use(createPinia())
+app.mount('#app')
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
new file mode 100644
index 0000000..4278ab1
--- /dev/null
+++ b/apps/web/src/stores/app.ts
@@ -0,0 +1,60 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { DEFAULT_CALENDARS } from '@uts/sdk'
+import type { DetachedTimestamp } from '@uts/sdk'
+
+export interface CalendarNode {
+ url: string
+ status: 'online' | 'offline' | 'checking'
+ latency?: number
+}
+
+export const useAppStore = defineStore('app', () => {
+ const calendars = ref(
+ DEFAULT_CALENDARS.map((url) => ({
+ url: url.toString(),
+ status: 'checking' as const,
+ })),
+ )
+
+ const recentStamps = ref([])
+
+ const onlineCount = computed(
+ () => calendars.value.filter((c) => c.status === 'online').length,
+ )
+
+ async function checkCalendars() {
+ for (const cal of calendars.value) {
+ cal.status = 'checking'
+ const start = performance.now()
+ try {
+ const response = await fetch(cal.url, {
+ method: 'HEAD',
+ mode: 'no-cors',
+ signal: AbortSignal.timeout(5000),
+ })
+ // no-cors returns opaque response; treat as online
+ cal.latency = Math.round(performance.now() - start)
+ cal.status = response.type === 'opaque' || response.ok ? 'online' : 'offline'
+ } catch {
+ cal.status = 'offline'
+ cal.latency = undefined
+ }
+ }
+ }
+
+ function addStamp(stamp: DetachedTimestamp) {
+ recentStamps.value.unshift(stamp)
+ if (recentStamps.value.length > 20) {
+ recentStamps.value.pop()
+ }
+ }
+
+ return {
+ calendars,
+ recentStamps,
+ onlineCount,
+ checkCalendars,
+ addStamp,
+ }
+})
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index f691315..8442c96 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -1,79 +1,168 @@
-:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
+@import 'tailwindcss';
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
+@theme {
+ --color-deep-black: #050505;
+ --color-midnight: #0a0f14;
+ --color-neon-cyan: #00f3ff;
+ --color-neon-purple: #bc13fe;
+ --color-neon-orange: #ff9e00;
+ --color-glass: rgba(255, 255, 255, 0.04);
+ --color-glass-border: rgba(255, 255, 255, 0.08);
+ --color-surface: #0d1117;
+ --color-surface-light: #161b22;
+ --color-valid: #00ff88;
+ --color-invalid: #ff3366;
+ --color-pending: #ff9e00;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+ --font-heading: 'Space Grotesk', system-ui, sans-serif;
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
+ --animate-glow-pulse: glow-pulse 2s ease-in-out infinite;
+ --animate-scan: scan 4s linear infinite;
+ --animate-fade-in: fade-in 0.5s ease-out;
+ --animate-slide-up: slide-up 0.4s ease-out;
+ --animate-typewriter: typewriter 0.05s steps(1) infinite;
+
+ @keyframes glow-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ }
+
+ @keyframes scan {
+ 0% {
+ transform: translateY(-100%);
+ }
+ 100% {
+ transform: translateY(100vh);
+ }
+ }
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ @keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
}
+/* Base styles */
body {
margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
min-height: 100vh;
+ background: var(--color-deep-black);
+ overflow-x: hidden;
}
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
+#app {
+ width: 100%;
+ min-height: 100vh;
}
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
+/* Scanline overlay */
+.scanlines::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0, 243, 255, 0.015) 2px,
+ rgba(0, 243, 255, 0.015) 4px
+ );
+ pointer-events: none;
+ z-index: 9999;
}
-button:hover {
- border-color: #646cff;
+
+/* Glass card */
+.glass {
+ background: var(--color-glass);
+ border: 1px solid var(--color-glass-border);
+ backdrop-filter: blur(12px);
}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
+
+/* Glow effects */
+.glow-cyan {
+ box-shadow:
+ 0 0 10px rgba(0, 243, 255, 0.15),
+ 0 0 40px rgba(0, 243, 255, 0.05);
}
-.card {
- padding: 2em;
+.glow-purple {
+ box-shadow:
+ 0 0 10px rgba(188, 19, 254, 0.15),
+ 0 0 40px rgba(188, 19, 254, 0.05);
}
-#app {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
+.glow-text-cyan {
+ text-shadow: 0 0 10px rgba(0, 243, 255, 0.5);
}
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
+.glow-text-valid {
+ text-shadow: 0 0 12px rgba(0, 255, 136, 0.6);
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 243, 255, 0.2);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 243, 255, 0.4);
+}
+
+/* Transition classes */
+.fade-enter-active,
+.fade-leave-active {
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+ transform: translateY(8px);
+}
+
+.list-enter-active,
+.list-leave-active {
+ transition: all 0.4s ease;
+}
+
+.list-enter-from {
+ opacity: 0;
+ transform: translateX(-20px);
+}
+
+.list-leave-to {
+ opacity: 0;
+ transform: translateX(20px);
}
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
new file mode 100644
index 0000000..57ed97c
--- /dev/null
+++ b/apps/web/src/views/HomeView.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UTS
+
+
Universal Timestamps
+
+
+
+
+
+
+
+
+
+ {{ store.onlineCount }}/{{ store.calendars.length }} nodes
+
+
+
+
+
+
+
+
+
+
+ Decentralized Timestamping
+
+
+ Cryptographic proof of existence anchored to Bitcoin & Ethereum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
index 8d16e42..245d63d 100644
--- a/apps/web/tsconfig.app.json
+++ b/apps/web/tsconfig.app.json
@@ -3,6 +3,9 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
/* Linting */
"strict": true,
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index bbcf80c..72a291d 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,7 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
+import tailwindcss from '@tailwindcss/vite'
+import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
- plugins: [vue()],
+ plugins: [vue(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
})
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 27cd9b4..bde657b 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -10,6 +10,13 @@ export type {
EthereumUTSAttestationExtraMetadata,
} from './types.ts'
+export {
+ DIGEST_OPS,
+ UpgradeStatus,
+ AttestationStatusKind,
+ VerifyStatus,
+} from './types.ts'
+
export { default as Encoder } from './codec/encode.ts'
export { default as Decoder } from './codec/decode.ts'
@@ -21,6 +28,9 @@ export * from './bmt.ts'
export { default as BitcoinRPC } from './rpc/btc.ts'
+export { default as SDK, DEFAULT_CALENDARS, UTS_ABI } from './sdk.ts'
+export type { SDKOptions } from './sdk.ts'
+
export const hexlify = (obj: any): any => {
if (obj instanceof URL) {
return obj
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80da8bf..4b49d17 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
apps/web:
dependencies:
+ '@noble/hashes':
+ specifier: ^2.0.1
+ version: 2.0.1
'@uts/sdk':
specifier: workspace:*
version: link:../../packages/sdk
@@ -44,6 +47,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
+ ethers:
+ specifier: ^6.16.0
+ version: 6.16.0
lucide-vue-next:
specifier: ^0.575.0
version: 0.575.0(vue@3.5.26(typescript@5.9.3))
From daf0d62385d029c072f7f38318267053c49ca8f6 Mon Sep 17 00:00:00 2001
From: lightsing
Date: Wed, 25 Feb 2026 13:32:54 +0800
Subject: [PATCH 04/21] webapp don't need to build sdk
---
apps/web/vite.config.ts | 1 +
packages/sdk/package.json | 1 +
2 files changed, 2 insertions(+)
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 72a291d..f1d2885 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -10,5 +10,6 @@ export default defineConfig({
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
+ conditions: ['uts-source', 'module', 'browser', 'development|production'],
},
})
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 9235168..0c459f6 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -9,6 +9,7 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
+ "uts-source": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
From 2c263be50dd95935fd33983675412995c127e426 Mon Sep 17 00:00:00 2001
From: lightsing
Date: Wed, 25 Feb 2026 13:33:08 +0800
Subject: [PATCH 05/21] fix naming
---
apps/web/src/views/HomeView.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 57ed97c..032991b 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -166,7 +166,7 @@ function handleResetWorkflow() {
From 55fb97a4bb10203491ec07b8599e1455b42390aa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 05:41:11 +0000
Subject: [PATCH 06/21] feat(web): ethereum-first UI with real feed data from
ethRPCs
- Replace mock LiveFeed with real Ethereum Attested event polling via SDK ethRPCs
- Show Ethereum chain RPC statuses in header instead of Bitcoin calendar nodes
- Update subtitle to "anchored to Ethereum"
- Expose singleton SDK instance via getSDK() for shared access
- Bitcoin attestation verification still fully supported in Verify tab
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/src/components/feed/LiveFeed.vue | 51 +++------
apps/web/src/composables/useTimestampSDK.ts | 11 +-
apps/web/src/composables/useWebSocketFeed.ts | 111 ++++++++++++-------
apps/web/src/stores/app.ts | 63 ++++++-----
apps/web/src/views/HomeView.vue | 20 ++--
5 files changed, 145 insertions(+), 111 deletions(-)
diff --git a/apps/web/src/components/feed/LiveFeed.vue b/apps/web/src/components/feed/LiveFeed.vue
index 29bd340..9370f7b 100644
--- a/apps/web/src/components/feed/LiveFeed.vue
+++ b/apps/web/src/components/feed/LiveFeed.vue
@@ -1,9 +1,9 @@
@@ -88,7 +99,7 @@ function getStepStatus(stepId: StampPhase) {
:is="step.icon"
v-else
class="h-3.5 w-3.5"
- :class="{ 'animate-spin': getStepStatus(step.id) === 'active' && step.id === 'hashing' }"
+ :class="{ 'animate-spin': getStepStatus(step.id) === 'active' && (step.id === 'upgrading') }"
/>
@@ -116,7 +127,7 @@ function getStepStatus(stepId: StampPhase) {
>
{{ step.label }}
- {{ step.description }}
+ {{ getStepDescription(step) }}
diff --git a/apps/web/src/composables/useTimestampSDK.ts b/apps/web/src/composables/useTimestampSDK.ts
index bcb3799..09bd5db 100644
--- a/apps/web/src/composables/useTimestampSDK.ts
+++ b/apps/web/src/composables/useTimestampSDK.ts
@@ -2,23 +2,28 @@ import { ref, shallowRef } from 'vue'
import {
SDK,
VerifyStatus,
+ UpgradeStatus,
Decoder,
+ Encoder,
} from '@uts/sdk'
import type {
DetachedTimestamp,
AttestationStatus,
UpgradeResult,
DigestHeader,
+ StampEventCallback,
} from '@uts/sdk'
export type StampPhase =
| 'idle'
- | 'hashing'
| 'generating-nonce'
| 'building-merkle-tree'
| 'broadcasting'
| 'waiting-attestation'
+ | 'building-proof'
| 'complete'
+ | 'upgrading'
+ | 'upgraded'
| 'error'
let _sdkInstance: SDK | null = null
@@ -30,37 +35,84 @@ export function getSDK(): SDK {
return _sdkInstance
}
+export function resetSDK(calendars?: URL[]) {
+ _sdkInstance = new SDK({ timeout: 15000, calendars })
+}
+
+function downloadOtsFile(stamp: DetachedTimestamp, fileName?: string) {
+ const encoded = Encoder.encodeDetachedTimestamp(stamp)
+ const blob = new Blob([encoded as BlobPart], { type: 'application/octet-stream' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = fileName ? `${fileName}.ots` : 'timestamp.ots'
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
+
export function useTimestampSDK() {
const sdk = getSDK()
const stampPhase = ref('idle')
const stampError = ref(null)
const stampResult = shallowRef(null)
+ const broadcastProgress = ref('')
+ const upgradeResults = shallowRef(null)
const verifyStatus = ref(null)
const verifyAttestations = shallowRef([])
const isVerifying = ref(false)
const verifyError = ref(null)
- async function stamp(digests: DigestHeader[]): Promise {
- stampPhase.value = 'hashing'
+ let upgradeTimer: ReturnType | null = null
+
+ async function stamp(digests: DigestHeader[], fileName?: string): Promise {
+ stampPhase.value = 'generating-nonce'
stampError.value = null
stampResult.value = null
+ broadcastProgress.value = ''
+ upgradeResults.value = null
+
+ const onEvent: StampEventCallback = (event) => {
+ switch (event.phase) {
+ case 'generating-nonce':
+ stampPhase.value = 'generating-nonce'
+ break
+ case 'building-merkle-tree':
+ stampPhase.value = 'building-merkle-tree'
+ break
+ case 'broadcasting':
+ stampPhase.value = 'broadcasting'
+ broadcastProgress.value = `0/${event.totalCalendars}`
+ break
+ case 'calendar-response':
+ broadcastProgress.value = `${event.responsesReceived}/${event.totalCalendars}${event.success ? '' : ' (failed: ' + event.calendarUrl + ')'}`
+ break
+ case 'building-proof':
+ stampPhase.value = 'building-proof'
+ break
+ case 'complete':
+ stampPhase.value = 'complete'
+ break
+ }
+ }
try {
- await delay(400)
- stampPhase.value = 'generating-nonce'
- await delay(300)
- stampPhase.value = 'building-merkle-tree'
- await delay(300)
- stampPhase.value = 'broadcasting'
-
- const results = await sdk.stamp(digests)
+ const results = await sdk.stamp(digests, onEvent)
- stampPhase.value = 'waiting-attestation'
- await delay(500)
stampPhase.value = 'complete'
stampResult.value = results
+
+ // Download the stamped .ots file
+ for (const result of results) {
+ downloadOtsFile(result, fileName)
+ }
+
+ // Start polling for upgrade
+ startUpgradePolling(results)
+
return results
} catch (e) {
stampPhase.value = 'error'
@@ -69,6 +121,47 @@ export function useTimestampSDK() {
}
}
+ function startUpgradePolling(stamps: DetachedTimestamp[]) {
+ stopUpgradePolling()
+ stampPhase.value = 'upgrading'
+
+ let attempts = 0
+ const maxAttempts = 40 // ~10 minutes at 15s intervals
+
+ upgradeTimer = setInterval(async () => {
+ attempts++
+ try {
+ const allResults: UpgradeResult[] = []
+ for (const s of stamps) {
+ const results = await sdk.upgrade(s)
+ allResults.push(...results)
+ }
+ upgradeResults.value = allResults
+
+ const hasUpgraded = allResults.some((r) => r.status === UpgradeStatus.Upgraded)
+ if (hasUpgraded) {
+ stampPhase.value = 'upgraded'
+ // Download upgraded file
+ for (const s of stamps) {
+ downloadOtsFile(s)
+ }
+ stopUpgradePolling()
+ } else if (attempts >= maxAttempts) {
+ stopUpgradePolling()
+ }
+ } catch {
+ // Silently retry on next interval
+ }
+ }, 15000)
+ }
+
+ function stopUpgradePolling() {
+ if (upgradeTimer) {
+ clearInterval(upgradeTimer)
+ upgradeTimer = null
+ }
+ }
+
async function verify(
stamp: DetachedTimestamp,
): Promise<{ status: VerifyStatus; attestations: AttestationStatus[] }> {
@@ -107,6 +200,9 @@ export function useTimestampSDK() {
stampPhase.value = 'idle'
stampError.value = null
stampResult.value = null
+ broadcastProgress.value = ''
+ upgradeResults.value = null
+ stopUpgradePolling()
}
function resetVerify() {
@@ -120,6 +216,8 @@ export function useTimestampSDK() {
stampPhase,
stampError,
stampResult,
+ broadcastProgress,
+ upgradeResults,
stamp,
resetStamp,
@@ -133,7 +231,3 @@ export function useTimestampSDK() {
resetVerify,
}
}
-
-function delay(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
index 3950eab..5989e47 100644
--- a/apps/web/src/stores/app.ts
+++ b/apps/web/src/stores/app.ts
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
+import { DEFAULT_CALENDARS } from '@uts/sdk'
import type { DetachedTimestamp } from '@uts/sdk'
-import { getSDK } from '@/composables/useTimestampSDK'
+import { getSDK, resetSDK } from '@/composables/useTimestampSDK'
const CHAIN_NAMES: Record = {
1: 'Ethereum',
@@ -18,7 +19,22 @@ export interface EthChainNode {
latency?: number
}
+const STORAGE_KEY = 'uts-calendars'
+
+function loadCalendars(): string[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) return JSON.parse(stored)
+ } catch { /* ignore */ }
+ return DEFAULT_CALENDARS.map((u) => u.toString())
+}
+
+function saveCalendars(urls: string[]) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(urls))
+}
+
export const useAppStore = defineStore('app', () => {
+ const calendarUrls = ref(loadCalendars())
const ethChains = ref([])
const recentStamps = ref([])
@@ -26,6 +42,11 @@ export const useAppStore = defineStore('app', () => {
() => ethChains.value.filter((c) => c.status === 'online').length,
)
+ watch(calendarUrls, (urls) => {
+ saveCalendars(urls)
+ resetSDK(urls.map((u) => new URL(u)))
+ }, { deep: true })
+
async function checkChains() {
const sdk = getSDK()
const chainIds = Object.keys(sdk.ethRPCs).map(Number)
@@ -61,11 +82,22 @@ export const useAppStore = defineStore('app', () => {
}
}
+ function setCalendars(urls: string[]) {
+ calendarUrls.value = urls
+ }
+
+ function resetCalendars() {
+ calendarUrls.value = DEFAULT_CALENDARS.map((u) => u.toString())
+ }
+
return {
+ calendarUrls,
ethChains,
recentStamps,
onlineCount,
checkChains,
addStamp,
+ setCalendars,
+ resetCalendars,
}
})
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 2e36866..7e1a044 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -1,19 +1,23 @@
@@ -66,8 +90,8 @@ function handleResetWorkflow() {
-
+
{{ store.onlineCount }}/{{ store.ethChains.length }} chains
+
+
+
+
+
+
+
+
+
Calendar Nodes
+
+
+ Reset to defaults
+
+
+
+
+
+ {{ url }}
+
+
+
+
+
+
+
+
+
+
@@ -145,6 +225,7 @@ function handleResetWorkflow() {
v-if="showWorkflow"
:phase="stampPhase"
:error="stampError"
+ :broadcast-progress="broadcastProgress"
/>
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index bde657b..1a8e0fc 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -29,7 +29,7 @@ export * from './bmt.ts'
export { default as BitcoinRPC } from './rpc/btc.ts'
export { default as SDK, DEFAULT_CALENDARS, UTS_ABI } from './sdk.ts'
-export type { SDKOptions } from './sdk.ts'
+export type { SDKOptions, StampEvent, StampEventCallback } from './sdk.ts'
export const hexlify = (obj: any): any => {
if (obj instanceof URL) {
diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts
index 679e237..2e5fa91 100644
--- a/packages/sdk/src/sdk.ts
+++ b/packages/sdk/src/sdk.ts
@@ -33,6 +33,16 @@ import { ripemd160, sha1 } from '@noble/hashes/legacy.js'
import BitcoinRPC from './rpc/btc.ts'
import { FallbackProvider } from 'ethers'
+export type StampEvent =
+ | { phase: 'generating-nonce' }
+ | { phase: 'building-merkle-tree' }
+ | { phase: 'broadcasting'; totalCalendars: number }
+ | { phase: 'calendar-response'; calendarUrl: string; success: boolean; responsesReceived: number; totalCalendars: number }
+ | { phase: 'building-proof' }
+ | { phase: 'complete' }
+
+export type StampEventCallback = (event: StampEvent) => void
+
export interface SDKOptions {
calendars?: URL[]
btcRPC?: BitcoinRPC
@@ -170,10 +180,12 @@ export default class SDK {
*
* @param digests The digests to be stamped, each with its associated header information. Input digests can use different hash algorithms, but the internal Merkle tree will be constructed using the SDK's configured hash algorithm (default KECCAK256).
*/
- async stamp(digests: DigestHeader[]): Promise {
+ async stamp(digests: DigestHeader[], onEvent?: StampEventCallback): Promise {
const nonces: Uint8Array[] = []
const nonceDigests: Uint8Array[] = []
+ onEvent?.({ phase: 'generating-nonce' })
+
for (const digest of digests) {
const hasher = this.hasher.create()
hasher.update(getBytes(digest.digest))
@@ -184,6 +196,8 @@ export default class SDK {
nonceDigests.push(nonceDigest)
}
+ onEvent?.({ phase: 'building-merkle-tree' })
+
const internalMerkleTree = UnorderedMerkleTree.new(
nonceDigests,
this.hasher,
@@ -191,8 +205,22 @@ export default class SDK {
const root = internalMerkleTree.root()
console.debug(`Internal Merkle root: ${hexlify(root)}`)
+ onEvent?.({ phase: 'broadcasting', totalCalendars: this.calendars.length })
+
+ let responsesReceived = 0
const calendarResponses = await Promise.allSettled(
- this.calendars.map((calendar) => this.requestAttest(calendar, root)),
+ this.calendars.map(async (calendar) => {
+ try {
+ const result = await this.requestAttest(calendar, root)
+ responsesReceived++
+ onEvent?.({ phase: 'calendar-response', calendarUrl: calendar.toString(), success: true, responsesReceived, totalCalendars: this.calendars.length })
+ return result
+ } catch (error) {
+ responsesReceived++
+ onEvent?.({ phase: 'calendar-response', calendarUrl: calendar.toString(), success: false, responsesReceived, totalCalendars: this.calendars.length })
+ throw error
+ }
+ }),
)
const successfulResponses = calendarResponses.filter(
@@ -214,7 +242,9 @@ export default class SDK {
} as ForkStep,
]
- return digests.map((digest, i) => {
+ onEvent?.({ phase: 'building-proof' })
+
+ const results = digests.map((digest, i) => {
const timestamp: Timestamp = [
{ op: 'APPEND', data: nonces[i] },
{ op: this.hashAlg },
@@ -256,6 +286,10 @@ export default class SDK {
timestamp,
}
})
+
+ onEvent?.({ phase: 'complete' })
+
+ return results
}
/**
From 7ebe7d111b27c05510d09a2ce26802eb275e5046 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 06:28:13 +0000
Subject: [PATCH 08/21] feat: add web3 wallet provider support for browser
usage
SDK changes:
- Add web3Provider (Eip1193Provider) field to SDKOptions and SDK class
- Add WELL_KNOWN_CHAINS map for wallet_switchEthereumChain
- Add getWeb3ProviderForChain() helper that tries wallet first, with
automatic chain switching for well-known networks
- verifyEthereumUTSAttestation now tries web3Provider first (no CORS),
then falls back to ethRPCs
- Export WELL_KNOWN_CHAINS from SDK index
Web app changes:
- Create useWallet composable: connect/disconnect MetaMask via EIP-1193,
track address/chainId, listen for account/chain changes
- Add setWeb3Provider() to useTimestampSDK for hot-swapping wallet
- useWebSocketFeed prefers web3Provider for event polling when connected,
falls back to ethRPCs when no wallet
- Add Connect Wallet button in header (shows address + chain when
connected, "No Wallet" when no EIP-1193 provider detected)
- Wallet provider synced to SDK via watch on connection state
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/src/components/feed/LiveFeed.vue | 2 +-
apps/web/src/composables/useTimestampSDK.ts | 14 ++-
apps/web/src/composables/useWallet.ts | 124 +++++++++++++++++++
apps/web/src/composables/useWebSocketFeed.ts | 73 ++++++++++-
apps/web/src/stores/app.ts | 2 +-
apps/web/src/views/HomeView.vue | 54 +++++++-
packages/sdk/src/index.ts | 2 +-
packages/sdk/src/sdk.ts | 75 +++++++++--
8 files changed, 324 insertions(+), 22 deletions(-)
create mode 100644 apps/web/src/composables/useWallet.ts
diff --git a/apps/web/src/components/feed/LiveFeed.vue b/apps/web/src/components/feed/LiveFeed.vue
index 9370f7b..55ee6ed 100644
--- a/apps/web/src/components/feed/LiveFeed.vue
+++ b/apps/web/src/components/feed/LiveFeed.vue
@@ -62,7 +62,7 @@ function truncate(hash: string): string {
class="py-8 text-center font-mono text-xs text-white/30"
>
- Polling Ethereum RPCs for Attested events...
+ Polling for Attested events...
(null)
+const walletChainId = ref
(null)
+const isConnecting = ref(false)
+const walletError = ref(null)
+
+const isConnected = computed(() => walletAddress.value !== null)
+const hasWallet = computed(() => typeof window !== 'undefined' && !!(window as any).ethereum)
+
+const CHAIN_NAMES: Record = {
+ 1: 'Ethereum',
+ 17000: 'Holesky',
+ 11155111: 'Sepolia',
+ 54352: 'Scroll',
+ 54351: 'Scroll Sepolia',
+}
+
+const walletChainName = computed(() => {
+ if (!walletChainId.value) return null
+ return CHAIN_NAMES[walletChainId.value] ?? `Chain ${walletChainId.value}`
+})
+
+function getEip1193Provider(): Eip1193Provider | null {
+ if (typeof window === 'undefined') return null
+ return (window as any).ethereum ?? null
+}
+
+function truncateAddress(address: string): string {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`
+}
+
+export function useWallet() {
+ async function connect() {
+ const ethereum = getEip1193Provider()
+ if (!ethereum) {
+ walletError.value = 'No wallet detected. Install MetaMask or another EIP-1193 wallet.'
+ return
+ }
+
+ isConnecting.value = true
+ walletError.value = null
+
+ try {
+ const provider = new BrowserProvider(ethereum)
+ const accounts = await provider.send('eth_requestAccounts', [])
+ if (accounts.length === 0) {
+ walletError.value = 'No accounts returned from wallet'
+ return
+ }
+ walletAddress.value = accounts[0] ?? null
+
+ const network = await provider.getNetwork()
+ walletChainId.value = Number(network.chainId)
+
+ // Listen for account/chain changes
+ ;(ethereum as any).on?.('accountsChanged', handleAccountsChanged)
+ ;(ethereum as any).on?.('chainChanged', handleChainChanged)
+ } catch (e) {
+ walletError.value = e instanceof Error ? e.message : 'Failed to connect wallet'
+ } finally {
+ isConnecting.value = false
+ }
+ }
+
+ function disconnect() {
+ const ethereum = getEip1193Provider()
+ if (ethereum) {
+ ;(ethereum as any).removeListener?.('accountsChanged', handleAccountsChanged)
+ ;(ethereum as any).removeListener?.('chainChanged', handleChainChanged)
+ }
+ walletAddress.value = null
+ walletChainId.value = null
+ walletError.value = null
+ }
+
+ async function switchChain(chainId: number) {
+ const ethereum = getEip1193Provider()
+ if (!ethereum) return
+
+ const knownChain = WELL_KNOWN_CHAINS[chainId]
+ if (!knownChain) return
+
+ try {
+ await ethereum.request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: knownChain.chainId }],
+ })
+ } catch {
+ // switch failed
+ }
+ }
+
+ return {
+ walletAddress,
+ walletChainId,
+ walletChainName,
+ isConnected,
+ isConnecting,
+ hasWallet,
+ walletError,
+ connect,
+ disconnect,
+ switchChain,
+ getEip1193Provider,
+ truncateAddress,
+ }
+}
+
+function handleAccountsChanged(accounts: string[]) {
+ if (accounts.length === 0) {
+ walletAddress.value = null
+ walletChainId.value = null
+ } else {
+ walletAddress.value = accounts[0] ?? null
+ }
+}
+
+function handleChainChanged(chainIdHex: string) {
+ walletChainId.value = parseInt(chainIdHex, 16)
+}
diff --git a/apps/web/src/composables/useWebSocketFeed.ts b/apps/web/src/composables/useWebSocketFeed.ts
index b599c2d..bd582de 100644
--- a/apps/web/src/composables/useWebSocketFeed.ts
+++ b/apps/web/src/composables/useWebSocketFeed.ts
@@ -1,4 +1,6 @@
import { ref, onUnmounted } from 'vue'
+import { BrowserProvider } from 'ethers'
+import type { Eip1193Provider } from 'ethers'
import { SDK } from '@uts/sdk'
import { getSDK } from './useTimestampSDK'
@@ -27,7 +29,59 @@ export function useWebSocketFeed() {
let intervalId: ReturnType | null = null
let lastBlock: Record = {}
- async function fetchLatestEvents(sdk: SDK) {
+ async function fetchEventsFromWeb3(web3Provider: Eip1193Provider) {
+ try {
+ const provider = new BrowserProvider(web3Provider)
+ const network = await provider.getNetwork()
+ const chainId = Number(network.chainId)
+
+ const currentBlock = await provider.getBlockNumber()
+ const fromBlock = lastBlock[chainId]
+ ? lastBlock[chainId] + 1
+ : currentBlock - 10
+
+ if (fromBlock > currentBlock) return
+
+ const logs = await provider.getLogs({
+ fromBlock,
+ toBlock: currentBlock,
+ topics: [SDK.utsLogTopic],
+ })
+
+ for (const log of logs) {
+ const parsed = SDK.utsInterface.parseLog(log)
+ if (!parsed) continue
+
+ const root: string = parsed.args[0]
+ const sender: string = parsed.args[1]
+ const ts: bigint = parsed.args[2]
+
+ const entryId = `${chainId}-${log.blockNumber}-${log.index}`
+ if (!entries.value.some((e) => e.id === entryId)) {
+ entries.value.unshift({
+ id: entryId,
+ hash: root,
+ type: 'ethereum',
+ chain: CHAIN_NAMES[chainId] ?? `Chain ${chainId}`,
+ chainId,
+ blockHeight: log.blockNumber,
+ sender,
+ timestamp: Number(ts) * 1000,
+ })
+ }
+ }
+
+ lastBlock[chainId] = currentBlock
+
+ if (entries.value.length > 50) {
+ entries.value.length = 50
+ }
+ } catch (e) {
+ console.warn('Feed: failed to poll web3Provider:', e)
+ }
+ }
+
+ async function fetchEventsFromRPCs(sdk: SDK) {
const chainIds = Object.keys(sdk.ethRPCs).map(Number)
for (const chainId of chainIds) {
@@ -84,12 +138,21 @@ export function useWebSocketFeed() {
isConnected.value = true
lastBlock = {}
- // Initial fetch
- await fetchLatestEvents(sdk)
+ // Initial fetch: prefer web3Provider
+ if (sdk.web3Provider) {
+ await fetchEventsFromWeb3(sdk.web3Provider)
+ } else {
+ await fetchEventsFromRPCs(sdk)
+ }
- // Poll every 15s
+ // Poll every 15s — prefer web3Provider when available
intervalId = setInterval(() => {
- fetchLatestEvents(sdk)
+ const currentSdk = getSDK()
+ if (currentSdk.web3Provider) {
+ fetchEventsFromWeb3(currentSdk.web3Provider)
+ } else {
+ fetchEventsFromRPCs(currentSdk)
+ }
}, 15000)
}
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
index 5989e47..d89fbb1 100644
--- a/apps/web/src/stores/app.ts
+++ b/apps/web/src/stores/app.ts
@@ -44,7 +44,7 @@ export const useAppStore = defineStore('app', () => {
watch(calendarUrls, (urls) => {
saveCalendars(urls)
- resetSDK(urls.map((u) => new URL(u)))
+ resetSDK({ calendars: urls.map((u) => new URL(u)) })
}, { deep: true })
async function checkChains() {
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 7e1a044..9158a7e 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -1,18 +1,30 @@
@@ -108,6 +137,25 @@ function removeCalendar(index: number) {
{{ store.onlineCount }}/{{ store.ethChains.length }} chains
+
+
-
+
Attestations ({{ verifyAttestations.length }})
-
-
-
-
-
- Bitcoin block #{{ att.attestation.height }}
-
-
- Ethereum (chain {{ att.attestation.chain }}) block #{{ att.attestation.height }}
-
-
- Pending → {{ att.attestation.url }}
-
- Unknown attestation
-
-
-
+ :attestation="att"
+ />
diff --git a/apps/web/src/composables/useTimestampSDK.ts b/apps/web/src/composables/useTimestampSDK.ts
index dd72c58..5ccb58f 100644
--- a/apps/web/src/composables/useTimestampSDK.ts
+++ b/apps/web/src/composables/useTimestampSDK.ts
@@ -49,7 +49,7 @@ export function setWeb3Provider(provider: Eip1193Provider | null) {
sdk.web3Provider = provider
}
-function downloadOtsFile(stamp: DetachedTimestamp, fileName?: string) {
+export function downloadOtsFile(stamp: DetachedTimestamp, fileName?: string) {
const encoded = Encoder.encodeDetachedTimestamp(stamp)
const blob = new Blob([encoded as BlobPart], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
@@ -68,6 +68,7 @@ export function useTimestampSDK() {
const stampPhase = ref('idle')
const stampError = ref(null)
const stampResult = shallowRef(null)
+ const stampFileName = ref(undefined)
const broadcastProgress = ref('')
const upgradeResults = shallowRef(null)
@@ -82,6 +83,7 @@ export function useTimestampSDK() {
stampPhase.value = 'generating-nonce'
stampError.value = null
stampResult.value = null
+ stampFileName.value = fileName
broadcastProgress.value = ''
upgradeResults.value = null
@@ -115,11 +117,6 @@ export function useTimestampSDK() {
stampPhase.value = 'complete'
stampResult.value = results
- // Download the stamped .ots file
- for (const result of results) {
- downloadOtsFile(result, fileName)
- }
-
// Start polling for upgrade
startUpgradePolling(results)
@@ -131,7 +128,14 @@ export function useTimestampSDK() {
}
}
- function startUpgradePolling(stamps: DetachedTimestamp[]) {
+ function downloadPendingStamp() {
+ if (!stampResult.value) return
+ for (const result of stampResult.value) {
+ downloadOtsFile(result, stampFileName.value)
+ }
+ }
+
+ function startUpgradePolling(stamps: DetachedTimestamp[], keepPending?: boolean) {
stopUpgradePolling()
stampPhase.value = 'upgrading'
@@ -143,7 +147,7 @@ export function useTimestampSDK() {
try {
const allResults: UpgradeResult[] = []
for (const s of stamps) {
- const results = await sdk.upgrade(s)
+ const results = await sdk.upgrade(s, keepPending ?? false)
allResults.push(...results)
}
upgradeResults.value = allResults
@@ -151,9 +155,9 @@ export function useTimestampSDK() {
const hasUpgraded = allResults.some((r) => r.status === UpgradeStatus.Upgraded)
if (hasUpgraded) {
stampPhase.value = 'upgraded'
- // Download upgraded file
+ // Download upgraded file with original filename
for (const s of stamps) {
- downloadOtsFile(s)
+ downloadOtsFile(s, stampFileName.value)
}
stopUpgradePolling()
} else if (attempts >= maxAttempts) {
@@ -197,8 +201,9 @@ export function useTimestampSDK() {
async function upgrade(
detached: DetachedTimestamp,
+ keepPending?: boolean,
): Promise {
- return sdk.upgrade(detached)
+ return sdk.upgrade(detached, keepPending ?? false)
}
function decodeOtsFile(data: Uint8Array): DetachedTimestamp {
@@ -210,6 +215,7 @@ export function useTimestampSDK() {
stampPhase.value = 'idle'
stampError.value = null
stampResult.value = null
+ stampFileName.value = undefined
broadcastProgress.value = ''
upgradeResults.value = null
stopUpgradePolling()
@@ -226,9 +232,11 @@ export function useTimestampSDK() {
stampPhase,
stampError,
stampResult,
+ stampFileName,
broadcastProgress,
upgradeResults,
stamp,
+ downloadPendingStamp,
resetStamp,
verifyStatus,
diff --git a/apps/web/src/composables/useWebSocketFeed.ts b/apps/web/src/composables/useWebSocketFeed.ts
index bd582de..58e6cba 100644
--- a/apps/web/src/composables/useWebSocketFeed.ts
+++ b/apps/web/src/composables/useWebSocketFeed.ts
@@ -26,21 +26,34 @@ export interface FeedEntry {
export function useWebSocketFeed() {
const entries = ref([])
const isConnected = ref(false)
+ const seenIds = new Set()
let intervalId: ReturnType | null = null
- let lastBlock: Record = {}
+ let lastBlockWeb3: Record = {}
+ let lastBlockRPC: Record = {}
+
+ function addEntry(entry: FeedEntry) {
+ if (seenIds.has(entry.id)) return
+ seenIds.add(entry.id)
+ entries.value.unshift(entry)
+ if (entries.value.length > 50) {
+ const removed = entries.value.splice(50)
+ for (const r of removed) seenIds.delete(r.id)
+ }
+ }
- async function fetchEventsFromWeb3(web3Provider: Eip1193Provider) {
+ /** Poll web3Provider. Returns the wallet's chainId on success, or null. */
+ async function fetchEventsFromWeb3(web3Provider: Eip1193Provider): Promise {
try {
const provider = new BrowserProvider(web3Provider)
const network = await provider.getNetwork()
const chainId = Number(network.chainId)
const currentBlock = await provider.getBlockNumber()
- const fromBlock = lastBlock[chainId]
- ? lastBlock[chainId] + 1
+ const fromBlock = lastBlockWeb3[chainId]
+ ? lastBlockWeb3[chainId] + 1
: currentBlock - 10
- if (fromBlock > currentBlock) return
+ if (fromBlock > currentBlock) return chainId
const logs = await provider.getLogs({
fromBlock,
@@ -52,46 +65,40 @@ export function useWebSocketFeed() {
const parsed = SDK.utsInterface.parseLog(log)
if (!parsed) continue
- const root: string = parsed.args[0]
- const sender: string = parsed.args[1]
- const ts: bigint = parsed.args[2]
-
- const entryId = `${chainId}-${log.blockNumber}-${log.index}`
- if (!entries.value.some((e) => e.id === entryId)) {
- entries.value.unshift({
- id: entryId,
- hash: root,
- type: 'ethereum',
- chain: CHAIN_NAMES[chainId] ?? `Chain ${chainId}`,
- chainId,
- blockHeight: log.blockNumber,
- sender,
- timestamp: Number(ts) * 1000,
- })
- }
+ addEntry({
+ id: `${chainId}-${log.blockNumber}-${log.index}`,
+ hash: parsed.args[0],
+ type: 'ethereum',
+ chain: CHAIN_NAMES[chainId] ?? `Chain ${chainId}`,
+ chainId,
+ blockHeight: log.blockNumber,
+ sender: parsed.args[1],
+ timestamp: Number(parsed.args[2] as bigint) * 1000,
+ })
}
- lastBlock[chainId] = currentBlock
-
- if (entries.value.length > 50) {
- entries.value.length = 50
- }
+ lastBlockWeb3[chainId] = currentBlock
+ return chainId
} catch (e) {
console.warn('Feed: failed to poll web3Provider:', e)
+ return null
}
}
- async function fetchEventsFromRPCs(sdk: SDK) {
+ /** Poll ethRPCs, skipping chains already covered by web3Provider. */
+ async function fetchEventsFromRPCs(sdk: SDK, skipChainIds: Set = new Set()) {
const chainIds = Object.keys(sdk.ethRPCs).map(Number)
for (const chainId of chainIds) {
+ if (skipChainIds.has(chainId)) continue
+
const provider = sdk.getEthProvider(chainId)
if (!provider) continue
try {
const currentBlock = await provider.getBlockNumber()
- const fromBlock = lastBlock[chainId]
- ? lastBlock[chainId] + 1
+ const fromBlock = lastBlockRPC[chainId]
+ ? lastBlockRPC[chainId] + 1
: currentBlock - 5
if (fromBlock > currentBlock) continue
@@ -106,53 +113,52 @@ export function useWebSocketFeed() {
const parsed = SDK.utsInterface.parseLog(log)
if (!parsed) continue
- const root: string = parsed.args[0]
- const sender: string = parsed.args[1]
- const ts: bigint = parsed.args[2]
-
- entries.value.unshift({
+ addEntry({
id: `${chainId}-${log.blockNumber}-${log.index}`,
- hash: root,
+ hash: parsed.args[0],
type: 'ethereum',
chain: CHAIN_NAMES[chainId] ?? `Chain ${chainId}`,
chainId,
blockHeight: log.blockNumber,
- sender,
- timestamp: Number(ts) * 1000,
+ sender: parsed.args[1],
+ timestamp: Number(parsed.args[2] as bigint) * 1000,
})
}
- lastBlock[chainId] = currentBlock
-
- if (entries.value.length > 50) {
- entries.value.length = 50
- }
+ lastBlockRPC[chainId] = currentBlock
} catch (e) {
console.warn(`Feed: failed to poll chain ${chainId}:`, e)
}
}
}
- async function connect() {
+ /** Poll both web3Provider and ethRPCs, deduplicating by entry id. */
+ async function pollAll() {
const sdk = getSDK()
- isConnected.value = true
- lastBlock = {}
+ const skipChainIds = new Set()
- // Initial fetch: prefer web3Provider
+ // Poll web3Provider first (if available)
if (sdk.web3Provider) {
- await fetchEventsFromWeb3(sdk.web3Provider)
- } else {
- await fetchEventsFromRPCs(sdk)
+ const web3ChainId = await fetchEventsFromWeb3(sdk.web3Provider)
+ if (web3ChainId !== null) {
+ // Skip this chain in ethRPCs since web3Provider already covers it
+ skipChainIds.add(web3ChainId)
+ }
}
- // Poll every 15s — prefer web3Provider when available
+ // Also poll ethRPCs for all remaining chains
+ await fetchEventsFromRPCs(sdk, skipChainIds)
+ }
+
+ async function connect() {
+ isConnected.value = true
+ lastBlockWeb3 = {}
+ lastBlockRPC = {}
+
+ await pollAll()
+
intervalId = setInterval(() => {
- const currentSdk = getSDK()
- if (currentSdk.web3Provider) {
- fetchEventsFromWeb3(currentSdk.web3Provider)
- } else {
- fetchEventsFromRPCs(currentSdk)
- }
+ pollAll()
}, 15000)
}
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
index d89fbb1..f643a6b 100644
--- a/apps/web/src/stores/app.ts
+++ b/apps/web/src/stores/app.ts
@@ -20,6 +20,7 @@ export interface EthChainNode {
}
const STORAGE_KEY = 'uts-calendars'
+const SETTINGS_KEY = 'uts-settings'
function loadCalendars(): string[] {
try {
@@ -33,8 +34,21 @@ function saveCalendars(urls: string[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(urls))
}
+function loadSettings(): { keepPending: boolean } {
+ try {
+ const stored = localStorage.getItem(SETTINGS_KEY)
+ if (stored) return JSON.parse(stored)
+ } catch { /* ignore */ }
+ return { keepPending: false }
+}
+
+function saveSettings(settings: { keepPending: boolean }) {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
+}
+
export const useAppStore = defineStore('app', () => {
const calendarUrls = ref(loadCalendars())
+ const keepPending = ref(loadSettings().keepPending)
const ethChains = ref([])
const recentStamps = ref([])
@@ -47,6 +61,10 @@ export const useAppStore = defineStore('app', () => {
resetSDK({ calendars: urls.map((u) => new URL(u)) })
}, { deep: true })
+ watch(keepPending, (val) => {
+ saveSettings({ keepPending: val })
+ })
+
async function checkChains() {
const sdk = getSDK()
const chainIds = Object.keys(sdk.ethRPCs).map(Number)
@@ -92,6 +110,7 @@ export const useAppStore = defineStore('app', () => {
return {
calendarUrls,
+ keepPending,
ethChains,
recentStamps,
onlineCount,
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 9158a7e..0743ec4 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -13,7 +13,7 @@ import type { FileDigestResult } from '@/composables/useFileDigest'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
-const { stampPhase, stampError, broadcastProgress, stamp, resetStamp } = useTimestampSDK()
+const { stampPhase, stampError, broadcastProgress, stamp, downloadPendingStamp } = useTimestampSDK()
const {
walletAddress,
walletChainName,
@@ -26,11 +26,16 @@ const {
truncateAddress,
} = useWallet()
-const activeTab = ref<'stamp' | 'verify'>('stamp')
+const savedTab = localStorage.getItem('uts-active-tab') as 'stamp' | 'verify' | null
+const activeTab = ref<'stamp' | 'verify'>(savedTab === 'verify' ? 'verify' : 'stamp')
const showWorkflow = ref(false)
const showSettings = ref(false)
const newCalendarUrl = ref('')
+watch(activeTab, (tab) => {
+ localStorage.setItem('uts-active-tab', tab)
+})
+
onMounted(() => {
store.checkChains()
})
@@ -68,11 +73,6 @@ async function handleStampFromHash(hash: string) {
}
}
-function handleResetWorkflow() {
- resetStamp()
- showWorkflow.value = false
-}
-
function addCalendar() {
const url = newCalendarUrl.value.trim()
if (!url) return
@@ -211,6 +211,21 @@ function handleWalletClick() {
+
+
+
+
+
@@ -237,7 +252,7 @@ function handleWalletClick() {
? 'bg-neon-cyan/10 text-neon-cyan'
: 'text-white/40 hover:text-white/60'
"
- @click="activeTab = 'stamp'; handleResetWorkflow()"
+ @click="activeTab = 'stamp'"
>
Stamp
@@ -260,29 +275,26 @@ function handleWalletClick() {
diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts
index 5190416..54975b0 100644
--- a/packages/sdk/src/sdk.ts
+++ b/packages/sdk/src/sdk.ts
@@ -384,12 +384,14 @@ export default class SDK {
/**
* Perform in-place upgrade of the provided detached timestamp by replacing any pending attestations with their upgraded timestamp steps, if they have become available.
* @param detached The detached timestamp to be upgraded.
+ * @param keepPending Whether to keep the original pending attestation alongside the upgraded one. Default is false (purge pending on success).
* @returns The result of the upgrade operation, including the original and upgraded timestamps if applicable.
*/
- async upgrade(detached: DetachedTimestamp): Promise
{
+ async upgrade(detached: DetachedTimestamp, keepPending: boolean = false): Promise {
return this.upgradeTimestamp(
getBytes(detached.header.digest),
detached.timestamp,
+ keepPending,
)
}
@@ -398,11 +400,13 @@ export default class SDK {
* This function will recursively traverse the timestamp steps and perform in-place upgrades of any pending attestations encountered.
* @param input The original digest input associated with the timestamp, which is needed to verify and upgrade the pending attestations.
* @param timestamp The timestamp steps to be upgraded, which may contain pending attestations that need to be replaced with their upgraded timestamp steps if they have become available.
+ * @param keepPending Whether to keep the original pending attestation alongside the upgraded one. Default is false (purge pending on success).
* @returns The result of the upgrade operation, including the original and upgraded timestamps if applicable.
*/
async upgradeTimestamp(
input: Uint8Array,
timestamp: Timestamp,
+ keepPending: boolean = false,
): Promise {
let current = input
@@ -426,7 +430,7 @@ export default class SDK {
// upgrade sub stamps
const results = (
await Promise.all(
- step.steps.map((branch) => this.upgradeTimestamp(input, branch)),
+ step.steps.map((branch) => this.upgradeTimestamp(input, branch, keepPending)),
)
).flat()
result.push(...results)
@@ -447,10 +451,15 @@ export default class SDK {
})
continue
}
- // preserve the original attestation in the upgraded timestamp for transparency
- timestamp[i] = {
- op: 'FORK',
- steps: [[step], upgraded],
+ if (keepPending) {
+ // preserve the original attestation in the upgraded timestamp for transparency
+ timestamp[i] = {
+ op: 'FORK',
+ steps: [[step], upgraded],
+ }
+ } else {
+ // replace the pending attestation with the upgraded one
+ timestamp.splice(i, 1, ...upgraded)
}
result.push({
status: UpgradeStatus.Upgraded,
From 542478afe53455c395375828cf9d6fcb3f001eff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 07:24:56 +0000
Subject: [PATCH 10/21] fix: correct Scroll chain IDs, delay hashing, wider
feed, default keccak, directory stamp
SDK: Fix Scroll chain IDs from 54351/54352 to 534351/534352 with correct
hex values 0x8274f/0x82750 matching the Rust CLI and actual chain IDs.
Web: Delay file hashing until Stamp button is pressed (not on file select).
Add "Choose Directory" button for batch stamping entire directories.
Batch stamps download all .ots files as a zip archive.
Default file hash algorithm changed to Keccak-256.
Add internal hash algorithm setting (for Merkle tree construction).
Widen Live Feed panel from 1/3 to 2/5 grid for more horizontal space.
All settings persisted to localStorage.
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/package.json | 2 +
apps/web/src/components/feed/LiveFeed.vue | 4 +-
.../src/components/terminal/HeroTerminal.vue | 127 +++++++++++++-----
.../components/verify/AttestationDetail.vue | 8 +-
apps/web/src/composables/useWallet.ts | 4 +-
apps/web/src/composables/useWebSocketFeed.ts | 4 +-
apps/web/src/stores/app.ts | 28 ++--
apps/web/src/views/HomeView.vue | 64 +++++++--
packages/sdk/src/sdk.ts | 8 +-
pnpm-lock.yaml | 96 +++++++++++++
10 files changed, 278 insertions(+), 67 deletions(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index de618f2..1d6f953 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -16,12 +16,14 @@
"@vueuse/core": "^14.2.1",
"date-fns": "^4.1.0",
"ethers": "^6.16.0",
+ "jszip": "^3.10.1",
"lucide-vue-next": "^0.575.0",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
+ "@types/jszip": "^3.4.1",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
diff --git a/apps/web/src/components/feed/LiveFeed.vue b/apps/web/src/components/feed/LiveFeed.vue
index e43613c..f4708bc 100644
--- a/apps/web/src/components/feed/LiveFeed.vue
+++ b/apps/web/src/components/feed/LiveFeed.vue
@@ -29,8 +29,8 @@ const ETHERSCAN_URLS: Record = {
1: 'https://etherscan.io',
17000: 'https://holesky.etherscan.io',
11155111: 'https://sepolia.etherscan.io',
- 54352: 'https://scrollscan.com',
- 54351: 'https://sepolia.scrollscan.com',
+ 534352: 'https://scrollscan.com',
+ 534351: 'https://sepolia.scrollscan.com',
}
function getBlockUrl(chainId: number, height: number): string | null {
diff --git a/apps/web/src/components/terminal/HeroTerminal.vue b/apps/web/src/components/terminal/HeroTerminal.vue
index c0a59ba..4a88a3e 100644
--- a/apps/web/src/components/terminal/HeroTerminal.vue
+++ b/apps/web/src/components/terminal/HeroTerminal.vue
@@ -1,21 +1,22 @@
@@ -86,8 +120,8 @@ function formatSize(bytes: number): string {
v-model="selectedAlgorithm"
class="rounded border border-glass-border bg-surface px-2 py-0.5 font-mono text-xs text-neon-cyan outline-none focus:border-neon-cyan/40"
>
-
+
@@ -99,8 +133,8 @@ function formatSize(bytes: number): string {
class="relative rounded-lg border-2 border-dashed p-8 text-center transition-all duration-300"
:class="{
'border-neon-cyan/50 bg-neon-cyan/5': isOverDropZone,
- 'border-glass-border hover:border-white/20': !isOverDropZone && !hasResult,
- 'border-valid/30 bg-valid/5': hasResult,
+ 'border-glass-border hover:border-white/20': !isOverDropZone && !hasFiles && !isDigesting && !hasResult,
+ 'border-valid/30 bg-valid/5': hasFiles || hasResult,
}"
>
@@ -118,11 +152,13 @@ function formatSize(bytes: number): string {
{{ progress.toFixed(0) }}%
-
-
+
+
-
- File Digested
+
+
+ {{ selectedFiles.length }} file{{ selectedFiles.length > 1 ? 's' : '' }} selected
+
-
- {{ digestResult.fileName }} ({{ formatSize(digestResult.fileSize) }})
-
-
- {{ digestResult.digest }}
+
+
+ {{ file.name }} ({{ formatSize(file.size) }})
+
+
+ ... and {{ selectedFiles.length - 5 }} more
+
-
- {{ digestResult.algorithm }}
+
+ Total: {{ formatSize(totalSize()) }} · Hash on stamp with {{ selectedAlgorithm }}
@@ -148,22 +188,37 @@ function formatSize(bytes: number): string {
- Drop a file here to compute its digest
+ Drop files here
or
-
-
- Choose File
-
+
+
+
+ Choose File
+
+
+
+ Choose Directory
+
+
+
+
@@ -183,12 +238,12 @@ function formatSize(bytes: number): string {
type="text"
placeholder="0x..."
class="flex-1 rounded-lg border border-glass-border bg-surface px-4 py-2.5 font-mono text-sm text-white/80 outline-none transition placeholder:text-white/20 focus:border-neon-cyan/40"
- :disabled="hasResult"
+ :disabled="hasFiles"
@keyup.enter="handleSubmit"
/>
diff --git a/apps/web/src/components/verify/AttestationDetail.vue b/apps/web/src/components/verify/AttestationDetail.vue
index b51836a..8ab22d4 100644
--- a/apps/web/src/components/verify/AttestationDetail.vue
+++ b/apps/web/src/components/verify/AttestationDetail.vue
@@ -17,16 +17,16 @@ const ETHERSCAN_URLS: Record = {
1: 'https://etherscan.io',
17000: 'https://holesky.etherscan.io',
11155111: 'https://sepolia.etherscan.io',
- 54352: 'https://scrollscan.com',
- 54351: 'https://sepolia.scrollscan.com',
+ 534352: 'https://scrollscan.com',
+ 534351: 'https://sepolia.scrollscan.com',
}
const CHAIN_NAMES: Record = {
1: 'Ethereum',
17000: 'Holesky',
11155111: 'Sepolia',
- 54352: 'Scroll',
- 54351: 'Scroll Sepolia',
+ 534352: 'Scroll',
+ 534351: 'Scroll Sepolia',
}
function statusLabel(status: string): 'valid' | 'invalid' | 'pending' | 'unknown' {
diff --git a/apps/web/src/composables/useWallet.ts b/apps/web/src/composables/useWallet.ts
index a392fa8..d076207 100644
--- a/apps/web/src/composables/useWallet.ts
+++ b/apps/web/src/composables/useWallet.ts
@@ -15,8 +15,8 @@ const CHAIN_NAMES: Record = {
1: 'Ethereum',
17000: 'Holesky',
11155111: 'Sepolia',
- 54352: 'Scroll',
- 54351: 'Scroll Sepolia',
+ 534352: 'Scroll',
+ 534351: 'Scroll Sepolia',
}
const walletChainName = computed(() => {
diff --git a/apps/web/src/composables/useWebSocketFeed.ts b/apps/web/src/composables/useWebSocketFeed.ts
index 58e6cba..c627f9a 100644
--- a/apps/web/src/composables/useWebSocketFeed.ts
+++ b/apps/web/src/composables/useWebSocketFeed.ts
@@ -8,8 +8,8 @@ const CHAIN_NAMES: Record = {
1: 'Ethereum',
17000: 'Holesky',
11155111: 'Sepolia',
- 54352: 'Scroll',
- 54351: 'Scroll Sepolia',
+ 534352: 'Scroll',
+ 534351: 'Scroll Sepolia',
}
export interface FeedEntry {
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
index f643a6b..869a499 100644
--- a/apps/web/src/stores/app.ts
+++ b/apps/web/src/stores/app.ts
@@ -1,15 +1,15 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { DEFAULT_CALENDARS } from '@uts/sdk'
-import type { DetachedTimestamp } from '@uts/sdk'
+import type { DetachedTimestamp, SecureDigestOp } from '@uts/sdk'
import { getSDK, resetSDK } from '@/composables/useTimestampSDK'
const CHAIN_NAMES: Record = {
1: 'Ethereum',
17000: 'Holesky',
11155111: 'Sepolia',
- 54352: 'Scroll',
- 54351: 'Scroll Sepolia',
+ 534352: 'Scroll',
+ 534351: 'Scroll Sepolia',
}
export interface EthChainNode {
@@ -34,21 +34,28 @@ function saveCalendars(urls: string[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(urls))
}
-function loadSettings(): { keepPending: boolean } {
+function loadSettings(): { keepPending: boolean; internalHashAlgo: SecureDigestOp } {
try {
const stored = localStorage.getItem(SETTINGS_KEY)
- if (stored) return JSON.parse(stored)
+ if (stored) {
+ const parsed = JSON.parse(stored)
+ return {
+ keepPending: parsed.keepPending ?? false,
+ internalHashAlgo: parsed.internalHashAlgo ?? 'KECCAK256',
+ }
+ }
} catch { /* ignore */ }
- return { keepPending: false }
+ return { keepPending: false, internalHashAlgo: 'KECCAK256' }
}
-function saveSettings(settings: { keepPending: boolean }) {
+function saveSettings(settings: { keepPending: boolean; internalHashAlgo: SecureDigestOp }) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
}
export const useAppStore = defineStore('app', () => {
const calendarUrls = ref(loadCalendars())
const keepPending = ref(loadSettings().keepPending)
+ const internalHashAlgo = ref(loadSettings().internalHashAlgo)
const ethChains = ref([])
const recentStamps = ref([])
@@ -62,7 +69,11 @@ export const useAppStore = defineStore('app', () => {
}, { deep: true })
watch(keepPending, (val) => {
- saveSettings({ keepPending: val })
+ saveSettings({ keepPending: val, internalHashAlgo: internalHashAlgo.value })
+ })
+
+ watch(internalHashAlgo, (val) => {
+ saveSettings({ keepPending: keepPending.value, internalHashAlgo: val })
})
async function checkChains() {
@@ -111,6 +122,7 @@ export const useAppStore = defineStore('app', () => {
return {
calendarUrls,
keepPending,
+ internalHashAlgo,
ethChains,
recentStamps,
onlineCount,
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 0743ec4..9108938 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -7,10 +7,12 @@ import VerificationResult from '@/components/verify/VerificationResult.vue'
import LiveFeed from '@/components/feed/LiveFeed.vue'
import GlassCard from '@/components/base/GlassCard.vue'
import BaseButton from '@/components/base/BaseButton.vue'
-import { useTimestampSDK, setWeb3Provider } from '@/composables/useTimestampSDK'
+import { useTimestampSDK, setWeb3Provider, getSDK } from '@/composables/useTimestampSDK'
import { useWallet } from '@/composables/useWallet'
import type { FileDigestResult } from '@/composables/useFileDigest'
+import { Encoder } from '@uts/sdk'
import { useAppStore } from '@/stores/app'
+import JSZip from 'jszip'
const store = useAppStore()
const { stampPhase, stampError, broadcastProgress, stamp, downloadPendingStamp } = useTimestampSDK()
@@ -49,11 +51,38 @@ watch(walletConnected, (connected) => {
}
})
-async function handleStampFromDigest(digest: FileDigestResult) {
+async function handleStampFromDigest(digests: FileDigestResult[]) {
showWorkflow.value = true
+
+ // Apply internal hash algorithm setting
+ getSDK().hashAlgorithm = store.internalHashAlgo
+
+ const isBatch = digests.length > 1
+ const firstFileName = digests[0]?.fileName
+
try {
- const results = await stamp([digest.header], digest.fileName)
+ const headers = digests.map((d) => d.header)
+ const results = await stamp(headers, firstFileName)
for (const r of results) store.addStamp(r)
+
+ // For batch (directory): download all as zip
+ if (isBatch && results.length > 0) {
+ const zip = new JSZip()
+ for (let i = 0; i < results.length; i++) {
+ const fileName = digests[i]?.fileName ?? `file-${i}`
+ const encoded = Encoder.encodeDetachedTimestamp(results[i]!)
+ zip.file(`${fileName}.ots`, encoded)
+ }
+ const blob = await zip.generateAsync({ type: 'blob' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'timestamps.zip'
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
} catch {
// error is tracked in stampError
}
@@ -61,6 +90,10 @@ async function handleStampFromDigest(digest: FileDigestResult) {
async function handleStampFromHash(hash: string) {
showWorkflow.value = true
+
+ // Apply internal hash algorithm setting
+ getSDK().hashAlgorithm = store.internalHashAlgo
+
const digest = hash.startsWith('0x') ? hash : `0x${hash}`
const bytes = new Uint8Array(
(digest.slice(2).match(/.{2}/g) ?? []).map((b) => parseInt(b, 16)),
@@ -213,7 +246,7 @@ function handleWalletClick() {
-
+
+
+
+
+
+ Used for Merkle tree construction
+
@@ -272,9 +318,9 @@ function handleWalletClick() {
-
-
-
+
+
+
-
-
diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts
index 54975b0..30485f2 100644
--- a/packages/sdk/src/sdk.ts
+++ b/packages/sdk/src/sdk.ts
@@ -63,8 +63,8 @@ export const WELL_KNOWN_CHAINS: Record
= 8'}
@@ -1457,6 +1470,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1469,6 +1485,9 @@ packages:
resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
engines: {node: '>=12'}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
is-builtin-module@5.0.0:
resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==}
engines: {node: '>=18.20'}
@@ -1495,6 +1514,9 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1520,6 +1542,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1527,6 +1552,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
@@ -1678,6 +1706,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1762,10 +1793,16 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
@@ -1804,11 +1841,17 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1842,6 +1885,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
strip-indent@4.1.1:
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
engines: {node: '>=12'}
@@ -1931,6 +1977,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
vite@7.3.0:
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2525,6 +2574,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/jszip@3.4.1':
+ dependencies:
+ jszip: 3.10.1
+
'@types/node@22.7.5':
dependencies:
undici-types: 6.19.8
@@ -2965,6 +3018,8 @@ snapshots:
dependencies:
browserslist: 4.28.1
+ core-util-is@1.0.3: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3255,6 +3310,8 @@ snapshots:
ignore@7.0.5: {}
+ immediate@3.0.6: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -3264,6 +3321,8 @@ snapshots:
indent-string@5.0.0: {}
+ inherits@2.0.4: {}
+
is-builtin-module@5.0.0:
dependencies:
builtin-modules: 5.0.0
@@ -3286,6 +3345,8 @@ snapshots:
is-what@5.5.0: {}
+ isarray@1.0.0: {}
+
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -3302,6 +3363,13 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -3311,6 +3379,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
lightningcss-android-arm64@1.31.1:
optional: true
@@ -3424,6 +3496,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -3488,8 +3562,20 @@ snapshots:
prettier@3.8.1: {}
+ process-nextick-args@2.0.1: {}
+
punycode@2.3.1: {}
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
regexp-tree@0.1.27: {}
regjsparser@0.13.0:
@@ -3572,8 +3658,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
+ safe-buffer@5.1.2: {}
+
semver@7.7.4: {}
+ setimmediate@1.0.5: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -3599,6 +3689,10 @@ snapshots:
std-env@3.10.0: {}
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
strip-indent@4.1.1: {}
strip-json-comments@3.1.1: {}
@@ -3692,6 +3786,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ util-deprecate@1.0.2: {}
+
vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.2
From 521d32b7ea0e111e5ea9818d342df04c5ce7615e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 07:39:46 +0000
Subject: [PATCH 11/21] fix: batch stamp download, correct filenames with dir
structure, download button in Complete step
- Don't auto-download pending timestamps on stamp completion
- Manual "Download pending .ots" button inside the Complete step only
(hidden once phase moves to upgrading/upgraded)
- Batch stamps download as zip preserving directory structure
(webkitRelativePath used for path-aware filenames)
- Each .ots in zip named after its input file (e.g. mydir/file.txt.ots)
- Upgraded timestamps also download as zip for batch with correct names
- Single file stamps download as individual .ots (same behavior as before)
- Track all file names in stampFileNames array, not single stampFileName
- Remove JSZip/Encoder imports from HomeView (moved to composable)
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
.../src/components/stamp/StampingWorkflow.vue | 22 +++----
apps/web/src/composables/useFileDigest.ts | 2 +-
apps/web/src/composables/useTimestampSDK.ts | 57 +++++++++++++------
apps/web/src/views/HomeView.vue | 26 +--------
4 files changed, 52 insertions(+), 55 deletions(-)
diff --git a/apps/web/src/components/stamp/StampingWorkflow.vue b/apps/web/src/components/stamp/StampingWorkflow.vue
index a5387d1..f767c4b 100644
--- a/apps/web/src/components/stamp/StampingWorkflow.vue
+++ b/apps/web/src/components/stamp/StampingWorkflow.vue
@@ -55,12 +55,6 @@ const phaseOrder: StampPhase[] = [
const currentIndex = computed(() => phaseOrder.indexOf(props.phase))
-const showDownloadButton = computed(() => {
- const ci = currentIndex.value
- const completeIndex = phaseOrder.indexOf('complete')
- return ci >= completeIndex && props.phase !== 'error'
-})
-
function getStepStatus(stepId: StampPhase) {
if (props.phase === 'error') return 'error'
const stepIndex = phaseOrder.indexOf(stepId)
@@ -140,6 +134,14 @@ function getStepDescription(step: WorkflowStep): string {
{{ step.label }}
{{ getStepDescription(step) }}
+
+
+
+
+
+ Download pending .ots
+
+
@@ -150,14 +152,6 @@ function getStepDescription(step: WorkflowStep): string {
-
-
-
-
- Download .ots
-
-
-
('idle')
const stampError = ref
(null)
const stampResult = shallowRef(null)
- const stampFileName = ref(undefined)
+ const stampFileNames = ref([])
const broadcastProgress = ref('')
const upgradeResults = shallowRef(null)
@@ -79,11 +96,11 @@ export function useTimestampSDK() {
let upgradeTimer: ReturnType | null = null
- async function stamp(digests: DigestHeader[], fileName?: string): Promise {
+ async function stamp(digests: DigestHeader[], fileNames?: string[]): Promise {
stampPhase.value = 'generating-nonce'
stampError.value = null
stampResult.value = null
- stampFileName.value = fileName
+ stampFileNames.value = fileNames ?? []
broadcastProgress.value = ''
upgradeResults.value = null
@@ -117,7 +134,7 @@ export function useTimestampSDK() {
stampPhase.value = 'complete'
stampResult.value = results
- // Start polling for upgrade
+ // Start polling for upgrade (no auto-download — user uses the download button)
startUpgradePolling(results)
return results
@@ -128,10 +145,15 @@ export function useTimestampSDK() {
}
}
- function downloadPendingStamp() {
+ async function downloadPendingStamp() {
if (!stampResult.value) return
- for (const result of stampResult.value) {
- downloadOtsFile(result, stampFileName.value)
+ const results = stampResult.value
+ const names = stampFileNames.value
+
+ if (results.length === 1) {
+ downloadOtsFile(results[0]!, names[0])
+ } else if (results.length > 1) {
+ await downloadStampsAsZip(results, names)
}
}
@@ -155,9 +177,12 @@ export function useTimestampSDK() {
const hasUpgraded = allResults.some((r) => r.status === UpgradeStatus.Upgraded)
if (hasUpgraded) {
stampPhase.value = 'upgraded'
- // Download upgraded file with original filename
- for (const s of stamps) {
- downloadOtsFile(s, stampFileName.value)
+ // Auto-download upgraded timestamps with correct per-file names
+ const names = stampFileNames.value
+ if (stamps.length === 1) {
+ downloadOtsFile(stamps[0]!, names[0])
+ } else if (stamps.length > 1) {
+ await downloadStampsAsZip(stamps, names)
}
stopUpgradePolling()
} else if (attempts >= maxAttempts) {
@@ -215,7 +240,7 @@ export function useTimestampSDK() {
stampPhase.value = 'idle'
stampError.value = null
stampResult.value = null
- stampFileName.value = undefined
+ stampFileNames.value = []
broadcastProgress.value = ''
upgradeResults.value = null
stopUpgradePolling()
@@ -232,7 +257,7 @@ export function useTimestampSDK() {
stampPhase,
stampError,
stampResult,
- stampFileName,
+ stampFileNames,
broadcastProgress,
upgradeResults,
stamp,
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 9108938..0ec95bf 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -10,9 +10,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import { useTimestampSDK, setWeb3Provider, getSDK } from '@/composables/useTimestampSDK'
import { useWallet } from '@/composables/useWallet'
import type { FileDigestResult } from '@/composables/useFileDigest'
-import { Encoder } from '@uts/sdk'
import { useAppStore } from '@/stores/app'
-import JSZip from 'jszip'
const store = useAppStore()
const { stampPhase, stampError, broadcastProgress, stamp, downloadPendingStamp } = useTimestampSDK()
@@ -57,32 +55,12 @@ async function handleStampFromDigest(digests: FileDigestResult[]) {
// Apply internal hash algorithm setting
getSDK().hashAlgorithm = store.internalHashAlgo
- const isBatch = digests.length > 1
- const firstFileName = digests[0]?.fileName
+ const fileNames = digests.map((d) => d.fileName)
try {
const headers = digests.map((d) => d.header)
- const results = await stamp(headers, firstFileName)
+ const results = await stamp(headers, fileNames)
for (const r of results) store.addStamp(r)
-
- // For batch (directory): download all as zip
- if (isBatch && results.length > 0) {
- const zip = new JSZip()
- for (let i = 0; i < results.length; i++) {
- const fileName = digests[i]?.fileName ?? `file-${i}`
- const encoded = Encoder.encodeDetachedTimestamp(results[i]!)
- zip.file(`${fileName}.ots`, encoded)
- }
- const blob = await zip.generateAsync({ type: 'blob' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'timestamps.zip'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
} catch {
// error is tracked in stampError
}
From 319edf878f3bfe302f0e25c85ae4ce00aab113c2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 08:05:29 +0000
Subject: [PATCH 12/21] feat: download button fix, fake progress, verify
original file, attestation details in proof path, upgrade tab, Scroll logo,
testnet warnings, clickable chain panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
StampingWorkflow: Download pending .ots button visible during both
'complete' and 'upgrading' phases (hidden once upgraded or idle).
useFileDigest: Fake progress bar estimates time based on file size
(~200 MB/s) instead of real streaming progress. Added digestFiles()
batch method.
VerificationResult: Optional original file upload to verify digest
matches the .ots header. Shows match/mismatch status with icons.
MerkleTreeViz: ATTESTATION steps now collapsible with full details
(type, chain, block height, contract, tx hash with etherscan links).
AttestationDetail/MerkleTreeViz/LiveFeed: Warning flag (⚠) for
testnet or unknown network attestations. Mainnet = Ethereum (1) and
Scroll (534352). All others show orange warning banner.
LiveFeed: Added txHash to feed entries from log.transactionHash.
Scroll logo shown for Scroll chain entries.
AttestationDetail: Scroll logo in header and expanded type row.
UpgradePanel: New tab for manual .ots upgrade with verify, upgrade
now button, and download upgraded result.
HomeView: Clickable "X/Y chains" in header opens dropdown showing
each chain with name, ID, status, latency, Scroll logo. Add custom
chain by ID, remove individual chains, reset to defaults. Persisted
to localStorage.
stores/app: addChain/removeChain/resetChains with localStorage
persistence. Public RPC fallbacks for common chains.
Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com>
---
apps/web/src/assets/Scroll_Logomark.svg | 5 +
apps/web/src/components/feed/LiveFeed.vue | 57 ++++-
.../src/components/stamp/StampingWorkflow.vue | 4 +-
.../src/components/upgrade/UpgradePanel.vue | 179 ++++++++++++++++
.../components/verify/AttestationDetail.vue | 40 +++-
.../src/components/verify/MerkleTreeViz.vue | 198 +++++++++++++++++-
.../components/verify/VerificationResult.vue | 69 +++++-
apps/web/src/composables/useFileDigest.ts | 113 ++++++++--
apps/web/src/composables/useWebSocketFeed.ts | 3 +
apps/web/src/stores/app.ts | 66 +++++-
apps/web/src/views/HomeView.vue | 170 +++++++++++++--
11 files changed, 841 insertions(+), 63 deletions(-)
create mode 100644 apps/web/src/assets/Scroll_Logomark.svg
create mode 100644 apps/web/src/components/upgrade/UpgradePanel.vue
diff --git a/apps/web/src/assets/Scroll_Logomark.svg b/apps/web/src/assets/Scroll_Logomark.svg
new file mode 100644
index 0000000..010b7bb
--- /dev/null
+++ b/apps/web/src/assets/Scroll_Logomark.svg
@@ -0,0 +1,5 @@
+
diff --git a/apps/web/src/components/feed/LiveFeed.vue b/apps/web/src/components/feed/LiveFeed.vue
index f4708bc..146846f 100644
--- a/apps/web/src/components/feed/LiveFeed.vue
+++ b/apps/web/src/components/feed/LiveFeed.vue
@@ -1,9 +1,12 @@
@@ -76,7 +91,8 @@ function getAddressUrl(chainId: number, address: string): string | null {
class="flex w-full items-center gap-3 px-3 py-2 text-left"
@click="toggleEntry(entry.id)"
>
-
+
+
{{ truncate(entry.hash) }}
@@ -84,6 +100,11 @@ function getAddressUrl(chainId: number, address: string): string | null {
{{ entry.chain }}
#{{ entry.blockHeight }}
+
@@ -104,7 +125,14 @@ function getAddressUrl(chainId: number, address: string): string | null {
Chain
-
{{ entry.chain }} ({{ entry.chainId }})
+
+ {{ entry.chain }} ({{ entry.chainId }})
+
+
Block
@@ -136,6 +164,29 @@ function getAddressUrl(chainId: number, address: string): string | null {
+
+
Tx Hash
+
+ {{ truncate(entry.txHash) }}
+
+
+
+
+
+
+
+
+
{{ ETHERSCAN_URLS[entry.chainId] ? 'Testnet attestation — not suitable for production use' : 'Unknown network — cannot verify on-chain' }}
+
diff --git a/apps/web/src/components/stamp/StampingWorkflow.vue b/apps/web/src/components/stamp/StampingWorkflow.vue
index f767c4b..ad5374c 100644
--- a/apps/web/src/components/stamp/StampingWorkflow.vue
+++ b/apps/web/src/components/stamp/StampingWorkflow.vue
@@ -135,8 +135,8 @@ function getStepDescription(step: WorkflowStep): string {
{{ getStepDescription(step) }}
-
-
+
+
Download pending .ots
diff --git a/apps/web/src/components/upgrade/UpgradePanel.vue b/apps/web/src/components/upgrade/UpgradePanel.vue
new file mode 100644
index 0000000..8391139
--- /dev/null
+++ b/apps/web/src/components/upgrade/UpgradePanel.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
Manual Upgrade
+
+
+
+
+
+ > Upload a pending .ots file to upgrade it with on-chain attestations
+
+
+
+ Upload .ots
+
+
+
+
+
+
+
+
+
+ Digest ({{ loadedTimestamp.header.kind }})
+
+
+ {{ hexlify(loadedTimestamp.header.digest as Uint8Array) }}
+
+
+
+
+
+
+ Current Attestations ({{ verifyAttestations.length }})
+
+
+
+
+
+
+
+ Upgrade Results
+
+
+
+
+
+
+ {{ result.original.url }} — {{ result.status }}
+
+
+
+
+
+
+
+
+ {{ isUpgrading ? 'Upgrading...' : 'Upgrade Now' }}
+
+
+
+
+ Download Upgraded .ots
+
+
+
+ Reset
+
+
+
+
+
+ > {{ upgradeError }}
+
+
+
+
diff --git a/apps/web/src/components/verify/AttestationDetail.vue b/apps/web/src/components/verify/AttestationDetail.vue
index 8ab22d4..df2b3e3 100644
--- a/apps/web/src/components/verify/AttestationDetail.vue
+++ b/apps/web/src/components/verify/AttestationDetail.vue
@@ -1,10 +1,13 @@
@@ -112,6 +139,44 @@ function handleReset() {
+
+
+
+ Verify Original File (Optional)
+
+
+ Upload the original file to verify it matches the .ots header digest
+
+
+
+
+ {{ isDigestingOriginal ? 'Digesting...' : 'Upload Original' }}
+
+
+ {{ originalFileName }}
+
+
+
+
+
+
+
+
+ File digest matches — this is the original file
+
+
+
+ File digest does NOT match the .ots header
+
+
+
diff --git a/apps/web/src/composables/useFileDigest.ts b/apps/web/src/composables/useFileDigest.ts
index 6271da5..ec401be 100644
--- a/apps/web/src/composables/useFileDigest.ts
+++ b/apps/web/src/composables/useFileDigest.ts
@@ -12,7 +12,11 @@ export interface FileDigestResult {
header: DigestHeader
}
-const CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming hash
+// Estimated browser hashing throughput (~200 MB/s), used for fake progress
+const ESTIMATED_THROUGHPUT = 200 * 1024 * 1024
+const MIN_DURATION_MS = 300
+const MAX_DURATION_MS = 30000
+const PROGRESS_CAP = 92 // fake progress caps at this before real completion
export function useFileDigest() {
const isDigesting = ref(false)
@@ -20,6 +24,31 @@ export function useFileDigest() {
const error = ref
(null)
const result = ref(null)
+ let progressTimer: ReturnType | null = null
+
+ function startFakeProgress(totalBytes: number, fileCount: number) {
+ progress.value = 0
+ const estimatedMs = Math.max(
+ MIN_DURATION_MS,
+ Math.min(((totalBytes * fileCount) / ESTIMATED_THROUGHPUT) * 1000, MAX_DURATION_MS),
+ )
+ const intervalMs = 50
+ const steps = estimatedMs / intervalMs
+ const increment = PROGRESS_CAP / steps
+
+ progressTimer = setInterval(() => {
+ progress.value = Math.min(progress.value + increment, PROGRESS_CAP)
+ }, intervalMs)
+ }
+
+ function stopFakeProgress() {
+ if (progressTimer) {
+ clearInterval(progressTimer)
+ progressTimer = null
+ }
+ progress.value = 100
+ }
+
async function digestFile(
file: File,
algorithm: SecureDigestOp = 'SHA256',
@@ -29,39 +58,22 @@ export function useFileDigest() {
error.value = null
result.value = null
+ startFakeProgress(file.size, 1)
+
try {
const factory = algorithm === 'KECCAK256' ? keccak_256 : sha256
const hasher = factory.create()
- const totalSize = file.size
- let processed = 0
const reader = file.stream().getReader()
-
while (true) {
const { done, value } = await reader.read()
if (done) break
-
- // Process in sub-chunks for progress reporting
- let offset = 0
- while (offset < value.length) {
- const end = Math.min(offset + CHUNK_SIZE, value.length)
- hasher.update(value.subarray(offset, end))
- offset = end
- processed += end - offset || (end - (offset - (end - offset)))
- }
- processed = Math.min(
- processed,
- totalSize,
- )
- // Recalculate based on actual bytes seen
- progress.value = totalSize > 0 ? Math.min((processed / totalSize) * 100, 100) : 100
-
- // Yield to main thread periodically
+ hasher.update(value)
+ // Yield to main thread periodically for UI updates
await new Promise((resolve) => setTimeout(resolve, 0))
}
- // Fix progress tracking: just use processed bytes
- progress.value = 100
+ stopFakeProgress()
const digestBytes = hasher.digest()
const digestHex = hexlify(digestBytes)
@@ -80,6 +92,7 @@ export function useFileDigest() {
result.value = digestResult
return digestResult
} catch (e) {
+ stopFakeProgress()
const msg = e instanceof Error ? e.message : 'Failed to digest file'
error.value = msg
throw new Error(msg)
@@ -88,7 +101,60 @@ export function useFileDigest() {
}
}
+ async function digestFiles(
+ files: File[],
+ algorithm: SecureDigestOp = 'SHA256',
+ ): Promise {
+ isDigesting.value = true
+ progress.value = 0
+ error.value = null
+ result.value = null
+
+ const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
+ startFakeProgress(totalBytes, files.length)
+
+ try {
+ const results: FileDigestResult[] = []
+ for (const file of files) {
+ const factory = algorithm === 'KECCAK256' ? keccak_256 : sha256
+ const hasher = factory.create()
+
+ const reader = file.stream().getReader()
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ hasher.update(value)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ const digestBytes = hasher.digest()
+ results.push({
+ fileName: file.webkitRelativePath || file.name,
+ fileSize: file.size,
+ algorithm,
+ digest: hexlify(digestBytes),
+ header: { kind: algorithm, digest: digestBytes },
+ })
+ }
+
+ stopFakeProgress()
+ if (results.length > 0) result.value = results[results.length - 1]!
+ return results
+ } catch (e) {
+ stopFakeProgress()
+ const msg = e instanceof Error ? e.message : 'Failed to digest files'
+ error.value = msg
+ throw new Error(msg)
+ } finally {
+ isDigesting.value = false
+ }
+ }
+
function reset() {
+ if (progressTimer) {
+ clearInterval(progressTimer)
+ progressTimer = null
+ }
isDigesting.value = false
progress.value = 0
error.value = null
@@ -101,6 +167,7 @@ export function useFileDigest() {
error,
result,
digestFile,
+ digestFiles,
reset,
}
}
diff --git a/apps/web/src/composables/useWebSocketFeed.ts b/apps/web/src/composables/useWebSocketFeed.ts
index c627f9a..eab8a1b 100644
--- a/apps/web/src/composables/useWebSocketFeed.ts
+++ b/apps/web/src/composables/useWebSocketFeed.ts
@@ -20,6 +20,7 @@ export interface FeedEntry {
chainId: number
blockHeight: number
sender?: string
+ txHash?: string
timestamp: number
}
@@ -73,6 +74,7 @@ export function useWebSocketFeed() {
chainId,
blockHeight: log.blockNumber,
sender: parsed.args[1],
+ txHash: log.transactionHash,
timestamp: Number(parsed.args[2] as bigint) * 1000,
})
}
@@ -121,6 +123,7 @@ export function useWebSocketFeed() {
chainId,
blockHeight: log.blockNumber,
sender: parsed.args[1],
+ txHash: log.transactionHash,
timestamp: Number(parsed.args[2] as bigint) * 1000,
})
}
diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts
index 869a499..9c633dc 100644
--- a/apps/web/src/stores/app.ts
+++ b/apps/web/src/stores/app.ts
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { DEFAULT_CALENDARS } from '@uts/sdk'
import type { DetachedTimestamp, SecureDigestOp } from '@uts/sdk'
import { getSDK, resetSDK } from '@/composables/useTimestampSDK'
+import { JsonRpcProvider } from 'ethers'
const CHAIN_NAMES: Record = {
1: 'Ethereum',
@@ -21,6 +22,12 @@ export interface EthChainNode {
const STORAGE_KEY = 'uts-calendars'
const SETTINGS_KEY = 'uts-settings'
+const CHAINS_KEY = 'uts-custom-chains'
+
+// Default chain IDs from SDK ethRPCs
+function getDefaultChainIds(): number[] {
+ return Object.keys(getSDK().ethRPCs).map(Number)
+}
function loadCalendars(): string[] {
try {
@@ -52,6 +59,31 @@ function saveSettings(settings: { keepPending: boolean; internalHashAlgo: Secure
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
}
+function loadCustomChains(): number[] | null {
+ try {
+ const stored = localStorage.getItem(CHAINS_KEY)
+ if (stored) return JSON.parse(stored)
+ } catch { /* ignore */ }
+ return null
+}
+
+function saveCustomChains(chainIds: number[]) {
+ localStorage.setItem(CHAINS_KEY, JSON.stringify(chainIds))
+}
+
+// Public RPC endpoints for common chains (used when user adds a chain not in SDK ethRPCs)
+const PUBLIC_RPCS: Record = {
+ 1: 'https://eth.llamarpc.com',
+ 17000: 'https://rpc.holesky.ethpandaops.io',
+ 11155111: 'https://rpc.sepolia.org',
+ 534352: 'https://rpc.scroll.io',
+ 534351: 'https://sepolia-rpc.scroll.io',
+ 42161: 'https://arb1.arbitrum.io/rpc',
+ 10: 'https://mainnet.optimism.io',
+ 8453: 'https://mainnet.base.org',
+ 137: 'https://polygon-rpc.com',
+}
+
export const useAppStore = defineStore('app', () => {
const calendarUrls = ref(loadCalendars())
const keepPending = ref(loadSettings().keepPending)
@@ -59,6 +91,9 @@ export const useAppStore = defineStore('app', () => {
const ethChains = ref([])
const recentStamps = ref([])
+ // Chain IDs to monitor (persisted or default from SDK)
+ const customChainIds = ref(loadCustomChains() ?? getDefaultChainIds())
+
const onlineCount = computed(
() => ethChains.value.filter((c) => c.status === 'online').length,
)
@@ -78,7 +113,7 @@ export const useAppStore = defineStore('app', () => {
async function checkChains() {
const sdk = getSDK()
- const chainIds = Object.keys(sdk.ethRPCs).map(Number)
+ const chainIds = customChainIds.value
ethChains.value = chainIds.map((chainId) => ({
chainId,
@@ -87,7 +122,14 @@ export const useAppStore = defineStore('app', () => {
}))
for (const chain of ethChains.value) {
- const provider = sdk.getEthProvider(chain.chainId)
+ // Try SDK provider first, then public RPC
+ let provider = sdk.getEthProvider(chain.chainId)
+ if (!provider && PUBLIC_RPCS[chain.chainId]) {
+ try {
+ provider = new JsonRpcProvider(PUBLIC_RPCS[chain.chainId])
+ } catch { /* ignore */ }
+ }
+
if (!provider) {
chain.status = 'offline'
continue
@@ -104,6 +146,23 @@ export const useAppStore = defineStore('app', () => {
}
}
+ function addChain(chainId: number) {
+ if (customChainIds.value.includes(chainId)) return
+ customChainIds.value.push(chainId)
+ saveCustomChains(customChainIds.value)
+ }
+
+ function removeChain(chainId: number) {
+ customChainIds.value = customChainIds.value.filter((id) => id !== chainId)
+ ethChains.value = ethChains.value.filter((c) => c.chainId !== chainId)
+ saveCustomChains(customChainIds.value)
+ }
+
+ function resetChains() {
+ customChainIds.value = getDefaultChainIds()
+ localStorage.removeItem(CHAINS_KEY)
+ }
+
function addStamp(stamp: DetachedTimestamp) {
recentStamps.value.unshift(stamp)
if (recentStamps.value.length > 20) {
@@ -127,6 +186,9 @@ export const useAppStore = defineStore('app', () => {
recentStamps,
onlineCount,
checkChains,
+ addChain,
+ removeChain,
+ resetChains,
addStamp,
setCalendars,
resetCalendars,
diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue
index 0ec95bf..a639f8c 100644
--- a/apps/web/src/views/HomeView.vue
+++ b/apps/web/src/views/HomeView.vue
@@ -1,9 +1,10 @@
-
+
-
+
Bitcoin block #{{ attestation.attestation.height }}
-
- {{ getChainName(attestation.attestation.chain) }} block #{{ attestation.attestation.height }}
+
+ {{ getChainName(attestation.attestation.chain) }} block #{{
+ attestation.attestation.height
+ }}
@@ -113,11 +138,17 @@ function formatTimestamp(ts: bigint | number): string {
Unknown attestation
-
+
@@ -132,12 +163,21 @@ function formatTimestamp(ts: bigint | number): string {
Block Height
- {{ attestation.attestation.height }}
+ {{
+ attestation.attestation.height
+ }}
-
+
Merkle Root
- {{ truncateHex(attestation.additionalInfo.header.merkleroot) }}
+ {{
+ truncateHex(attestation.additionalInfo.header.merkleroot)
+ }}
@@ -149,21 +189,42 @@ function formatTimestamp(ts: bigint | number): string {
Type
-
+
Ethereum UTS
Chain
- {{ getChainName(attestation.attestation.chain) }} ({{ attestation.attestation.chain }})
+ {{ getChainName(attestation.attestation.chain) }} ({{
+ attestation.attestation.chain
+ }})
Block Height
- {{ attestation.attestation.height }}
+ {{
+ attestation.attestation.height
+ }}
-
-
+
+
Sender
- {{ truncateHex(attestation.additionalInfo.sender) }}
+ {{
+ truncateHex(attestation.additionalInfo.sender)
+ }}
-
+
Timestamp
- {{ formatTimestamp(attestation.additionalInfo.timestamp) }}
+ {{
+ formatTimestamp(attestation.additionalInfo.timestamp)
+ }}
-
+
Root
- {{ truncateHex(attestation.additionalInfo.root) }}
+ {{
+ truncateHex(attestation.additionalInfo.root)
+ }}
-
+
Contract
- {{ truncateHex(hexlify(attestation.attestation.metadata.contract as Uint8Array)) }}
+ {{
+ truncateHex(
+ hexlify(
+ attestation.attestation.metadata.contract as Uint8Array,
+ ),
+ )
+ }}
-
@@ -249,14 +389,20 @@ function formatTimestamp(ts: bigint | number): string {
Calendar
- {{ attestation.attestation.url }}
+ {{
+ attestation.attestation.url
+ }}
{{ attestation.error?.message }}
diff --git a/apps/web/src/components/verify/MerkleTreeViz.vue b/apps/web/src/components/verify/MerkleTreeViz.vue
index c42a816..63e4dc1 100644
--- a/apps/web/src/components/verify/MerkleTreeViz.vue
+++ b/apps/web/src/components/verify/MerkleTreeViz.vue
@@ -2,7 +2,13 @@
import { ref } from 'vue'
import type { Step, Attestation } from '@uts/sdk'
import { hexlify } from 'ethers/utils'
-import { ChevronRight, ChevronDown, GitBranch, ExternalLink, AlertTriangle } from 'lucide-vue-next'
+import {
+ ChevronRight,
+ ChevronDown,
+ GitBranch,
+ ExternalLink,
+ AlertTriangle,
+} from 'lucide-vue-next'
import { WELL_KNOWN_CHAINS } from '@uts/sdk'
import ScrollLogo from '@/assets/Scroll_Logomark.svg'
@@ -40,7 +46,11 @@ const CHAIN_NAMES: Record
= {
}
function getChainName(chainId: number): string {
- return CHAIN_NAMES[chainId] ?? WELL_KNOWN_CHAINS[chainId]?.chainName ?? `Chain ${chainId}`
+ return (
+ CHAIN_NAMES[chainId] ??
+ WELL_KNOWN_CHAINS[chainId]?.chainName ??
+ `Chain ${chainId}`
+ )
}
function getEtherscanBlockUrl(chainId: number, height: number): string | null {
@@ -53,7 +63,10 @@ function getEtherscanTxUrl(chainId: number, txHash: string): string | null {
return base ? `${base}/tx/${txHash}` : null
}
-function getEtherscanAddressUrl(chainId: number, address: string): string | null {
+function getEtherscanAddressUrl(
+ chainId: number,
+ address: string,
+): string | null {
const base = ETHERSCAN_URLS[chainId]
return base ? `${base}/address/${address}` : null
}
@@ -66,7 +79,8 @@ function isTestnetOrUnknown(chainId: number): boolean {
function getNetworkWarning(chainId: number): string | null {
if (MAINNET_CHAIN_IDS.has(chainId)) return null
- if (ETHERSCAN_URLS[chainId]) return 'Testnet attestation — not suitable for production use'
+ if (ETHERSCAN_URLS[chainId])
+ return 'Testnet attestation — not suitable for production use'
return 'Unknown network — cannot verify on-chain'
}
@@ -136,29 +150,37 @@ const currentDepth = props.depth ?? 0
-
-
+
+
-
+
{{ formatOp(step) }}
-
+
@@ -168,7 +190,9 @@ const currentDepth = props.depth ?? 0
Block Height
- {{ step.attestation.height }}
+ {{
+ step.attestation.height
+ }}
@@ -179,21 +203,42 @@ const currentDepth = props.depth ?? 0
Type
-
+
Ethereum UTS
Chain
- {{ getChainName(step.attestation.chain) }} ({{ step.attestation.chain }})
+ {{ getChainName(step.attestation.chain) }} ({{
+ step.attestation.chain
+ }})
Block Height
- {{ step.attestation.height }}
+ {{
+ step.attestation.height
+ }}
-
+
Contract
- {{ truncateHex(hexlify(step.attestation.metadata.contract as Uint8Array)) }}
+ {{
+ truncateHex(
+ hexlify(
+ step.attestation.metadata.contract as Uint8Array,
+ ),
+ )
+ }}
-