@le-space/ui 0.9.1 → 0.9.4

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.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "Floating Aleph Relay Button 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.9.1",
48
- "@le-space/core": "0.9.1",
47
+ "@le-space/browser": "0.9.4",
48
+ "@le-space/core": "0.9.4",
49
49
  "viem": "^2.53.1"
50
50
  },
51
51
  "peerDependencies": {
package/react/index.d.ts CHANGED
@@ -199,6 +199,16 @@ interface DeploymentProgressEvent {
199
199
  }
200
200
  type DeploymentProgressListener = (event: DeploymentProgressEvent) => void;
201
201
 
202
+ /**
203
+ * Geometry for a launcher the consumer can place and the user can drag.
204
+ *
205
+ * Kept free of DOM access on purpose: the framework layers read rects and write
206
+ * styles, this module only does the arithmetic, which is what makes the awkward
207
+ * parts — the tap/drag threshold, staying on screen after a rotation — testable
208
+ * without a browser.
209
+ */
210
+ type FabCorner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
211
+
202
212
  type SponsorRelayHealthTone = 'ok' | 'caution' | 'error' | 'idle';
203
213
  interface SponsorRelayUcanStoreBootstrapInput {
204
214
  adminDid: string;
@@ -233,6 +243,20 @@ interface SponsorRelayProps {
233
243
  crnSource?: CrnSourcePreference;
234
244
  schedulerApiHost?: string;
235
245
  twoN6ApiHost?: string;
246
+ /**
247
+ * Which corner the floating launcher starts in. Consumers could not place it
248
+ * at all before: the launcher is `position: fixed`, so a wrapper around it
249
+ * collapses to 0x0 and has no box to position or drag.
250
+ */
251
+ position?: FabCorner;
252
+ /** Let the user drag the launcher. The panel follows it. */
253
+ draggable?: boolean;
254
+ /**
255
+ * localStorage key for the dragged position. Omit it and nothing is
256
+ * persisted -- a library that writes to storage unasked collides with its
257
+ * host.
258
+ */
259
+ positionStorageKey?: string;
236
260
  }
237
261
  interface SponsorRelayWalletState {
238
262
  connected: boolean;
@@ -400,6 +424,7 @@ declare class SponsorRelayController {
400
424
  private emit;
401
425
  private trace;
402
426
  private patch;
427
+ private lastProgressLabel;
403
428
  private emitProgress;
404
429
  private configureRelayBootstrapRegistration;
405
430
  private probeRelayReachableOverProxy;
package/react/index.js CHANGED
@@ -1,6 +1,59 @@
1
1
  // src/react/SponsorRelayFab.tsx
2
2
  import React2 from "react";
3
3
 
4
+ // src/shared/fab-position.ts
5
+ var FAB_EDGE_MARGIN = 22;
6
+ var FAB_DRAG_THRESHOLD = 6;
7
+ function dragExceedsThreshold(start, current, threshold = FAB_DRAG_THRESHOLD) {
8
+ const dx = current.x - start.x;
9
+ const dy = current.y - start.y;
10
+ return Math.hypot(dx, dy) >= threshold;
11
+ }
12
+ function resolveCornerPlacement(corner, size, viewport, margin = FAB_EDGE_MARGIN) {
13
+ const left = corner.endsWith("left") ? margin : viewport.width - size.width - margin;
14
+ const top = corner.startsWith("top") ? margin : viewport.height - size.height - margin;
15
+ return clampToViewport({ left, top }, size, viewport, margin);
16
+ }
17
+ function clampToViewport(placement, size, viewport, margin = FAB_EDGE_MARGIN) {
18
+ const maxLeft = Math.max(margin, viewport.width - size.width - margin);
19
+ const maxTop = Math.max(margin, viewport.height - size.height - margin);
20
+ return {
21
+ left: Math.min(Math.max(placement.left, margin), maxLeft),
22
+ top: Math.min(Math.max(placement.top, margin), maxTop)
23
+ };
24
+ }
25
+ function anchorPanelToLauncher(launcher, panel, viewport, gap = 12, margin = FAB_EDGE_MARGIN) {
26
+ const launcherCentre = launcher.top + launcher.height / 2;
27
+ const side = launcherCentre < viewport.height / 2 ? "below" : "above";
28
+ const top = side === "below" ? launcher.top + launcher.height + gap : launcher.top - gap - panel.height;
29
+ const preferredLeft = launcher.left + launcher.width / 2 < viewport.width / 2 ? launcher.left : launcher.left + launcher.width - panel.width;
30
+ const clamped = clampToViewport({ left: preferredLeft, top }, panel, viewport, margin);
31
+ const available = side === "below" ? viewport.height - clamped.top - margin : launcher.top - gap - margin;
32
+ return {
33
+ ...clamped,
34
+ side,
35
+ maxHeight: Math.max(0, available)
36
+ };
37
+ }
38
+ function readStoredPosition(key) {
39
+ if (!key) return null;
40
+ try {
41
+ const raw = globalThis.localStorage?.getItem(key);
42
+ if (!raw) return null;
43
+ const parsed = JSON.parse(raw);
44
+ return Number.isFinite(parsed?.left) && Number.isFinite(parsed?.top) ? parsed : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ function writeStoredPosition(key, placement, viewport) {
50
+ if (!key) return;
51
+ try {
52
+ globalThis.localStorage?.setItem(key, JSON.stringify({ ...placement, viewport }));
53
+ } catch {
54
+ }
55
+ }
56
+
4
57
  // src/shared/constants.ts
5
58
  var DEFAULT_INSTANCE_NAME = "sponsor-relay";
6
59
  var DEFAULT_MANIFEST_URL = "./rootfs-manifest.json";
@@ -43,7 +96,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
43
96
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
44
97
 
45
98
  // src/shared/package-version.ts
46
- var UI_PACKAGE_VERSION = "0.9.1";
99
+ var UI_PACKAGE_VERSION = "0.9.4";
47
100
 
48
101
  // src/shared/format.ts
49
102
  function shortHash(value, head = 8, tail = 6) {
@@ -2777,7 +2830,8 @@ function browserMultiaddrTransport(addr) {
2777
2830
  }
2778
2831
  function selectCompactRelayBootstrapMultiaddrs(addrs, limit = DEFAULT_BOOTSTRAP_COMPACT_MULTIADDR_LIMIT) {
2779
2832
  const entries = filterPublicMultiaddrs(addrs, {
2780
- browserDialableOnly: true
2833
+ browserDialableOnly: true,
2834
+ allowInsecureWebSockets: true
2781
2835
  }).map((addr, index) => ({
2782
2836
  addr,
2783
2837
  index,
@@ -2847,12 +2901,23 @@ function isLocalHostname(host) {
2847
2901
  function hasPeerId(addr) {
2848
2902
  return splitMultiaddr(addr).includes("p2p");
2849
2903
  }
2850
- function isBrowserDialableMultiaddr(addr) {
2904
+ function isSecureWebSocketMultiaddr(addr) {
2905
+ const parts = splitMultiaddr(addr.toLowerCase());
2906
+ if (parts.includes("wss")) return true;
2907
+ const wsIndex = parts.indexOf("ws");
2908
+ return wsIndex > 0 && parts.lastIndexOf("tls", wsIndex) >= 0;
2909
+ }
2910
+ function isWebSocketMultiaddr(addr) {
2911
+ const parts = splitMultiaddr(addr.toLowerCase());
2912
+ return parts.includes("ws") || parts.includes("wss");
2913
+ }
2914
+ function isBrowserDialableMultiaddr(addr, options = {}) {
2851
2915
  const normalized = addr.toLowerCase();
2852
2916
  if (normalized.includes("/webtransport") || normalized.includes("/webrtc-direct")) {
2853
2917
  return normalized.includes("/certhash/");
2854
2918
  }
2855
- return normalized.includes("/ws") || normalized.includes("/wss");
2919
+ if (!isWebSocketMultiaddr(addr)) return false;
2920
+ return (options.allowInsecureWebSockets ?? false) || isSecureWebSocketMultiaddr(addr);
2856
2921
  }
2857
2922
  function dedupeMultiaddrs(addrs) {
2858
2923
  const seen = /* @__PURE__ */ new Set();
@@ -2886,7 +2951,9 @@ function filterPublicMultiaddrs(addrs, options = {}) {
2886
2951
  return dedupeMultiaddrs(addrs).filter((addr) => {
2887
2952
  if (!isPublicMultiaddr(addr)) return false;
2888
2953
  if (options.requirePeerId !== false && !hasPeerId(addr)) return false;
2889
- if (options.browserDialableOnly && !isBrowserDialableMultiaddr(addr)) {
2954
+ if (options.browserDialableOnly && !isBrowserDialableMultiaddr(addr, {
2955
+ allowInsecureWebSockets: options.allowInsecureWebSockets
2956
+ })) {
2890
2957
  return false;
2891
2958
  }
2892
2959
  return true;
@@ -3001,7 +3068,8 @@ function buildRelayBootstrapPostContent(args) {
3001
3068
  const updatedAt = args.now ?? Date.now();
3002
3069
  const fullMultiaddrs = filterPublicMultiaddrs(args.multiaddrs);
3003
3070
  const fullBrowserMultiaddrs = args.browserMultiaddrs ? filterPublicMultiaddrs(args.browserMultiaddrs, {
3004
- browserDialableOnly: true
3071
+ browserDialableOnly: true,
3072
+ allowInsecureWebSockets: true
3005
3073
  }) : void 0;
3006
3074
  const compactMultiaddrs = selectCompactRelayBootstrapMultiaddrs(
3007
3075
  fullBrowserMultiaddrs && fullBrowserMultiaddrs.length > 0 ? fullBrowserMultiaddrs : fullMultiaddrs,
@@ -3083,7 +3151,8 @@ async function signRelayBootstrapAuthorization(args) {
3083
3151
  async function signRelayBootstrapProof(args) {
3084
3152
  const fullMultiaddrs = filterPublicMultiaddrs(args.multiaddrs);
3085
3153
  const fullBrowserMultiaddrs = args.browserMultiaddrs ? filterPublicMultiaddrs(args.browserMultiaddrs, {
3086
- browserDialableOnly: true
3154
+ browserDialableOnly: true,
3155
+ allowInsecureWebSockets: true
3087
3156
  }) : void 0;
3088
3157
  const compactMultiaddrs = selectCompactRelayBootstrapMultiaddrs(
3089
3158
  fullBrowserMultiaddrs && fullBrowserMultiaddrs.length > 0 ? fullBrowserMultiaddrs : fullMultiaddrs,
@@ -3885,12 +3954,30 @@ function validateUcanStoreBootstrapPackage(value) {
3885
3954
  function deploymentNoun(profile) {
3886
3955
  return profile === "ucan-store" ? "service" : "relay";
3887
3956
  }
3957
+ function describeRequest(input, init) {
3958
+ const method = String(init?.method ?? "GET").toUpperCase();
3959
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : typeof input?.url === "string" ? input.url : "<unknown url>";
3960
+ return `${method} ${url}`;
3961
+ }
3962
+ function deploymentFailureMessage(error, stage) {
3963
+ const message = error instanceof Error ? error.message : String(error);
3964
+ const step = String(stage ?? "").trim();
3965
+ return step ? `${message} (while: ${step})` : message;
3966
+ }
3888
3967
  function asJsonFetch(input, init) {
3889
- return fetch(input, init).then(async (response) => ({
3890
- ok: response.ok,
3891
- status: response.status,
3892
- json: async () => await response.json()
3893
- }));
3968
+ return fetch(input, init).then(
3969
+ async (response) => ({
3970
+ ok: response.ok,
3971
+ status: response.status,
3972
+ json: async () => await response.json()
3973
+ }),
3974
+ (error) => {
3975
+ const reason = error instanceof Error ? error.message : String(error);
3976
+ throw new Error(`${reason} (${describeRequest(input, init)})`, {
3977
+ cause: error
3978
+ });
3979
+ }
3980
+ );
3894
3981
  }
3895
3982
  function isConfirmedGuestSetupResult(value) {
3896
3983
  return Boolean(
@@ -4533,11 +4620,15 @@ var SponsorRelayController = class {
4533
4620
  };
4534
4621
  this.emit();
4535
4622
  }
4623
+ lastProgressLabel = null;
4536
4624
  emitProgress(event) {
4537
4625
  const nextEvent = {
4538
4626
  ...event,
4539
4627
  timestamp: Date.now()
4540
4628
  };
4629
+ if (nextEvent.stage !== "error") {
4630
+ this.lastProgressLabel = nextEvent.label;
4631
+ }
4541
4632
  this.trace(`progress:${nextEvent.stage}`, {
4542
4633
  label: nextEvent.label,
4543
4634
  progress: nextEvent.progress,
@@ -6454,10 +6545,15 @@ var SponsorRelayController = class {
6454
6545
  attemptErrors.length > 0 ? `All compatible CRNs failed. ${attemptErrors.join(" | ")}` : lastError?.message ?? "All compatible CRNs failed."
6455
6546
  );
6456
6547
  } catch (error) {
6457
- this.trace("deploy:error", error);
6548
+ const message = deploymentFailureMessage(error, this.lastProgressLabel);
6549
+ this.trace("deploy:error", {
6550
+ message,
6551
+ stage: this.lastProgressLabel,
6552
+ error
6553
+ });
6458
6554
  this.patch({
6459
6555
  busy: { deploying: false },
6460
- errorText: error instanceof Error ? error.message : String(error),
6556
+ errorText: message,
6461
6557
  statusText: "Deployment failed"
6462
6558
  });
6463
6559
  this.emitProgress({
@@ -6465,7 +6561,7 @@ var SponsorRelayController = class {
6465
6561
  label: "Deployment failed",
6466
6562
  progress: 100,
6467
6563
  status: "error",
6468
- error: error instanceof Error ? error.message : String(error)
6564
+ error: message
6469
6565
  });
6470
6566
  }
6471
6567
  }
@@ -7169,6 +7265,14 @@ function SponsorRelayFab(props) {
7169
7265
  }
7170
7266
  };
7171
7267
  const launcherRef = React2.useRef(null);
7268
+ const panelRef = React2.useRef(null);
7269
+ const draggable = props.draggable ?? false;
7270
+ const corner = props.position ?? "bottom-right";
7271
+ const storageKey = props.positionStorageKey;
7272
+ const [placement, setPlacement] = React2.useState(null);
7273
+ const dragOrigin = React2.useRef(null);
7274
+ const dragOffset = React2.useRef(null);
7275
+ const draggedRef = React2.useRef(false);
7172
7276
  const [inlinePanelStyle, setInlinePanelStyle] = React2.useState(null);
7173
7277
  const [compactInlineLabel, setCompactInlineLabel] = React2.useState(false);
7174
7278
  React2.useEffect(() => {
@@ -7198,6 +7302,67 @@ function SponsorRelayFab(props) {
7198
7302
  window.clearTimeout(timeout);
7199
7303
  };
7200
7304
  }, [state.deploymentProgress.stage, state.deploymentProgress.timestamp]);
7305
+ const viewportSize = () => ({
7306
+ width: window.innerWidth,
7307
+ height: window.innerHeight
7308
+ });
7309
+ const launcherSize = () => {
7310
+ const rect = launcherRef.current?.getBoundingClientRect();
7311
+ return { width: rect?.width ?? 180, height: rect?.height ?? 44 };
7312
+ };
7313
+ React2.useEffect(() => {
7314
+ if (launcherMode !== "floating") {
7315
+ setPlacement(null);
7316
+ return;
7317
+ }
7318
+ const stored = readStoredPosition(storageKey);
7319
+ setPlacement(
7320
+ stored ? clampToViewport(stored, launcherSize(), viewportSize()) : resolveCornerPlacement(corner, launcherSize(), viewportSize())
7321
+ );
7322
+ const onViewportChange = () => setPlacement(
7323
+ (current) => current ? clampToViewport(current, launcherSize(), viewportSize()) : current
7324
+ );
7325
+ window.addEventListener("resize", onViewportChange);
7326
+ window.addEventListener("orientationchange", onViewportChange);
7327
+ return () => {
7328
+ window.removeEventListener("resize", onViewportChange);
7329
+ window.removeEventListener("orientationchange", onViewportChange);
7330
+ };
7331
+ }, [launcherMode, corner, storageKey]);
7332
+ const handlePointerDown = (event) => {
7333
+ if (!draggable || launcherMode !== "floating" || event.button > 0 || !placement) return;
7334
+ dragOrigin.current = { x: event.clientX, y: event.clientY };
7335
+ dragOffset.current = { x: event.clientX - placement.left, y: event.clientY - placement.top };
7336
+ draggedRef.current = false;
7337
+ event.currentTarget.setPointerCapture?.(event.pointerId);
7338
+ };
7339
+ const handlePointerMove = (event) => {
7340
+ const origin = dragOrigin.current;
7341
+ const offset = dragOffset.current;
7342
+ if (!origin || !offset) return;
7343
+ const point = { x: event.clientX, y: event.clientY };
7344
+ if (!draggedRef.current && !dragExceedsThreshold(origin, point)) return;
7345
+ draggedRef.current = true;
7346
+ setPlacement(
7347
+ clampToViewport(
7348
+ { left: point.x - offset.x, top: point.y - offset.y },
7349
+ launcherSize(),
7350
+ viewportSize()
7351
+ )
7352
+ );
7353
+ };
7354
+ const handlePointerUp = (event) => {
7355
+ if (!dragOrigin.current) return;
7356
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
7357
+ dragOrigin.current = null;
7358
+ dragOffset.current = null;
7359
+ if (draggedRef.current && placement) {
7360
+ writeStoredPosition(storageKey, placement, viewportSize());
7361
+ } else {
7362
+ controller.toggleOpen();
7363
+ }
7364
+ draggedRef.current = false;
7365
+ };
7201
7366
  React2.useEffect(() => {
7202
7367
  if (launcherMode !== "inline" || !state.open) {
7203
7368
  return;
@@ -7251,18 +7416,37 @@ function SponsorRelayFab(props) {
7251
7416
  const inlineButtonBorder = `1px solid ${themedLauncherBorder}`;
7252
7417
  const inlineBadgeBackground = themedLauncherBadgeBackground;
7253
7418
  const inlineBadgeBorder = `1px solid ${themedLauncherBadgeBorder}`;
7419
+ const floatingPanelStyle = () => {
7420
+ if (!placement || typeof window === "undefined") {
7421
+ return {
7422
+ ...basePanelStyle,
7423
+ right: "1.4rem",
7424
+ bottom: "11.5rem",
7425
+ maxHeight: "calc(100vh - 12.5rem)"
7426
+ };
7427
+ }
7428
+ const panelRect = panelRef.current?.getBoundingClientRect();
7429
+ const anchored = anchorPanelToLauncher(
7430
+ { ...placement, ...launcherSize() },
7431
+ { width: panelRect?.width ?? 448, height: panelRect?.height ?? 520 },
7432
+ viewportSize()
7433
+ );
7434
+ return {
7435
+ ...basePanelStyle,
7436
+ left: anchored.left,
7437
+ top: anchored.top,
7438
+ right: "auto",
7439
+ bottom: "auto",
7440
+ maxHeight: anchored.maxHeight
7441
+ };
7442
+ };
7254
7443
  const panelStyle = launcherMode === "inline" ? inlinePanelStyle ?? {
7255
7444
  ...basePanelStyle,
7256
7445
  top: "4.75rem",
7257
7446
  right: "1rem",
7258
7447
  maxHeight: "calc(100vh - 6rem)",
7259
7448
  width: "min(30rem, calc(100vw - 1.5rem))"
7260
- } : {
7261
- ...basePanelStyle,
7262
- right: "1.4rem",
7263
- bottom: "11.5rem",
7264
- maxHeight: "calc(100vh - 12.5rem)"
7265
- };
7449
+ } : floatingPanelStyle();
7266
7450
  const launcherLabel = deploymentLauncherLabel(state, compactInlineLabel, launcherMode);
7267
7451
  const rootfsBlocked = state.rootfsHealth.tone === "error";
7268
7452
  const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : rootfsBlocked ? "Deployment blocked \u2014 Rootfs unavailable" : deploymentButtonLabel(state) : "Connect MetaMask";
@@ -7273,7 +7457,11 @@ function SponsorRelayFab(props) {
7273
7457
  {
7274
7458
  ref: launcherRef,
7275
7459
  type: "button",
7276
- onClick: () => controller.toggleOpen(),
7460
+ onClick: draggable ? void 0 : () => controller.toggleOpen(),
7461
+ onPointerDown: handlePointerDown,
7462
+ onPointerMove: handlePointerMove,
7463
+ onPointerUp: handlePointerUp,
7464
+ onPointerCancel: handlePointerUp,
7277
7465
  onMouseEnter: () => setHovered(true),
7278
7466
  onMouseLeave: () => setHovered(false),
7279
7467
  onFocus: () => setHovered(true),
@@ -7281,8 +7469,13 @@ function SponsorRelayFab(props) {
7281
7469
  title: indicator.detail ?? deploymentPanelTitle(state),
7282
7470
  style: {
7283
7471
  position: launcherMode === "floating" ? "fixed" : "relative",
7284
- right: launcherMode === "floating" ? "1.4rem" : void 0,
7285
- bottom: launcherMode === "floating" ? "5.8rem" : void 0,
7472
+ left: launcherMode === "floating" && placement ? placement.left : void 0,
7473
+ top: launcherMode === "floating" && placement ? placement.top : void 0,
7474
+ right: launcherMode === "floating" && !placement ? "1.4rem" : void 0,
7475
+ bottom: launcherMode === "floating" && !placement ? "5.8rem" : void 0,
7476
+ // Without this the browser scrolls the page instead of moving the button.
7477
+ touchAction: draggable ? "none" : void 0,
7478
+ userSelect: draggable ? "none" : void 0,
7286
7479
  zIndex: launcherMode === "floating" ? 1e4 : "auto",
7287
7480
  borderRadius: "999px",
7288
7481
  border: launcherMode === "floating" ? `1px solid ${themedLauncherBorder}` : inlineButtonBorder,
@@ -7412,7 +7605,7 @@ function SponsorRelayFab(props) {
7412
7605
  }
7413
7606
  }
7414
7607
  ),
7415
- /* @__PURE__ */ jsxs("aside", { style: panelStyle, children: [
7608
+ /* @__PURE__ */ jsxs("aside", { ref: panelRef, style: panelStyle, children: [
7416
7609
  /* @__PURE__ */ jsxs(
7417
7610
  "div",
7418
7611
  {
package/shared/index.d.ts CHANGED
@@ -197,6 +197,75 @@ interface DeploymentProgressEvent {
197
197
  }
198
198
  type DeploymentProgressListener = (event: DeploymentProgressEvent) => void;
199
199
 
200
+ /**
201
+ * Geometry for a launcher the consumer can place and the user can drag.
202
+ *
203
+ * Kept free of DOM access on purpose: the framework layers read rects and write
204
+ * styles, this module only does the arithmetic, which is what makes the awkward
205
+ * parts — the tap/drag threshold, staying on screen after a rotation — testable
206
+ * without a browser.
207
+ */
208
+ type FabCorner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
209
+ interface FabPoint {
210
+ x: number;
211
+ y: number;
212
+ }
213
+ interface FabSize {
214
+ width: number;
215
+ height: number;
216
+ }
217
+ interface FabViewport {
218
+ width: number;
219
+ height: number;
220
+ }
221
+ /** Absolute top-left of the launcher, in viewport coordinates. */
222
+ interface FabPlacement {
223
+ left: number;
224
+ top: number;
225
+ }
226
+ declare const DEFAULT_FAB_CORNER: FabCorner;
227
+ /** Distance from the viewport edge, matching the previous `1.4rem` inset. */
228
+ declare const FAB_EDGE_MARGIN = 22;
229
+ /**
230
+ * Pointer travel, in pixels, before a press becomes a drag.
231
+ *
232
+ * Not zero: fingers are not steady, so a strict zero turns every tap into a
233
+ * one-pixel drag and the click never fires.
234
+ */
235
+ declare const FAB_DRAG_THRESHOLD = 6;
236
+ declare function dragExceedsThreshold(start: FabPoint, current: FabPoint, threshold?: number): boolean;
237
+ /** Where a named corner puts a launcher of this size in this viewport. */
238
+ declare function resolveCornerPlacement(corner: FabCorner, size: FabSize, viewport: FabViewport, margin?: number): FabPlacement;
239
+ /**
240
+ * Keep the launcher reachable. A viewport that shrinks — rotation, a desktop
241
+ * window dragged narrow, a mobile keyboard — would otherwise strand a dragged
242
+ * button off-screen with no way to bring it back.
243
+ */
244
+ declare function clampToViewport(placement: FabPlacement, size: FabSize, viewport: FabViewport, margin?: number): FabPlacement;
245
+ /** Which way the panel should open, given where the launcher sits. */
246
+ type FabPanelSide = 'above' | 'below';
247
+ interface FabPanelPlacement extends FabPlacement {
248
+ side: FabPanelSide;
249
+ maxHeight: number;
250
+ }
251
+ /**
252
+ * Hang the panel off the launcher instead of off its own hardcoded corner.
253
+ *
254
+ * The panel opens away from the nearer edge, so a launcher dragged to the top
255
+ * of the screen drops its panel downward rather than off-screen upward.
256
+ */
257
+ declare function anchorPanelToLauncher(launcher: FabPlacement & FabSize, panel: FabSize, viewport: FabViewport, gap?: number, margin?: number): FabPanelPlacement;
258
+ interface StoredPosition extends FabPlacement {
259
+ /** Viewport the position was captured in, so a resize can be detected. */
260
+ viewport?: FabViewport;
261
+ }
262
+ /**
263
+ * Persistence is opt-in through a consumer-supplied key: a library that writes
264
+ * to localStorage unasked is a library that collides with its host.
265
+ */
266
+ declare function readStoredPosition(key: string | null | undefined): StoredPosition | null;
267
+ declare function writeStoredPosition(key: string | null | undefined, placement: FabPlacement, viewport?: FabViewport): void;
268
+
200
269
  type SponsorRelayHealthTone = 'ok' | 'caution' | 'error' | 'idle';
201
270
  interface SponsorRelayUcanStoreBootstrapInput {
202
271
  adminDid: string;
@@ -231,6 +300,20 @@ interface SponsorRelayProps {
231
300
  crnSource?: CrnSourcePreference;
232
301
  schedulerApiHost?: string;
233
302
  twoN6ApiHost?: string;
303
+ /**
304
+ * Which corner the floating launcher starts in. Consumers could not place it
305
+ * at all before: the launcher is `position: fixed`, so a wrapper around it
306
+ * collapses to 0x0 and has no box to position or drag.
307
+ */
308
+ position?: FabCorner;
309
+ /** Let the user drag the launcher. The panel follows it. */
310
+ draggable?: boolean;
311
+ /**
312
+ * localStorage key for the dragged position. Omit it and nothing is
313
+ * persisted -- a library that writes to storage unasked collides with its
314
+ * host.
315
+ */
316
+ positionStorageKey?: string;
234
317
  }
235
318
  interface SponsorRelayWalletState {
236
319
  connected: boolean;
@@ -397,7 +480,7 @@ declare const STALE_INSTANCE_ALLOCATION_COOLDOWN_MS: number;
397
480
  declare const RECENT_INSTANCE_RUNTIME_GRACE_MS: number;
398
481
  declare const DEPLOYMENT_PENDING_WARNING_MS: number;
399
482
 
400
- declare const UI_PACKAGE_VERSION = "0.9.1";
483
+ declare const UI_PACKAGE_VERSION = "0.9.4";
401
484
 
402
485
  interface RootfsRequiredPortForward {
403
486
  port: number;
@@ -433,6 +516,30 @@ declare function connectWallet(provider?: EthereumProviderLike | null): Promise<
433
516
  declare function personalSign(address: string, message: string, provider?: EthereumProviderLike | null): Promise<string>;
434
517
  declare function watchWallet(onChange: () => void, provider?: EthereumProviderLike | null): () => void;
435
518
 
519
+ /**
520
+ * What a request was, for an error that otherwise says nothing.
521
+ *
522
+ * `fetch` rejects with a bare `TypeError: Failed to fetch` for every network
523
+ * failure — no URL, no method, no cause. Carried up through the deploy path
524
+ * that becomes "Relay Button deployment failed: Failed to fetch", which is
525
+ * where a run of this project ended with no way to tell which of a dozen calls
526
+ * had failed.
527
+ *
528
+ * Exported for its test; `init.method` is read defensively because a call site
529
+ * may pass none.
530
+ */
531
+ declare function describeRequest(input: RequestInfo | URL, init?: RequestInit): string;
532
+ /**
533
+ * The failure message, with the step it happened in.
534
+ *
535
+ * A deployment walks two dozen calls across Aleph, a CRN and a guest VM. When
536
+ * one of them fails with a message that names none of them — `Failed to fetch`
537
+ * is the standard case — the step is the only thing that narrows it down, and
538
+ * it is already known.
539
+ *
540
+ * Exported for its test.
541
+ */
542
+ declare function deploymentFailureMessage(error: unknown, stage: string | null | undefined): string;
436
543
  /**
437
544
  * Normalise the two address documents in the fleet into one list of groups.
438
545
  *
@@ -467,6 +574,7 @@ declare class SponsorRelayController {
467
574
  private emit;
468
575
  private trace;
469
576
  private patch;
577
+ private lastProgressLabel;
470
578
  private emitProgress;
471
579
  private configureRelayBootstrapRegistration;
472
580
  private probeRelayReachableOverProxy;
@@ -520,4 +628,4 @@ interface DeploymentProgressEmitter {
520
628
  }
521
629
  declare function createDeploymentProgressEmitter(): DeploymentProgressEmitter;
522
630
 
523
- export { type CompactBootstrapRegistrationRecord, type CompactInstanceDetails, type CompactInstanceRecord, DEFAULT_INSTANCE_NAME, DEFAULT_MANIFEST_URL, DEFAULT_TIER_ID, DEFAULT_UCAN_STORE_ALLOWED_CAPABILITIES, DEFAULT_UCAN_STORE_MAX_DELEGATION_EXPIRATION_SECONDS, DEFAULT_UCAN_STORE_PWA_ORIGIN, DEFAULT_UCAN_STORE_SERVICE_DID, DEFAULT_UCAN_STORE_SERVICE_ORIGIN, DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS, DEPLOYMENT_PENDING_WARNING_MS, type DeploymentProgressEmitter, type EthereumProviderLike, IDLE_DEPLOYMENT_PROGRESS, INSTANCE_RUNTIME_PENDING_REFRESH_MS, INSTANCE_RUNTIME_READY_REFRESH_MS, INSTANCE_RUNTIME_STALE_REFRESH_MS, MANIFEST_SOURCE_REFRESH_DEBOUNCE_MS, RECENT_INSTANCE_RUNTIME_GRACE_MS, REFRESH_INTERVAL_MS, ROOTFS_MISSING_STATE, STALE_INSTANCE_ALLOCATION_COOLDOWN_MS, type SponsorRelayAddressGroup, SponsorRelayController, type SponsorRelayHealthTone, type SponsorRelayInstanceAddresses, type SponsorRelayPricingSummary, type SponsorRelayProps, type SponsorRelayRootfsHealth, type SponsorRelayState, type SponsorRelaySubscriber, type SponsorRelayUcanStoreBootstrapInput, type SponsorRelayWalletState, UI_PACKAGE_VERSION, buildSshCommand, connectWallet, createDeploymentProgressEmitter, createSponsorRelayController, formatDateTime, formatNumber, formatTierSpecLabel, getEthereumProvider, joinMappedPorts, joinRequiredPortForwards, parseInstanceAddressPayload, personalSign, resolveManifestSource, rootfsHealth, shortHash, toChecksumAddress, watchWallet };
631
+ export { type CompactBootstrapRegistrationRecord, type CompactInstanceDetails, type CompactInstanceRecord, DEFAULT_FAB_CORNER, DEFAULT_INSTANCE_NAME, DEFAULT_MANIFEST_URL, DEFAULT_TIER_ID, DEFAULT_UCAN_STORE_ALLOWED_CAPABILITIES, DEFAULT_UCAN_STORE_MAX_DELEGATION_EXPIRATION_SECONDS, DEFAULT_UCAN_STORE_PWA_ORIGIN, DEFAULT_UCAN_STORE_SERVICE_DID, DEFAULT_UCAN_STORE_SERVICE_ORIGIN, DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS, DEPLOYMENT_PENDING_WARNING_MS, type DeploymentProgressEmitter, type EthereumProviderLike, FAB_DRAG_THRESHOLD, FAB_EDGE_MARGIN, type FabCorner, type FabPanelPlacement, type FabPanelSide, type FabPlacement, type FabPoint, type FabSize, type FabViewport, IDLE_DEPLOYMENT_PROGRESS, INSTANCE_RUNTIME_PENDING_REFRESH_MS, INSTANCE_RUNTIME_READY_REFRESH_MS, INSTANCE_RUNTIME_STALE_REFRESH_MS, MANIFEST_SOURCE_REFRESH_DEBOUNCE_MS, RECENT_INSTANCE_RUNTIME_GRACE_MS, REFRESH_INTERVAL_MS, ROOTFS_MISSING_STATE, STALE_INSTANCE_ALLOCATION_COOLDOWN_MS, type SponsorRelayAddressGroup, SponsorRelayController, type SponsorRelayHealthTone, type SponsorRelayInstanceAddresses, type SponsorRelayPricingSummary, type SponsorRelayProps, type SponsorRelayRootfsHealth, type SponsorRelayState, type SponsorRelaySubscriber, type SponsorRelayUcanStoreBootstrapInput, type SponsorRelayWalletState, UI_PACKAGE_VERSION, anchorPanelToLauncher, buildSshCommand, clampToViewport, connectWallet, createDeploymentProgressEmitter, createSponsorRelayController, deploymentFailureMessage, describeRequest, dragExceedsThreshold, formatDateTime, formatNumber, formatTierSpecLabel, getEthereumProvider, joinMappedPorts, joinRequiredPortForwards, parseInstanceAddressPayload, personalSign, readStoredPosition, resolveCornerPlacement, resolveManifestSource, rootfsHealth, shortHash, toChecksumAddress, watchWallet, writeStoredPosition };
package/shared/index.js CHANGED
@@ -1,3 +1,57 @@
1
+ // src/shared/fab-position.ts
2
+ var DEFAULT_FAB_CORNER = "bottom-right";
3
+ var FAB_EDGE_MARGIN = 22;
4
+ var FAB_DRAG_THRESHOLD = 6;
5
+ function dragExceedsThreshold(start, current, threshold = FAB_DRAG_THRESHOLD) {
6
+ const dx = current.x - start.x;
7
+ const dy = current.y - start.y;
8
+ return Math.hypot(dx, dy) >= threshold;
9
+ }
10
+ function resolveCornerPlacement(corner, size, viewport, margin = FAB_EDGE_MARGIN) {
11
+ const left = corner.endsWith("left") ? margin : viewport.width - size.width - margin;
12
+ const top = corner.startsWith("top") ? margin : viewport.height - size.height - margin;
13
+ return clampToViewport({ left, top }, size, viewport, margin);
14
+ }
15
+ function clampToViewport(placement, size, viewport, margin = FAB_EDGE_MARGIN) {
16
+ const maxLeft = Math.max(margin, viewport.width - size.width - margin);
17
+ const maxTop = Math.max(margin, viewport.height - size.height - margin);
18
+ return {
19
+ left: Math.min(Math.max(placement.left, margin), maxLeft),
20
+ top: Math.min(Math.max(placement.top, margin), maxTop)
21
+ };
22
+ }
23
+ function anchorPanelToLauncher(launcher, panel, viewport, gap = 12, margin = FAB_EDGE_MARGIN) {
24
+ const launcherCentre = launcher.top + launcher.height / 2;
25
+ const side = launcherCentre < viewport.height / 2 ? "below" : "above";
26
+ const top = side === "below" ? launcher.top + launcher.height + gap : launcher.top - gap - panel.height;
27
+ const preferredLeft = launcher.left + launcher.width / 2 < viewport.width / 2 ? launcher.left : launcher.left + launcher.width - panel.width;
28
+ const clamped = clampToViewport({ left: preferredLeft, top }, panel, viewport, margin);
29
+ const available = side === "below" ? viewport.height - clamped.top - margin : launcher.top - gap - margin;
30
+ return {
31
+ ...clamped,
32
+ side,
33
+ maxHeight: Math.max(0, available)
34
+ };
35
+ }
36
+ function readStoredPosition(key) {
37
+ if (!key) return null;
38
+ try {
39
+ const raw = globalThis.localStorage?.getItem(key);
40
+ if (!raw) return null;
41
+ const parsed = JSON.parse(raw);
42
+ return Number.isFinite(parsed?.left) && Number.isFinite(parsed?.top) ? parsed : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function writeStoredPosition(key, placement, viewport) {
48
+ if (!key) return;
49
+ try {
50
+ globalThis.localStorage?.setItem(key, JSON.stringify({ ...placement, viewport }));
51
+ } catch {
52
+ }
53
+ }
54
+
1
55
  // src/shared/constants.ts
2
56
  var DEFAULT_INSTANCE_NAME = "sponsor-relay";
3
57
  var DEFAULT_MANIFEST_URL = "./rootfs-manifest.json";
@@ -42,7 +96,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
42
96
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
43
97
 
44
98
  // src/shared/package-version.ts
45
- var UI_PACKAGE_VERSION = "0.9.1";
99
+ var UI_PACKAGE_VERSION = "0.9.4";
46
100
 
47
101
  // src/shared/format.ts
48
102
  function shortHash(value, head = 8, tail = 6) {
@@ -2776,7 +2830,8 @@ function browserMultiaddrTransport(addr) {
2776
2830
  }
2777
2831
  function selectCompactRelayBootstrapMultiaddrs(addrs, limit = DEFAULT_BOOTSTRAP_COMPACT_MULTIADDR_LIMIT) {
2778
2832
  const entries = filterPublicMultiaddrs(addrs, {
2779
- browserDialableOnly: true
2833
+ browserDialableOnly: true,
2834
+ allowInsecureWebSockets: true
2780
2835
  }).map((addr, index) => ({
2781
2836
  addr,
2782
2837
  index,
@@ -2846,12 +2901,23 @@ function isLocalHostname(host) {
2846
2901
  function hasPeerId(addr) {
2847
2902
  return splitMultiaddr(addr).includes("p2p");
2848
2903
  }
2849
- function isBrowserDialableMultiaddr(addr) {
2904
+ function isSecureWebSocketMultiaddr(addr) {
2905
+ const parts = splitMultiaddr(addr.toLowerCase());
2906
+ if (parts.includes("wss")) return true;
2907
+ const wsIndex = parts.indexOf("ws");
2908
+ return wsIndex > 0 && parts.lastIndexOf("tls", wsIndex) >= 0;
2909
+ }
2910
+ function isWebSocketMultiaddr(addr) {
2911
+ const parts = splitMultiaddr(addr.toLowerCase());
2912
+ return parts.includes("ws") || parts.includes("wss");
2913
+ }
2914
+ function isBrowserDialableMultiaddr(addr, options = {}) {
2850
2915
  const normalized = addr.toLowerCase();
2851
2916
  if (normalized.includes("/webtransport") || normalized.includes("/webrtc-direct")) {
2852
2917
  return normalized.includes("/certhash/");
2853
2918
  }
2854
- return normalized.includes("/ws") || normalized.includes("/wss");
2919
+ if (!isWebSocketMultiaddr(addr)) return false;
2920
+ return (options.allowInsecureWebSockets ?? false) || isSecureWebSocketMultiaddr(addr);
2855
2921
  }
2856
2922
  function dedupeMultiaddrs(addrs) {
2857
2923
  const seen = /* @__PURE__ */ new Set();
@@ -2885,7 +2951,9 @@ function filterPublicMultiaddrs(addrs, options = {}) {
2885
2951
  return dedupeMultiaddrs(addrs).filter((addr) => {
2886
2952
  if (!isPublicMultiaddr(addr)) return false;
2887
2953
  if (options.requirePeerId !== false && !hasPeerId(addr)) return false;
2888
- if (options.browserDialableOnly && !isBrowserDialableMultiaddr(addr)) {
2954
+ if (options.browserDialableOnly && !isBrowserDialableMultiaddr(addr, {
2955
+ allowInsecureWebSockets: options.allowInsecureWebSockets
2956
+ })) {
2889
2957
  return false;
2890
2958
  }
2891
2959
  return true;
@@ -3000,7 +3068,8 @@ function buildRelayBootstrapPostContent(args) {
3000
3068
  const updatedAt = args.now ?? Date.now();
3001
3069
  const fullMultiaddrs = filterPublicMultiaddrs(args.multiaddrs);
3002
3070
  const fullBrowserMultiaddrs = args.browserMultiaddrs ? filterPublicMultiaddrs(args.browserMultiaddrs, {
3003
- browserDialableOnly: true
3071
+ browserDialableOnly: true,
3072
+ allowInsecureWebSockets: true
3004
3073
  }) : void 0;
3005
3074
  const compactMultiaddrs = selectCompactRelayBootstrapMultiaddrs(
3006
3075
  fullBrowserMultiaddrs && fullBrowserMultiaddrs.length > 0 ? fullBrowserMultiaddrs : fullMultiaddrs,
@@ -3082,7 +3151,8 @@ async function signRelayBootstrapAuthorization(args) {
3082
3151
  async function signRelayBootstrapProof(args) {
3083
3152
  const fullMultiaddrs = filterPublicMultiaddrs(args.multiaddrs);
3084
3153
  const fullBrowserMultiaddrs = args.browserMultiaddrs ? filterPublicMultiaddrs(args.browserMultiaddrs, {
3085
- browserDialableOnly: true
3154
+ browserDialableOnly: true,
3155
+ allowInsecureWebSockets: true
3086
3156
  }) : void 0;
3087
3157
  const compactMultiaddrs = selectCompactRelayBootstrapMultiaddrs(
3088
3158
  fullBrowserMultiaddrs && fullBrowserMultiaddrs.length > 0 ? fullBrowserMultiaddrs : fullMultiaddrs,
@@ -3884,12 +3954,30 @@ function validateUcanStoreBootstrapPackage(value) {
3884
3954
  function deploymentNoun(profile) {
3885
3955
  return profile === "ucan-store" ? "service" : "relay";
3886
3956
  }
3957
+ function describeRequest(input, init) {
3958
+ const method = String(init?.method ?? "GET").toUpperCase();
3959
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : typeof input?.url === "string" ? input.url : "<unknown url>";
3960
+ return `${method} ${url}`;
3961
+ }
3962
+ function deploymentFailureMessage(error, stage) {
3963
+ const message = error instanceof Error ? error.message : String(error);
3964
+ const step = String(stage ?? "").trim();
3965
+ return step ? `${message} (while: ${step})` : message;
3966
+ }
3887
3967
  function asJsonFetch(input, init) {
3888
- return fetch(input, init).then(async (response) => ({
3889
- ok: response.ok,
3890
- status: response.status,
3891
- json: async () => await response.json()
3892
- }));
3968
+ return fetch(input, init).then(
3969
+ async (response) => ({
3970
+ ok: response.ok,
3971
+ status: response.status,
3972
+ json: async () => await response.json()
3973
+ }),
3974
+ (error) => {
3975
+ const reason = error instanceof Error ? error.message : String(error);
3976
+ throw new Error(`${reason} (${describeRequest(input, init)})`, {
3977
+ cause: error
3978
+ });
3979
+ }
3980
+ );
3893
3981
  }
3894
3982
  function isConfirmedGuestSetupResult(value) {
3895
3983
  return Boolean(
@@ -4532,11 +4620,15 @@ var SponsorRelayController = class {
4532
4620
  };
4533
4621
  this.emit();
4534
4622
  }
4623
+ lastProgressLabel = null;
4535
4624
  emitProgress(event) {
4536
4625
  const nextEvent = {
4537
4626
  ...event,
4538
4627
  timestamp: Date.now()
4539
4628
  };
4629
+ if (nextEvent.stage !== "error") {
4630
+ this.lastProgressLabel = nextEvent.label;
4631
+ }
4540
4632
  this.trace(`progress:${nextEvent.stage}`, {
4541
4633
  label: nextEvent.label,
4542
4634
  progress: nextEvent.progress,
@@ -6453,10 +6545,15 @@ var SponsorRelayController = class {
6453
6545
  attemptErrors.length > 0 ? `All compatible CRNs failed. ${attemptErrors.join(" | ")}` : lastError?.message ?? "All compatible CRNs failed."
6454
6546
  );
6455
6547
  } catch (error) {
6456
- this.trace("deploy:error", error);
6548
+ const message = deploymentFailureMessage(error, this.lastProgressLabel);
6549
+ this.trace("deploy:error", {
6550
+ message,
6551
+ stage: this.lastProgressLabel,
6552
+ error
6553
+ });
6457
6554
  this.patch({
6458
6555
  busy: { deploying: false },
6459
- errorText: error instanceof Error ? error.message : String(error),
6556
+ errorText: message,
6460
6557
  statusText: "Deployment failed"
6461
6558
  });
6462
6559
  this.emitProgress({
@@ -6464,7 +6561,7 @@ var SponsorRelayController = class {
6464
6561
  label: "Deployment failed",
6465
6562
  progress: 100,
6466
6563
  status: "error",
6467
- error: error instanceof Error ? error.message : String(error)
6564
+ error: message
6468
6565
  });
6469
6566
  }
6470
6567
  }
@@ -6724,6 +6821,7 @@ function createSponsorRelayController(props = {}) {
6724
6821
  return new SponsorRelayController(props);
6725
6822
  }
6726
6823
  export {
6824
+ DEFAULT_FAB_CORNER,
6727
6825
  DEFAULT_INSTANCE_NAME,
6728
6826
  DEFAULT_MANIFEST_URL,
6729
6827
  DEFAULT_TIER_ID,
@@ -6734,6 +6832,8 @@ export {
6734
6832
  DEFAULT_UCAN_STORE_SERVICE_ORIGIN,
6735
6833
  DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS,
6736
6834
  DEPLOYMENT_PENDING_WARNING_MS,
6835
+ FAB_DRAG_THRESHOLD,
6836
+ FAB_EDGE_MARGIN,
6737
6837
  IDLE_DEPLOYMENT_PROGRESS,
6738
6838
  INSTANCE_RUNTIME_PENDING_REFRESH_MS,
6739
6839
  INSTANCE_RUNTIME_READY_REFRESH_MS,
@@ -6745,10 +6845,15 @@ export {
6745
6845
  STALE_INSTANCE_ALLOCATION_COOLDOWN_MS,
6746
6846
  SponsorRelayController,
6747
6847
  UI_PACKAGE_VERSION,
6848
+ anchorPanelToLauncher,
6748
6849
  buildSshCommand,
6850
+ clampToViewport,
6749
6851
  connectWallet,
6750
6852
  createDeploymentProgressEmitter,
6751
6853
  createSponsorRelayController,
6854
+ deploymentFailureMessage,
6855
+ describeRequest,
6856
+ dragExceedsThreshold,
6752
6857
  formatDateTime,
6753
6858
  formatNumber,
6754
6859
  formatTierSpecLabel,
@@ -6757,9 +6862,12 @@ export {
6757
6862
  joinRequiredPortForwards,
6758
6863
  parseInstanceAddressPayload,
6759
6864
  personalSign,
6865
+ readStoredPosition,
6866
+ resolveCornerPlacement,
6760
6867
  resolveManifestSource,
6761
6868
  rootfsHealth,
6762
6869
  shortHash,
6763
6870
  toChecksumAddress,
6764
- watchWallet
6871
+ watchWallet,
6872
+ writeStoredPosition
6765
6873
  };
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  import { onDestroy, onMount } from 'svelte'
3
3
 
4
- import { UI_PACKAGE_VERSION, createSponsorRelayController, formatDateTime, formatNumber, formatTierSpecLabel, joinMappedPorts, joinRequiredPortForwards, shortHash } from '../shared/index'
4
+ import { UI_PACKAGE_VERSION, anchorPanelToLauncher, clampToViewport, createSponsorRelayController, readStoredPosition, resolveCornerPlacement, writeStoredPosition, formatDateTime, formatNumber, formatTierSpecLabel, joinMappedPorts, joinRequiredPortForwards, shortHash } from '../shared/index'
5
5
  import AccordionSection from './components/AccordionSection.svelte'
6
6
  import CopyButton from './components/CopyButton.svelte'
7
7
  import LauncherButton from './components/LauncherButton.svelte'
@@ -23,6 +23,9 @@ export let apiHosts = undefined
23
23
  export let crnSource = undefined
24
24
  export let schedulerApiHost = undefined
25
25
  export let twoN6ApiHost = undefined
26
+ export let position = 'bottom-right'
27
+ export let draggable = false
28
+ export let positionStorageKey = undefined
26
29
 
27
30
  const controller = createSponsorRelayController({
28
31
  manifestUrl,
@@ -131,13 +134,104 @@ export let apiHosts = undefined
131
134
  .join('\n')
132
135
  }
133
136
 
137
+ // Placement lives here rather than in the launcher because the panel has to
138
+ // be anchored to it, and only this component renders both.
139
+ let launcherEl = null
140
+ let panelEl = null
141
+ let placement = null
142
+ let panelPlacement = null
143
+ let dragOffset = null
144
+
145
+ function viewport() {
146
+ return { width: window.innerWidth, height: window.innerHeight }
147
+ }
148
+
149
+ function launcherSize() {
150
+ const rect = launcherEl?.getBoundingClientRect()
151
+ return { width: rect?.width ?? 180, height: rect?.height ?? 44 }
152
+ }
153
+
154
+ function initPlacement() {
155
+ if (launcherMode !== 'floating' || typeof window === 'undefined') return
156
+ const stored = readStoredPosition(positionStorageKey)
157
+ placement = stored
158
+ ? clampToViewport(stored, launcherSize(), viewport())
159
+ : resolveCornerPlacement(position, launcherSize(), viewport())
160
+ updatePanelPlacement()
161
+ }
162
+
163
+ function updatePanelPlacement() {
164
+ if (!state.open || !placement || launcherMode !== 'floating') {
165
+ panelPlacement = null
166
+ return
167
+ }
168
+ const panelRect = panelEl?.getBoundingClientRect()
169
+ panelPlacement = anchorPanelToLauncher(
170
+ { ...placement, ...launcherSize() },
171
+ { width: panelRect?.width ?? 448, height: panelRect?.height ?? 520 },
172
+ viewport(),
173
+ )
174
+ }
175
+
176
+ function handleDragStart(point) {
177
+ if (!placement) return
178
+ // Remember where inside the button the finger landed, so it does not jump
179
+ // to the pointer on the first move.
180
+ dragOffset = { x: point.x - placement.left, y: point.y - placement.top }
181
+ }
182
+
183
+ function handleDragMove(point) {
184
+ if (!dragOffset) return
185
+ placement = clampToViewport(
186
+ { left: point.x - dragOffset.x, top: point.y - dragOffset.y },
187
+ launcherSize(),
188
+ viewport(),
189
+ )
190
+ updatePanelPlacement()
191
+ }
192
+
193
+ function handleDragEnd(wasDragging) {
194
+ dragOffset = null
195
+ if (wasDragging && placement) writeStoredPosition(positionStorageKey, placement, viewport())
196
+ }
197
+
198
+ function handleViewportChange() {
199
+ if (!placement) return
200
+ placement = clampToViewport(placement, launcherSize(), viewport())
201
+ updatePanelPlacement()
202
+ }
203
+
204
+ $: if (state.open !== undefined) updatePanelPlacement()
205
+
206
+ $: panelStyle = panelPlacement
207
+ ? `left:${panelPlacement.left}px;top:${panelPlacement.top}px;right:auto;bottom:auto;max-height:${panelPlacement.maxHeight}px;`
208
+ : ''
209
+
134
210
  onMount(async () => {
135
211
  const unsubscribe = controller.subscribe((next) => {
136
212
  state = next
137
213
  })
138
214
 
215
+ // Before `controller.init()`, not after it, and the order is the whole
216
+ // feature. Placement reads storage and the viewport — no network — while
217
+ // `init()` fetches the manifest and the account's instances. Behind that
218
+ // await, `placement` stays null for as long as Aleph takes to answer, and
219
+ // `handleDragStart` returns early on a null placement: the launcher simply
220
+ // cannot be dragged. Measured in simple-todo: still immovable 20 seconds
221
+ // after the app was ready, with pointerdown, eight pointermoves and
222
+ // pointerup all arriving at the button.
223
+ //
224
+ // The React build never had this — it resolves placement in its own effect.
225
+ initPlacement()
226
+
139
227
  await controller.init()
140
- return unsubscribe
228
+ window.addEventListener('resize', handleViewportChange)
229
+ window.addEventListener('orientationchange', handleViewportChange)
230
+ return () => {
231
+ window.removeEventListener('resize', handleViewportChange)
232
+ window.removeEventListener('orientationchange', handleViewportChange)
233
+ unsubscribe()
234
+ }
141
235
  })
142
236
 
143
237
  onDestroy(() => {
@@ -145,14 +239,24 @@ export let apiHosts = undefined
145
239
  })
146
240
  </script>
147
241
 
148
- <LauncherButton open={state.open} onToggle={() => controller.toggleOpen()} mode={launcherMode} />
242
+ <LauncherButton
243
+ bind:element={launcherEl}
244
+ open={state.open}
245
+ onToggle={() => controller.toggleOpen()}
246
+ mode={launcherMode}
247
+ {placement}
248
+ {draggable}
249
+ onDragStart={handleDragStart}
250
+ onDragMove={handleDragMove}
251
+ onDragEnd={handleDragEnd}
252
+ />
149
253
 
150
254
  {#if state.open}
151
255
  <div class="backdrop" on:click={() => controller.setOpen(false)}></div>
152
256
  {/if}
153
257
 
154
258
  {#if state.open}
155
- <aside class="panel">
259
+ <aside class="panel" bind:this={panelEl} style={panelStyle}>
156
260
  <div class="panel-head">
157
261
  <div>
158
262
  <p class="eyebrow">
@@ -1,10 +1,68 @@
1
1
  <script>
2
+ import { dragExceedsThreshold } from '../../shared/index'
3
+
2
4
  export let open = false
3
5
  export let onToggle = () => {}
4
6
  export let mode = 'floating'
7
+ /** Absolute placement from the parent, or null to use the CSS corner default. */
8
+ export let placement = null
9
+ export let draggable = false
10
+ /** Called with the pointer position while dragging; the parent owns the maths. */
11
+ export let onDragStart = () => {}
12
+ export let onDragMove = () => {}
13
+ export let onDragEnd = () => {}
14
+ /** Bound by the parent so it can measure the launcher to anchor the panel. */
15
+ export let element = null
16
+
17
+ let dragging = false
18
+ let pressOrigin = null
19
+
20
+ function handlePointerDown(event) {
21
+ if (!draggable || mode !== 'floating' || event.button > 0) return
22
+ pressOrigin = { x: event.clientX, y: event.clientY }
23
+ dragging = false
24
+ event.currentTarget.setPointerCapture?.(event.pointerId)
25
+ onDragStart(pressOrigin)
26
+ }
27
+
28
+ function handlePointerMove(event) {
29
+ if (!pressOrigin) return
30
+ const point = { x: event.clientX, y: event.clientY }
31
+ // Only become a drag once the pointer has travelled far enough; below the
32
+ // threshold this is still a press that should open the panel.
33
+ if (!dragging && !dragExceedsThreshold(pressOrigin, point)) return
34
+ dragging = true
35
+ onDragMove(point)
36
+ }
37
+
38
+ function handlePointerUp(event) {
39
+ if (!pressOrigin) return
40
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
41
+ const wasDragging = dragging
42
+ pressOrigin = null
43
+ dragging = false
44
+ onDragEnd(wasDragging)
45
+ // A drag must not also toggle the panel; a tap must.
46
+ if (!wasDragging) onToggle()
47
+ }
48
+
49
+ $: placementStyle =
50
+ placement && mode === 'floating'
51
+ ? `left:${placement.left}px;top:${placement.top}px;right:auto;bottom:auto;`
52
+ : ''
5
53
  </script>
6
54
 
7
- <button class={`launcher ${mode} ${open ? 'open' : ''}`} type="button" on:click={onToggle}>
55
+ <button
56
+ bind:this={element}
57
+ class={`launcher ${mode} ${open ? 'open' : ''} ${draggable ? 'draggable' : ''}`}
58
+ style={placementStyle}
59
+ type="button"
60
+ on:pointerdown={handlePointerDown}
61
+ on:pointermove={handlePointerMove}
62
+ on:pointerup={handlePointerUp}
63
+ on:pointercancel={handlePointerUp}
64
+ on:click={draggable ? undefined : onToggle}
65
+ >
8
66
  <svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
9
67
  <line x1="17" y1="15.4" x2="19.6" y2="13" class="mark-link" stroke-width="2.4" stroke-linecap="round" />
10
68
  <circle cx="11.5" cy="20.5" r="7" class="mark-node" />
@@ -53,6 +111,12 @@
53
111
  z-index: 10000;
54
112
  }
55
113
 
114
+ .launcher.draggable {
115
+ /* Without this the browser scrolls the page instead of moving the button. */
116
+ touch-action: none;
117
+ user-select: none;
118
+ }
119
+
56
120
  .launcher.inline {
57
121
  position: relative;
58
122
  z-index: auto;