@le-space/ui 0.9.0 → 0.9.2

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.0",
3
+ "version": "0.9.2",
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.0",
48
- "@le-space/core": "0.9.0",
47
+ "@le-space/browser": "0.9.2",
48
+ "@le-space/core": "0.9.2",
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;
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.0";
99
+ var UI_PACKAGE_VERSION = "0.9.2";
47
100
 
48
101
  // src/shared/format.ts
49
102
  function shortHash(value, head = 8, tail = 6) {
@@ -4116,6 +4169,17 @@ function hostnameOfUrl(value) {
4116
4169
  return null;
4117
4170
  }
4118
4171
  }
4172
+ var INSTANCE_ADDRESS_TIMEOUT_MS = 1e4;
4173
+ function describeInstanceAddressFailure(error, sourceUrl) {
4174
+ const raw = error instanceof Error ? error.message : String(error);
4175
+ if (error instanceof Error && error.name === "AbortError") {
4176
+ return `${sourceUrl} did not answer within ${INSTANCE_ADDRESS_TIMEOUT_MS / 1e3}s. Press Refresh to try again.`;
4177
+ }
4178
+ if (error instanceof TypeError || /failed to fetch|load failed|networkerror/i.test(raw)) {
4179
+ return `${sourceUrl} closed the connection without answering. Relays built before rootfs 0.8.0 do not serve an address document, and their proxy hangs up instead of returning 404 \u2014 redeploying from a current rootfs fixes it. (${raw})`;
4180
+ }
4181
+ return raw;
4182
+ }
4119
4183
  function emptyInstanceAddresses() {
4120
4184
  return {
4121
4185
  status: "idle",
@@ -6488,14 +6552,21 @@ var SponsorRelayController = class {
6488
6552
  sourceUrl,
6489
6553
  error: null
6490
6554
  });
6555
+ const abort = new AbortController();
6556
+ const timeout = globalThis.setTimeout(
6557
+ () => abort.abort(),
6558
+ INSTANCE_ADDRESS_TIMEOUT_MS
6559
+ );
6491
6560
  try {
6492
- const abort = new AbortController();
6493
- const timeout = globalThis.setTimeout(() => abort.abort(), 1e4);
6494
- const response = await fetch(sourceUrl, {
6495
- cache: "no-cache",
6496
- signal: abort.signal
6497
- });
6498
- globalThis.clearTimeout(timeout);
6561
+ let response;
6562
+ try {
6563
+ response = await fetch(sourceUrl, {
6564
+ cache: "no-cache",
6565
+ signal: abort.signal
6566
+ });
6567
+ } finally {
6568
+ globalThis.clearTimeout(timeout);
6569
+ }
6499
6570
  if (response.status === 404) {
6500
6571
  this.patchInstanceAddresses(instanceHash, {
6501
6572
  ...emptyInstanceAddresses(),
@@ -6522,7 +6593,7 @@ var SponsorRelayController = class {
6522
6593
  ...this.state.instanceAddresses[instanceHash] ?? emptyInstanceAddresses(),
6523
6594
  status: "error",
6524
6595
  sourceUrl,
6525
- error: error instanceof Error ? error.message : String(error)
6596
+ error: describeInstanceAddressFailure(error, sourceUrl)
6526
6597
  });
6527
6598
  }
6528
6599
  }
@@ -7151,6 +7222,14 @@ function SponsorRelayFab(props) {
7151
7222
  }
7152
7223
  };
7153
7224
  const launcherRef = React2.useRef(null);
7225
+ const panelRef = React2.useRef(null);
7226
+ const draggable = props.draggable ?? false;
7227
+ const corner = props.position ?? "bottom-right";
7228
+ const storageKey = props.positionStorageKey;
7229
+ const [placement, setPlacement] = React2.useState(null);
7230
+ const dragOrigin = React2.useRef(null);
7231
+ const dragOffset = React2.useRef(null);
7232
+ const draggedRef = React2.useRef(false);
7154
7233
  const [inlinePanelStyle, setInlinePanelStyle] = React2.useState(null);
7155
7234
  const [compactInlineLabel, setCompactInlineLabel] = React2.useState(false);
7156
7235
  React2.useEffect(() => {
@@ -7180,6 +7259,67 @@ function SponsorRelayFab(props) {
7180
7259
  window.clearTimeout(timeout);
7181
7260
  };
7182
7261
  }, [state.deploymentProgress.stage, state.deploymentProgress.timestamp]);
7262
+ const viewportSize = () => ({
7263
+ width: window.innerWidth,
7264
+ height: window.innerHeight
7265
+ });
7266
+ const launcherSize = () => {
7267
+ const rect = launcherRef.current?.getBoundingClientRect();
7268
+ return { width: rect?.width ?? 180, height: rect?.height ?? 44 };
7269
+ };
7270
+ React2.useEffect(() => {
7271
+ if (launcherMode !== "floating") {
7272
+ setPlacement(null);
7273
+ return;
7274
+ }
7275
+ const stored = readStoredPosition(storageKey);
7276
+ setPlacement(
7277
+ stored ? clampToViewport(stored, launcherSize(), viewportSize()) : resolveCornerPlacement(corner, launcherSize(), viewportSize())
7278
+ );
7279
+ const onViewportChange = () => setPlacement(
7280
+ (current) => current ? clampToViewport(current, launcherSize(), viewportSize()) : current
7281
+ );
7282
+ window.addEventListener("resize", onViewportChange);
7283
+ window.addEventListener("orientationchange", onViewportChange);
7284
+ return () => {
7285
+ window.removeEventListener("resize", onViewportChange);
7286
+ window.removeEventListener("orientationchange", onViewportChange);
7287
+ };
7288
+ }, [launcherMode, corner, storageKey]);
7289
+ const handlePointerDown = (event) => {
7290
+ if (!draggable || launcherMode !== "floating" || event.button > 0 || !placement) return;
7291
+ dragOrigin.current = { x: event.clientX, y: event.clientY };
7292
+ dragOffset.current = { x: event.clientX - placement.left, y: event.clientY - placement.top };
7293
+ draggedRef.current = false;
7294
+ event.currentTarget.setPointerCapture?.(event.pointerId);
7295
+ };
7296
+ const handlePointerMove = (event) => {
7297
+ const origin = dragOrigin.current;
7298
+ const offset = dragOffset.current;
7299
+ if (!origin || !offset) return;
7300
+ const point = { x: event.clientX, y: event.clientY };
7301
+ if (!draggedRef.current && !dragExceedsThreshold(origin, point)) return;
7302
+ draggedRef.current = true;
7303
+ setPlacement(
7304
+ clampToViewport(
7305
+ { left: point.x - offset.x, top: point.y - offset.y },
7306
+ launcherSize(),
7307
+ viewportSize()
7308
+ )
7309
+ );
7310
+ };
7311
+ const handlePointerUp = (event) => {
7312
+ if (!dragOrigin.current) return;
7313
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
7314
+ dragOrigin.current = null;
7315
+ dragOffset.current = null;
7316
+ if (draggedRef.current && placement) {
7317
+ writeStoredPosition(storageKey, placement, viewportSize());
7318
+ } else {
7319
+ controller.toggleOpen();
7320
+ }
7321
+ draggedRef.current = false;
7322
+ };
7183
7323
  React2.useEffect(() => {
7184
7324
  if (launcherMode !== "inline" || !state.open) {
7185
7325
  return;
@@ -7233,18 +7373,37 @@ function SponsorRelayFab(props) {
7233
7373
  const inlineButtonBorder = `1px solid ${themedLauncherBorder}`;
7234
7374
  const inlineBadgeBackground = themedLauncherBadgeBackground;
7235
7375
  const inlineBadgeBorder = `1px solid ${themedLauncherBadgeBorder}`;
7376
+ const floatingPanelStyle = () => {
7377
+ if (!placement || typeof window === "undefined") {
7378
+ return {
7379
+ ...basePanelStyle,
7380
+ right: "1.4rem",
7381
+ bottom: "11.5rem",
7382
+ maxHeight: "calc(100vh - 12.5rem)"
7383
+ };
7384
+ }
7385
+ const panelRect = panelRef.current?.getBoundingClientRect();
7386
+ const anchored = anchorPanelToLauncher(
7387
+ { ...placement, ...launcherSize() },
7388
+ { width: panelRect?.width ?? 448, height: panelRect?.height ?? 520 },
7389
+ viewportSize()
7390
+ );
7391
+ return {
7392
+ ...basePanelStyle,
7393
+ left: anchored.left,
7394
+ top: anchored.top,
7395
+ right: "auto",
7396
+ bottom: "auto",
7397
+ maxHeight: anchored.maxHeight
7398
+ };
7399
+ };
7236
7400
  const panelStyle = launcherMode === "inline" ? inlinePanelStyle ?? {
7237
7401
  ...basePanelStyle,
7238
7402
  top: "4.75rem",
7239
7403
  right: "1rem",
7240
7404
  maxHeight: "calc(100vh - 6rem)",
7241
7405
  width: "min(30rem, calc(100vw - 1.5rem))"
7242
- } : {
7243
- ...basePanelStyle,
7244
- right: "1.4rem",
7245
- bottom: "11.5rem",
7246
- maxHeight: "calc(100vh - 12.5rem)"
7247
- };
7406
+ } : floatingPanelStyle();
7248
7407
  const launcherLabel = deploymentLauncherLabel(state, compactInlineLabel, launcherMode);
7249
7408
  const rootfsBlocked = state.rootfsHealth.tone === "error";
7250
7409
  const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : rootfsBlocked ? "Deployment blocked \u2014 Rootfs unavailable" : deploymentButtonLabel(state) : "Connect MetaMask";
@@ -7255,7 +7414,11 @@ function SponsorRelayFab(props) {
7255
7414
  {
7256
7415
  ref: launcherRef,
7257
7416
  type: "button",
7258
- onClick: () => controller.toggleOpen(),
7417
+ onClick: draggable ? void 0 : () => controller.toggleOpen(),
7418
+ onPointerDown: handlePointerDown,
7419
+ onPointerMove: handlePointerMove,
7420
+ onPointerUp: handlePointerUp,
7421
+ onPointerCancel: handlePointerUp,
7259
7422
  onMouseEnter: () => setHovered(true),
7260
7423
  onMouseLeave: () => setHovered(false),
7261
7424
  onFocus: () => setHovered(true),
@@ -7263,8 +7426,13 @@ function SponsorRelayFab(props) {
7263
7426
  title: indicator.detail ?? deploymentPanelTitle(state),
7264
7427
  style: {
7265
7428
  position: launcherMode === "floating" ? "fixed" : "relative",
7266
- right: launcherMode === "floating" ? "1.4rem" : void 0,
7267
- bottom: launcherMode === "floating" ? "5.8rem" : void 0,
7429
+ left: launcherMode === "floating" && placement ? placement.left : void 0,
7430
+ top: launcherMode === "floating" && placement ? placement.top : void 0,
7431
+ right: launcherMode === "floating" && !placement ? "1.4rem" : void 0,
7432
+ bottom: launcherMode === "floating" && !placement ? "5.8rem" : void 0,
7433
+ // Without this the browser scrolls the page instead of moving the button.
7434
+ touchAction: draggable ? "none" : void 0,
7435
+ userSelect: draggable ? "none" : void 0,
7268
7436
  zIndex: launcherMode === "floating" ? 1e4 : "auto",
7269
7437
  borderRadius: "999px",
7270
7438
  border: launcherMode === "floating" ? `1px solid ${themedLauncherBorder}` : inlineButtonBorder,
@@ -7394,7 +7562,7 @@ function SponsorRelayFab(props) {
7394
7562
  }
7395
7563
  }
7396
7564
  ),
7397
- /* @__PURE__ */ jsxs("aside", { style: panelStyle, children: [
7565
+ /* @__PURE__ */ jsxs("aside", { ref: panelRef, style: panelStyle, children: [
7398
7566
  /* @__PURE__ */ jsxs(
7399
7567
  "div",
7400
7568
  {
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.0";
483
+ declare const UI_PACKAGE_VERSION = "0.9.2";
401
484
 
402
485
  interface RootfsRequiredPortForward {
403
486
  port: number;
@@ -520,4 +603,4 @@ interface DeploymentProgressEmitter {
520
603
  }
521
604
  declare function createDeploymentProgressEmitter(): DeploymentProgressEmitter;
522
605
 
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 };
606
+ 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, 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.0";
99
+ var UI_PACKAGE_VERSION = "0.9.2";
46
100
 
47
101
  // src/shared/format.ts
48
102
  function shortHash(value, head = 8, tail = 6) {
@@ -4115,6 +4169,17 @@ function hostnameOfUrl(value) {
4115
4169
  return null;
4116
4170
  }
4117
4171
  }
4172
+ var INSTANCE_ADDRESS_TIMEOUT_MS = 1e4;
4173
+ function describeInstanceAddressFailure(error, sourceUrl) {
4174
+ const raw = error instanceof Error ? error.message : String(error);
4175
+ if (error instanceof Error && error.name === "AbortError") {
4176
+ return `${sourceUrl} did not answer within ${INSTANCE_ADDRESS_TIMEOUT_MS / 1e3}s. Press Refresh to try again.`;
4177
+ }
4178
+ if (error instanceof TypeError || /failed to fetch|load failed|networkerror/i.test(raw)) {
4179
+ return `${sourceUrl} closed the connection without answering. Relays built before rootfs 0.8.0 do not serve an address document, and their proxy hangs up instead of returning 404 \u2014 redeploying from a current rootfs fixes it. (${raw})`;
4180
+ }
4181
+ return raw;
4182
+ }
4118
4183
  function emptyInstanceAddresses() {
4119
4184
  return {
4120
4185
  status: "idle",
@@ -6487,14 +6552,21 @@ var SponsorRelayController = class {
6487
6552
  sourceUrl,
6488
6553
  error: null
6489
6554
  });
6555
+ const abort = new AbortController();
6556
+ const timeout = globalThis.setTimeout(
6557
+ () => abort.abort(),
6558
+ INSTANCE_ADDRESS_TIMEOUT_MS
6559
+ );
6490
6560
  try {
6491
- const abort = new AbortController();
6492
- const timeout = globalThis.setTimeout(() => abort.abort(), 1e4);
6493
- const response = await fetch(sourceUrl, {
6494
- cache: "no-cache",
6495
- signal: abort.signal
6496
- });
6497
- globalThis.clearTimeout(timeout);
6561
+ let response;
6562
+ try {
6563
+ response = await fetch(sourceUrl, {
6564
+ cache: "no-cache",
6565
+ signal: abort.signal
6566
+ });
6567
+ } finally {
6568
+ globalThis.clearTimeout(timeout);
6569
+ }
6498
6570
  if (response.status === 404) {
6499
6571
  this.patchInstanceAddresses(instanceHash, {
6500
6572
  ...emptyInstanceAddresses(),
@@ -6521,7 +6593,7 @@ var SponsorRelayController = class {
6521
6593
  ...this.state.instanceAddresses[instanceHash] ?? emptyInstanceAddresses(),
6522
6594
  status: "error",
6523
6595
  sourceUrl,
6524
- error: error instanceof Error ? error.message : String(error)
6596
+ error: describeInstanceAddressFailure(error, sourceUrl)
6525
6597
  });
6526
6598
  }
6527
6599
  }
@@ -6706,6 +6778,7 @@ function createSponsorRelayController(props = {}) {
6706
6778
  return new SponsorRelayController(props);
6707
6779
  }
6708
6780
  export {
6781
+ DEFAULT_FAB_CORNER,
6709
6782
  DEFAULT_INSTANCE_NAME,
6710
6783
  DEFAULT_MANIFEST_URL,
6711
6784
  DEFAULT_TIER_ID,
@@ -6716,6 +6789,8 @@ export {
6716
6789
  DEFAULT_UCAN_STORE_SERVICE_ORIGIN,
6717
6790
  DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS,
6718
6791
  DEPLOYMENT_PENDING_WARNING_MS,
6792
+ FAB_DRAG_THRESHOLD,
6793
+ FAB_EDGE_MARGIN,
6719
6794
  IDLE_DEPLOYMENT_PROGRESS,
6720
6795
  INSTANCE_RUNTIME_PENDING_REFRESH_MS,
6721
6796
  INSTANCE_RUNTIME_READY_REFRESH_MS,
@@ -6727,10 +6802,13 @@ export {
6727
6802
  STALE_INSTANCE_ALLOCATION_COOLDOWN_MS,
6728
6803
  SponsorRelayController,
6729
6804
  UI_PACKAGE_VERSION,
6805
+ anchorPanelToLauncher,
6730
6806
  buildSshCommand,
6807
+ clampToViewport,
6731
6808
  connectWallet,
6732
6809
  createDeploymentProgressEmitter,
6733
6810
  createSponsorRelayController,
6811
+ dragExceedsThreshold,
6734
6812
  formatDateTime,
6735
6813
  formatNumber,
6736
6814
  formatTierSpecLabel,
@@ -6739,9 +6817,12 @@ export {
6739
6817
  joinRequiredPortForwards,
6740
6818
  parseInstanceAddressPayload,
6741
6819
  personalSign,
6820
+ readStoredPosition,
6821
+ resolveCornerPlacement,
6742
6822
  resolveManifestSource,
6743
6823
  rootfsHealth,
6744
6824
  shortHash,
6745
6825
  toChecksumAddress,
6746
- watchWallet
6826
+ watchWallet,
6827
+ writeStoredPosition
6747
6828
  };
@@ -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,93 @@ 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
 
139
215
  await controller.init()
140
- return unsubscribe
216
+ initPlacement()
217
+ window.addEventListener('resize', handleViewportChange)
218
+ window.addEventListener('orientationchange', handleViewportChange)
219
+ return () => {
220
+ window.removeEventListener('resize', handleViewportChange)
221
+ window.removeEventListener('orientationchange', handleViewportChange)
222
+ unsubscribe()
223
+ }
141
224
  })
142
225
 
143
226
  onDestroy(() => {
@@ -145,14 +228,24 @@ export let apiHosts = undefined
145
228
  })
146
229
  </script>
147
230
 
148
- <LauncherButton open={state.open} onToggle={() => controller.toggleOpen()} mode={launcherMode} />
231
+ <LauncherButton
232
+ bind:element={launcherEl}
233
+ open={state.open}
234
+ onToggle={() => controller.toggleOpen()}
235
+ mode={launcherMode}
236
+ {placement}
237
+ {draggable}
238
+ onDragStart={handleDragStart}
239
+ onDragMove={handleDragMove}
240
+ onDragEnd={handleDragEnd}
241
+ />
149
242
 
150
243
  {#if state.open}
151
244
  <div class="backdrop" on:click={() => controller.setOpen(false)}></div>
152
245
  {/if}
153
246
 
154
247
  {#if state.open}
155
- <aside class="panel">
248
+ <aside class="panel" bind:this={panelEl} style={panelStyle}>
156
249
  <div class="panel-head">
157
250
  <div>
158
251
  <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;