@le-space/ui 0.6.23 → 0.6.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/ui",
3
- "version": "0.6.23",
3
+ "version": "0.6.24",
4
4
  "description": "Floating Aleph sponsor relay UI components for Svelte and React.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,8 +44,8 @@
44
44
  "url": "https://github.com/NiKrause/relay-button/issues"
45
45
  },
46
46
  "dependencies": {
47
- "@le-space/browser": "0.6.23",
48
- "@le-space/core": "0.6.23",
47
+ "@le-space/browser": "0.6.24",
48
+ "@le-space/core": "0.6.24",
49
49
  "viem": "^2.53.1"
50
50
  },
51
51
  "peerDependencies": {
package/react/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
 
3
- type MessageStatus = 'processed' | 'pending' | 'rejected' | 'unknown';
3
+ type MessageStatus = 'processed' | 'pending' | 'rejected' | 'removing' | 'removed' | 'unknown';
4
4
  interface BalanceResponse {
5
5
  address: string;
6
6
  balance: string;
@@ -235,6 +235,7 @@ interface SponsorRelayRootfsHealth {
235
235
  tone: SponsorRelayHealthTone;
236
236
  label: string;
237
237
  detail: string | null;
238
+ code?: 'manifest-invalid' | 'rootfs-not-found' | 'rootfs-verifying' | 'rootfs-pending' | 'rootfs-unavailable' | 'rootfs-ready';
238
239
  }
239
240
  interface CompactInstanceDetails {
240
241
  messageStatus: string;
package/react/index.js CHANGED
@@ -43,7 +43,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
43
43
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
44
44
 
45
45
  // src/shared/package-version.ts
46
- var UI_PACKAGE_VERSION = "0.6.23";
46
+ var UI_PACKAGE_VERSION = "0.6.24";
47
47
 
48
48
  // src/shared/format.ts
49
49
  function shortHash(value, head = 8, tail = 6) {
@@ -647,7 +647,7 @@ async function verifyRootfsExists(itemHash, apiHost = DEFAULT_ALEPH_API_HOST) {
647
647
  function normalizeStatus(status) {
648
648
  if (typeof status !== "string") return "unknown";
649
649
  const normalized = status.toLowerCase();
650
- if (normalized === "processed" || normalized === "pending" || normalized === "rejected") {
650
+ if (normalized === "processed" || normalized === "pending" || normalized === "rejected" || normalized === "removing" || normalized === "removed") {
651
651
  return normalized;
652
652
  }
653
653
  return "unknown";
@@ -672,6 +672,7 @@ function parseCidFromPayload(payload) {
672
672
  }
673
673
  function parseRejectionReason(payload) {
674
674
  const errorCode = typeof payload.error_code === "number" ? payload.error_code : null;
675
+ const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
675
676
  const details = payload.details && typeof payload.details === "object" ? payload.details : null;
676
677
  const rawErrors = Array.isArray(details?.errors) ? details.errors : [];
677
678
  const firstError = rawErrors[0] && typeof rawErrors[0] === "object" ? rawErrors[0] : null;
@@ -686,6 +687,18 @@ function parseRejectionReason(payload) {
686
687
  };
687
688
  }
688
689
  }
690
+ if (reason === "balance_insufficient") {
691
+ return {
692
+ rejectionErrorCode: errorCode,
693
+ rejectionReason: "The rootfs STORE is being removed because its publisher no longer has enough Aleph credits. Your connected MetaMask balance may be sufficient, but this manifest must point to a newly published, processed rootfs STORE."
694
+ };
695
+ }
696
+ if (reason) {
697
+ return {
698
+ rejectionErrorCode: errorCode,
699
+ rejectionReason: `Aleph marked the rootfs STORE as unavailable: ${reason.replaceAll("_", " ")}.`
700
+ };
701
+ }
689
702
  return {
690
703
  rejectionErrorCode: errorCode,
691
704
  rejectionReason: errorCode != null ? `Rejected by Aleph (error code ${errorCode}).` : null
@@ -727,11 +740,12 @@ async function resolveRootfsReference(itemHash, apiHost = DEFAULT_ALEPH_API_HOST
727
740
  const firstMessage = Array.isArray(payload.messages) && payload.messages[0] && typeof payload.messages[0] === "object" ? payload.messages[0] : null;
728
741
  const messageObject = payload.message && typeof payload.message === "object" ? payload.message : null;
729
742
  const cid = parseCidFromPayload(payload);
730
- const rejection = normalizeStatus(payload.status) === "rejected" ? parseRejectionReason(payload) : { rejectionErrorCode: null, rejectionReason: null };
743
+ const messageStatus = normalizeStatus(payload.status);
744
+ const rejection = messageStatus === "processed" || messageStatus === "pending" ? { rejectionErrorCode: null, rejectionReason: null } : parseRejectionReason(payload);
731
745
  const gateway = cid ? await probeGateway(cid, gatewayBaseUrl) : { gatewayUrl: null, gatewayStatus: "unknown", gatewayError: null };
732
746
  return {
733
747
  itemHash,
734
- messageStatus: normalizeStatus(payload.status),
748
+ messageStatus,
735
749
  messageType: String(payload.type || messageObject?.type || firstMessage?.type || "").toUpperCase() || null,
736
750
  cid,
737
751
  receptionTime: typeof payload.reception_time === "string" ? payload.reception_time : null,
@@ -3836,48 +3850,55 @@ function rootfsHealth(args) {
3836
3850
  return {
3837
3851
  tone: "error",
3838
3852
  label: "manifest invalid",
3839
- detail: args.manifestState.errors[0] ?? "Manifest could not be parsed."
3853
+ detail: args.manifestState.errors[0] ?? "Manifest could not be parsed.",
3854
+ code: "manifest-invalid"
3840
3855
  };
3841
3856
  }
3842
3857
  if (!args.rootfsVerified) {
3843
3858
  return {
3844
3859
  tone: "error",
3845
3860
  label: "not found on Aleph",
3846
- detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten."
3861
+ detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten.",
3862
+ code: "rootfs-not-found"
3847
3863
  };
3848
3864
  }
3849
3865
  if (!args.resolution) {
3850
3866
  return {
3851
3867
  tone: "caution",
3852
3868
  label: "verifying rootfs",
3853
- detail: "The rootfs reference is still being resolved."
3869
+ detail: "The rootfs reference is still being resolved.",
3870
+ code: "rootfs-verifying"
3854
3871
  };
3855
3872
  }
3856
3873
  if (args.resolution.messageStatus === "processed") {
3857
3874
  return {
3858
3875
  tone: "ok",
3859
3876
  label: "deployable",
3860
- detail: args.resolution.gatewayUrl ?? "Rootfs verified on Aleph."
3877
+ detail: args.resolution.gatewayUrl ?? "Rootfs verified on Aleph.",
3878
+ code: "rootfs-ready"
3861
3879
  };
3862
3880
  }
3863
3881
  if (args.resolution.messageStatus === "pending" && args.resolution.gatewayStatus === "reachable") {
3864
3882
  return {
3865
3883
  tone: "caution",
3866
3884
  label: "pending but reachable",
3867
- detail: "Gateway probe succeeded even though Aleph still reports pending."
3885
+ detail: "Gateway probe succeeded even though Aleph still reports pending.",
3886
+ code: "rootfs-pending"
3868
3887
  };
3869
3888
  }
3870
3889
  if (args.resolution.messageStatus === "pending") {
3871
3890
  return {
3872
3891
  tone: "caution",
3873
3892
  label: "pending on Aleph",
3874
- detail: "Wait until the STORE message is processed."
3893
+ detail: "Wait until the STORE message is processed.",
3894
+ code: "rootfs-pending"
3875
3895
  };
3876
3896
  }
3877
3897
  return {
3878
3898
  tone: "error",
3879
3899
  label: "manifest rootfs not deployable",
3880
- detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs.")
3900
+ detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs."),
3901
+ code: "rootfs-unavailable"
3881
3902
  };
3882
3903
  }
3883
3904
  async function resolveManifest(args) {
@@ -6397,7 +6418,8 @@ function SponsorRelayFab(props) {
6397
6418
  maxHeight: "calc(100vh - 12.5rem)"
6398
6419
  };
6399
6420
  const launcherLabel = deploymentLauncherLabel(state, compactInlineLabel, launcherMode);
6400
- const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : deploymentButtonLabel(state) : "Connect MetaMask";
6421
+ const rootfsBlocked = state.rootfsHealth.tone === "error";
6422
+ const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : rootfsBlocked ? "Deployment blocked \u2014 Rootfs unavailable" : deploymentButtonLabel(state) : "Connect MetaMask";
6401
6423
  return /* @__PURE__ */ jsxs(Fragment, { children: [
6402
6424
  /* @__PURE__ */ jsx("style", { children: `@keyframes leSpaceRelayPulse { 0%, 100% { transform: scale(1); opacity: 0.74; } 50% { transform: scale(1.22); opacity: 1; } }` }),
6403
6425
  /* @__PURE__ */ jsxs(
@@ -7082,13 +7104,55 @@ function SponsorRelayFab(props) {
7082
7104
  )
7083
7105
  ] })
7084
7106
  ] }),
7107
+ rootfsBlocked ? /* @__PURE__ */ jsxs(
7108
+ "div",
7109
+ {
7110
+ role: "alert",
7111
+ id: "relay-rootfs-deployment-blocker",
7112
+ style: {
7113
+ display: "grid",
7114
+ gap: "0.55rem",
7115
+ marginTop: "1rem",
7116
+ padding: "0.9rem",
7117
+ borderRadius: "1rem",
7118
+ border: "1px solid rgba(248, 113, 113, 0.58)",
7119
+ background: "rgba(127, 29, 29, 0.34)",
7120
+ color: "#fee2e2"
7121
+ },
7122
+ children: [
7123
+ /* @__PURE__ */ jsx("strong", { children: "Rootfs unavailable \u2014 deployment blocked" }),
7124
+ /* @__PURE__ */ jsx("span", { style: { fontSize: "0.82rem", lineHeight: 1.45 }, children: state.rootfsHealth.detail }),
7125
+ /* @__PURE__ */ jsxs("span", { style: { fontSize: "0.75rem", lineHeight: 1.4 }, children: [
7126
+ "This is separate from the connected MetaMask balance. The manifest must reference an Aleph rootfs STORE with status ",
7127
+ /* @__PURE__ */ jsx("code", { children: "processed" }),
7128
+ "."
7129
+ ] }),
7130
+ state.manifest?.rootfsItemHash ? /* @__PURE__ */ jsxs("code", { style: { fontSize: "0.7rem", overflowWrap: "anywhere" }, children: [
7131
+ "Rootfs: ",
7132
+ state.manifest.rootfsItemHash,
7133
+ state.rootfsResolution?.messageStatus ? ` \xB7 Aleph status: ${state.rootfsResolution.messageStatus}` : ""
7134
+ ] }) : null,
7135
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: "0.5rem" }, children: [
7136
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => void controller.refresh(), disabled: state.busy.refreshing, style: { ...secondaryButtonStyle, padding: "0.55rem 0.7rem" }, children: state.busy.refreshing ? "Checking\u2026" : "Retry validation" }),
7137
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => controller.setShowAdvanced(true), style: { ...secondaryButtonStyle, padding: "0.55rem 0.7rem" }, children: "Edit manifest URL" }),
7138
+ /^https?:\/\//.test(state.manifestUrl) ? /* @__PURE__ */ jsx("a", { href: state.manifestUrl, target: "_blank", rel: "noreferrer", style: { color: "#bfdbfe", alignSelf: "center", fontSize: "0.78rem" }, children: "Open manifest" }) : null
7139
+ ] })
7140
+ ]
7141
+ }
7142
+ ) : null,
7085
7143
  /* @__PURE__ */ jsx(
7086
7144
  "button",
7087
7145
  {
7088
7146
  type: "button",
7089
7147
  onClick: () => void (state.wallet.connected ? controller.deploy() : controller.connectWallet()),
7090
7148
  disabled: state.wallet.connected ? state.busy.deploying || state.rootfsHealth.tone !== "ok" : state.busy.connectingWallet,
7091
- style: { ...primaryButtonStyle, width: "100%", marginTop: "1rem" },
7149
+ "aria-describedby": rootfsBlocked ? "relay-rootfs-deployment-blocker" : void 0,
7150
+ style: {
7151
+ ...primaryButtonStyle,
7152
+ width: "100%",
7153
+ marginTop: "1rem",
7154
+ ...rootfsBlocked ? { background: "rgba(148, 163, 184, 0.22)", borderColor: "rgba(248, 113, 113, 0.45)", color: "#fee2e2", boxShadow: "none", cursor: "not-allowed" } : {}
7155
+ },
7092
7156
  children: deployCallToAction
7093
7157
  }
7094
7158
  ),
package/shared/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- type MessageStatus = 'processed' | 'pending' | 'rejected' | 'unknown';
1
+ type MessageStatus = 'processed' | 'pending' | 'rejected' | 'removing' | 'removed' | 'unknown';
2
2
  interface BalanceResponse {
3
3
  address: string;
4
4
  balance: string;
@@ -233,6 +233,7 @@ interface SponsorRelayRootfsHealth {
233
233
  tone: SponsorRelayHealthTone;
234
234
  label: string;
235
235
  detail: string | null;
236
+ code?: 'manifest-invalid' | 'rootfs-not-found' | 'rootfs-verifying' | 'rootfs-pending' | 'rootfs-unavailable' | 'rootfs-ready';
236
237
  }
237
238
  interface CompactInstanceDetails {
238
239
  messageStatus: string;
@@ -331,7 +332,7 @@ declare const STALE_INSTANCE_ALLOCATION_COOLDOWN_MS: number;
331
332
  declare const RECENT_INSTANCE_RUNTIME_GRACE_MS: number;
332
333
  declare const DEPLOYMENT_PENDING_WARNING_MS: number;
333
334
 
334
- declare const UI_PACKAGE_VERSION = "0.6.23";
335
+ declare const UI_PACKAGE_VERSION = "0.6.24";
335
336
 
336
337
  interface RootfsRequiredPortForward {
337
338
  port: number;
package/shared/index.js CHANGED
@@ -42,7 +42,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
42
42
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
43
43
 
44
44
  // src/shared/package-version.ts
45
- var UI_PACKAGE_VERSION = "0.6.23";
45
+ var UI_PACKAGE_VERSION = "0.6.24";
46
46
 
47
47
  // src/shared/format.ts
48
48
  function shortHash(value, head = 8, tail = 6) {
@@ -646,7 +646,7 @@ async function verifyRootfsExists(itemHash, apiHost = DEFAULT_ALEPH_API_HOST) {
646
646
  function normalizeStatus(status) {
647
647
  if (typeof status !== "string") return "unknown";
648
648
  const normalized = status.toLowerCase();
649
- if (normalized === "processed" || normalized === "pending" || normalized === "rejected") {
649
+ if (normalized === "processed" || normalized === "pending" || normalized === "rejected" || normalized === "removing" || normalized === "removed") {
650
650
  return normalized;
651
651
  }
652
652
  return "unknown";
@@ -671,6 +671,7 @@ function parseCidFromPayload(payload) {
671
671
  }
672
672
  function parseRejectionReason(payload) {
673
673
  const errorCode = typeof payload.error_code === "number" ? payload.error_code : null;
674
+ const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
674
675
  const details = payload.details && typeof payload.details === "object" ? payload.details : null;
675
676
  const rawErrors = Array.isArray(details?.errors) ? details.errors : [];
676
677
  const firstError = rawErrors[0] && typeof rawErrors[0] === "object" ? rawErrors[0] : null;
@@ -685,6 +686,18 @@ function parseRejectionReason(payload) {
685
686
  };
686
687
  }
687
688
  }
689
+ if (reason === "balance_insufficient") {
690
+ return {
691
+ rejectionErrorCode: errorCode,
692
+ rejectionReason: "The rootfs STORE is being removed because its publisher no longer has enough Aleph credits. Your connected MetaMask balance may be sufficient, but this manifest must point to a newly published, processed rootfs STORE."
693
+ };
694
+ }
695
+ if (reason) {
696
+ return {
697
+ rejectionErrorCode: errorCode,
698
+ rejectionReason: `Aleph marked the rootfs STORE as unavailable: ${reason.replaceAll("_", " ")}.`
699
+ };
700
+ }
688
701
  return {
689
702
  rejectionErrorCode: errorCode,
690
703
  rejectionReason: errorCode != null ? `Rejected by Aleph (error code ${errorCode}).` : null
@@ -726,11 +739,12 @@ async function resolveRootfsReference(itemHash, apiHost = DEFAULT_ALEPH_API_HOST
726
739
  const firstMessage = Array.isArray(payload.messages) && payload.messages[0] && typeof payload.messages[0] === "object" ? payload.messages[0] : null;
727
740
  const messageObject = payload.message && typeof payload.message === "object" ? payload.message : null;
728
741
  const cid = parseCidFromPayload(payload);
729
- const rejection = normalizeStatus(payload.status) === "rejected" ? parseRejectionReason(payload) : { rejectionErrorCode: null, rejectionReason: null };
742
+ const messageStatus = normalizeStatus(payload.status);
743
+ const rejection = messageStatus === "processed" || messageStatus === "pending" ? { rejectionErrorCode: null, rejectionReason: null } : parseRejectionReason(payload);
730
744
  const gateway = cid ? await probeGateway(cid, gatewayBaseUrl) : { gatewayUrl: null, gatewayStatus: "unknown", gatewayError: null };
731
745
  return {
732
746
  itemHash,
733
- messageStatus: normalizeStatus(payload.status),
747
+ messageStatus,
734
748
  messageType: String(payload.type || messageObject?.type || firstMessage?.type || "").toUpperCase() || null,
735
749
  cid,
736
750
  receptionTime: typeof payload.reception_time === "string" ? payload.reception_time : null,
@@ -3835,48 +3849,55 @@ function rootfsHealth(args) {
3835
3849
  return {
3836
3850
  tone: "error",
3837
3851
  label: "manifest invalid",
3838
- detail: args.manifestState.errors[0] ?? "Manifest could not be parsed."
3852
+ detail: args.manifestState.errors[0] ?? "Manifest could not be parsed.",
3853
+ code: "manifest-invalid"
3839
3854
  };
3840
3855
  }
3841
3856
  if (!args.rootfsVerified) {
3842
3857
  return {
3843
3858
  tone: "error",
3844
3859
  label: "not found on Aleph",
3845
- detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten."
3860
+ detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten.",
3861
+ code: "rootfs-not-found"
3846
3862
  };
3847
3863
  }
3848
3864
  if (!args.resolution) {
3849
3865
  return {
3850
3866
  tone: "caution",
3851
3867
  label: "verifying rootfs",
3852
- detail: "The rootfs reference is still being resolved."
3868
+ detail: "The rootfs reference is still being resolved.",
3869
+ code: "rootfs-verifying"
3853
3870
  };
3854
3871
  }
3855
3872
  if (args.resolution.messageStatus === "processed") {
3856
3873
  return {
3857
3874
  tone: "ok",
3858
3875
  label: "deployable",
3859
- detail: args.resolution.gatewayUrl ?? "Rootfs verified on Aleph."
3876
+ detail: args.resolution.gatewayUrl ?? "Rootfs verified on Aleph.",
3877
+ code: "rootfs-ready"
3860
3878
  };
3861
3879
  }
3862
3880
  if (args.resolution.messageStatus === "pending" && args.resolution.gatewayStatus === "reachable") {
3863
3881
  return {
3864
3882
  tone: "caution",
3865
3883
  label: "pending but reachable",
3866
- detail: "Gateway probe succeeded even though Aleph still reports pending."
3884
+ detail: "Gateway probe succeeded even though Aleph still reports pending.",
3885
+ code: "rootfs-pending"
3867
3886
  };
3868
3887
  }
3869
3888
  if (args.resolution.messageStatus === "pending") {
3870
3889
  return {
3871
3890
  tone: "caution",
3872
3891
  label: "pending on Aleph",
3873
- detail: "Wait until the STORE message is processed."
3892
+ detail: "Wait until the STORE message is processed.",
3893
+ code: "rootfs-pending"
3874
3894
  };
3875
3895
  }
3876
3896
  return {
3877
3897
  tone: "error",
3878
3898
  label: "manifest rootfs not deployable",
3879
- detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs.")
3899
+ detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs."),
3900
+ code: "rootfs-unavailable"
3880
3901
  };
3881
3902
  }
3882
3903
  async function resolveManifest(args) {
@@ -62,6 +62,12 @@ export let apiHosts = undefined
62
62
  return deploymentProfile() === 'ucan-store' ? 'Deploy Service' : 'Deploy Relay'
63
63
  }
64
64
 
65
+ function deploymentCallToAction() {
66
+ if (state.busy.deploying) return 'Deploying…'
67
+ if (state.rootfsHealth.tone === 'error') return 'Deployment blocked — Rootfs unavailable'
68
+ return deploymentButtonLabel()
69
+ }
70
+
65
71
  function bootstrapUiEnabled() {
66
72
  return deploymentProfile() !== 'ucan-store'
67
73
  }
@@ -264,10 +270,28 @@ export let apiHosts = undefined
264
270
  </div>
265
271
  </div>
266
272
 
273
+ {#if state.rootfsHealth.tone === 'error'}
274
+ <div class="rootfs-blocker" id="relay-rootfs-deployment-blocker" role="alert">
275
+ <strong>Rootfs unavailable — deployment blocked</strong>
276
+ <p>{state.rootfsHealth.detail}</p>
277
+ <small>This is separate from the connected MetaMask balance. The manifest must reference an Aleph rootfs STORE with status <code>processed</code>.</small>
278
+ {#if state.manifest?.rootfsItemHash}
279
+ <code class="rootfs-reference">Rootfs: {state.manifest.rootfsItemHash}{state.rootfsResolution?.messageStatus ? ` · Aleph status: ${state.rootfsResolution.messageStatus}` : ''}</code>
280
+ {/if}
281
+ <div class="rootfs-actions">
282
+ <button class="refresh" type="button" on:click={() => controller.refresh()} disabled={state.busy.refreshing}>{state.busy.refreshing ? 'Checking…' : 'Retry validation'}</button>
283
+ <button class="refresh" type="button" on:click={() => controller.setShowAdvanced(true)}>Edit manifest URL</button>
284
+ {#if /^https?:\/\//.test(state.manifestUrl)}
285
+ <a href={state.manifestUrl} target="_blank" rel="noreferrer">Open manifest</a>
286
+ {/if}
287
+ </div>
288
+ </div>
289
+ {/if}
290
+
267
291
  <div class="actions">
268
292
  {#if state.wallet.connected}
269
- <button class="primary" type="button" on:click={() => controller.deploy()} disabled={state.busy.deploying || state.rootfsHealth.tone !== 'ok'}>
270
- {state.busy.deploying ? 'Deploying…' : deploymentButtonLabel()}
293
+ <button class:blocked={state.rootfsHealth.tone === 'error'} class="primary" type="button" aria-describedby={state.rootfsHealth.tone === 'error' ? 'relay-rootfs-deployment-blocker' : undefined} on:click={() => controller.deploy()} disabled={state.busy.deploying || state.rootfsHealth.tone !== 'ok'}>
294
+ {deploymentCallToAction()}
271
295
  </button>
272
296
  {:else}
273
297
  <button class="primary" type="button" on:click={() => controller.connectWallet()} disabled={state.busy.connectingWallet}>
@@ -470,6 +494,25 @@ export let apiHosts = undefined
470
494
  line-height: 1.35;
471
495
  }
472
496
 
497
+ .rootfs-blocker {
498
+ display: grid;
499
+ gap: 0.55rem;
500
+ margin-top: 1rem;
501
+ padding: 0.9rem;
502
+ border: 1px solid rgba(248, 113, 113, 0.58);
503
+ border-radius: 1rem;
504
+ background: rgba(127, 29, 29, 0.34);
505
+ color: #fee2e2;
506
+ }
507
+
508
+ .rootfs-blocker p,
509
+ .rootfs-blocker small { margin: 0; line-height: 1.45; }
510
+ .rootfs-reference { overflow-wrap: anywhere; font-size: 0.7rem; }
511
+ .rootfs-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
512
+ .rootfs-actions .refresh { padding: 0.55rem 0.7rem; }
513
+ .rootfs-actions a { color: #bfdbfe; font-size: 0.78rem; }
514
+ .primary.blocked { border-color: rgba(248, 113, 113, 0.45); background: rgba(148, 163, 184, 0.22); color: #fee2e2; box-shadow: none; cursor: not-allowed; }
515
+
473
516
  .eyebrow,
474
517
  .field span,
475
518
  .metric-card span,