@le-space/ui 0.9.1 → 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.1",
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.1",
48
- "@le-space/core": "0.9.1",
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.1";
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) {
@@ -7169,6 +7222,14 @@ function SponsorRelayFab(props) {
7169
7222
  }
7170
7223
  };
7171
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);
7172
7233
  const [inlinePanelStyle, setInlinePanelStyle] = React2.useState(null);
7173
7234
  const [compactInlineLabel, setCompactInlineLabel] = React2.useState(false);
7174
7235
  React2.useEffect(() => {
@@ -7198,6 +7259,67 @@ function SponsorRelayFab(props) {
7198
7259
  window.clearTimeout(timeout);
7199
7260
  };
7200
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
+ };
7201
7323
  React2.useEffect(() => {
7202
7324
  if (launcherMode !== "inline" || !state.open) {
7203
7325
  return;
@@ -7251,18 +7373,37 @@ function SponsorRelayFab(props) {
7251
7373
  const inlineButtonBorder = `1px solid ${themedLauncherBorder}`;
7252
7374
  const inlineBadgeBackground = themedLauncherBadgeBackground;
7253
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
+ };
7254
7400
  const panelStyle = launcherMode === "inline" ? inlinePanelStyle ?? {
7255
7401
  ...basePanelStyle,
7256
7402
  top: "4.75rem",
7257
7403
  right: "1rem",
7258
7404
  maxHeight: "calc(100vh - 6rem)",
7259
7405
  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
- };
7406
+ } : floatingPanelStyle();
7266
7407
  const launcherLabel = deploymentLauncherLabel(state, compactInlineLabel, launcherMode);
7267
7408
  const rootfsBlocked = state.rootfsHealth.tone === "error";
7268
7409
  const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : rootfsBlocked ? "Deployment blocked \u2014 Rootfs unavailable" : deploymentButtonLabel(state) : "Connect MetaMask";
@@ -7273,7 +7414,11 @@ function SponsorRelayFab(props) {
7273
7414
  {
7274
7415
  ref: launcherRef,
7275
7416
  type: "button",
7276
- onClick: () => controller.toggleOpen(),
7417
+ onClick: draggable ? void 0 : () => controller.toggleOpen(),
7418
+ onPointerDown: handlePointerDown,
7419
+ onPointerMove: handlePointerMove,
7420
+ onPointerUp: handlePointerUp,
7421
+ onPointerCancel: handlePointerUp,
7277
7422
  onMouseEnter: () => setHovered(true),
7278
7423
  onMouseLeave: () => setHovered(false),
7279
7424
  onFocus: () => setHovered(true),
@@ -7281,8 +7426,13 @@ function SponsorRelayFab(props) {
7281
7426
  title: indicator.detail ?? deploymentPanelTitle(state),
7282
7427
  style: {
7283
7428
  position: launcherMode === "floating" ? "fixed" : "relative",
7284
- right: launcherMode === "floating" ? "1.4rem" : void 0,
7285
- 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,
7286
7436
  zIndex: launcherMode === "floating" ? 1e4 : "auto",
7287
7437
  borderRadius: "999px",
7288
7438
  border: launcherMode === "floating" ? `1px solid ${themedLauncherBorder}` : inlineButtonBorder,
@@ -7412,7 +7562,7 @@ function SponsorRelayFab(props) {
7412
7562
  }
7413
7563
  }
7414
7564
  ),
7415
- /* @__PURE__ */ jsxs("aside", { style: panelStyle, children: [
7565
+ /* @__PURE__ */ jsxs("aside", { ref: panelRef, style: panelStyle, children: [
7416
7566
  /* @__PURE__ */ jsxs(
7417
7567
  "div",
7418
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.1";
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.1";
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) {
@@ -6724,6 +6778,7 @@ function createSponsorRelayController(props = {}) {
6724
6778
  return new SponsorRelayController(props);
6725
6779
  }
6726
6780
  export {
6781
+ DEFAULT_FAB_CORNER,
6727
6782
  DEFAULT_INSTANCE_NAME,
6728
6783
  DEFAULT_MANIFEST_URL,
6729
6784
  DEFAULT_TIER_ID,
@@ -6734,6 +6789,8 @@ export {
6734
6789
  DEFAULT_UCAN_STORE_SERVICE_ORIGIN,
6735
6790
  DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS,
6736
6791
  DEPLOYMENT_PENDING_WARNING_MS,
6792
+ FAB_DRAG_THRESHOLD,
6793
+ FAB_EDGE_MARGIN,
6737
6794
  IDLE_DEPLOYMENT_PROGRESS,
6738
6795
  INSTANCE_RUNTIME_PENDING_REFRESH_MS,
6739
6796
  INSTANCE_RUNTIME_READY_REFRESH_MS,
@@ -6745,10 +6802,13 @@ export {
6745
6802
  STALE_INSTANCE_ALLOCATION_COOLDOWN_MS,
6746
6803
  SponsorRelayController,
6747
6804
  UI_PACKAGE_VERSION,
6805
+ anchorPanelToLauncher,
6748
6806
  buildSshCommand,
6807
+ clampToViewport,
6749
6808
  connectWallet,
6750
6809
  createDeploymentProgressEmitter,
6751
6810
  createSponsorRelayController,
6811
+ dragExceedsThreshold,
6752
6812
  formatDateTime,
6753
6813
  formatNumber,
6754
6814
  formatTierSpecLabel,
@@ -6757,9 +6817,12 @@ export {
6757
6817
  joinRequiredPortForwards,
6758
6818
  parseInstanceAddressPayload,
6759
6819
  personalSign,
6820
+ readStoredPosition,
6821
+ resolveCornerPlacement,
6760
6822
  resolveManifestSource,
6761
6823
  rootfsHealth,
6762
6824
  shortHash,
6763
6825
  toChecksumAddress,
6764
- watchWallet
6826
+ watchWallet,
6827
+ writeStoredPosition
6765
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;