summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/HomeTileGrid.svelte
blob: 40a46c59739c9ec6da49a573ccbe54590fcfbf8b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
<script lang="ts">
  import type { WorkspaceEntry } from "@dispatch/wire";
  import { gsap } from "gsap";
  import { Flip } from "gsap/Flip";
  import { tick } from "svelte";
  import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
  import {
    displacePlace,
    dragAppendLimit,
    type Cell,
    type GridGeometry,
    type Placement,
    type Span,
  } from "../logic/tile-layout";
  import { tileTone, type TileTone } from "../logic/view-model";
  import type { WorkspaceStore } from "../store.svelte";
  import WorkspaceTile from "./WorkspaceTile.svelte";

  gsap.registerPlugin(Flip);

  let {
    geometry,
    placements,
    workspacesById,
    store,
    selectedId = null,
    renamingId = null,
    preview = null,
    hasActive,
    onOpen,
    onSelect,
    onPlace,
    onRenameEnd,
  }: {
    geometry: GridGeometry;
    /** Packed placements of the COMMITTED layout (the drag preview derives). */
    placements: readonly Placement[];
    workspacesById: ReadonlyMap<string, WorkspaceEntry>;
    /** Forwarded to tiles for the inline rename (semantics preserved). */
    store: WorkspaceStore;
    selectedId?: string | null;
    renamingId?: string | null;
    /** The resize picker's transient ghost footprint for this tile. */
    preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null;
    /** Optional port: does the workspace have an active (generating) conversation. */
    hasActive?: (workspaceId: string) => boolean;
    onOpen: (id: string) => void;
    onSelect: (id: string) => void;
    /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */
    onPlace: (id: string, cell: Cell | null) => void;
    onRenameEnd: () => void;
  } = $props();

  // ── Drag/press state machine (pure logic, wired to pointer events) ────────
  const DRAG_THRESHOLD_PX = 6;
  const LONG_PRESS_MS = 500;
  let phase = $state<DragPhase>({ kind: "idle" });
  let longPressTimer: ReturnType<typeof setTimeout> | undefined;
  /** Pointer position where the current press began (the follow transform base). */
  let pressOrigin = $state<{ x: number; y: number } | null>(null);
  /** Where within the pressed tile the pointer grabbed (its follow-transform base). */
  let grabOffset = $state<{ x: number; y: number } | null>(null);
  /** The tile mid-settle after a drop (excluded from FLIP until it lands). */
  let settling = $state<string | null>(null);

  function armLongPress(): void {
    disarmLongPress();
    longPressTimer = setTimeout(() => {
      longPressTimer = undefined;
      dispatch({ type: "long-press" });
    }, LONG_PRESS_MS);
  }

  function disarmLongPress(): void {
    if (longPressTimer !== undefined) {
      clearTimeout(longPressTimer);
      longPressTimer = undefined;
    }
  }

  function dispatch(event: DragEvent): void {
    const wasDragging = phase.kind === "dragging" ? phase.id : null;
    const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX });
    phase = transition.phase;
    const emit = transition.emit;
    if (emit) {
      if (emit.kind === "tap") onOpen(emit.id);
      else if (emit.kind === "lift") onSelect(emit.id);
      else if (emit.kind === "drop") drop(emit.id, emit.targetCell);
    }
    if (phase.kind === "idle") {
      // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above;
      // a CANCEL mid-drag flies the tile back to its committed slot.
      if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging);
      pressOrigin = null;
      grabOffset = null;
    }
    // Keep the long-press armed exactly while a TOUCH press is undecided.
    if (phase.kind === "pressing" && phase.touch) armLongPress();
    else disarmLongPress();
  }

  /** The tile being dragged (or settling after a drop) follows no FLIP. */
  const excludedFromFlip = $derived(
    new Set(
      [phase.kind === "dragging" ? phase.id : null, settling].filter(
        (id): id is string => id !== null,
      ),
    ),
  );

  /** Commit the drop, then settle the tile from the pointer onto its new cell. */
  function drop(id: string, cell: Cell | null): void {
    if (cell !== null) onPlace(id, cell);
    flyBack(id);
  }

  /**
   * Animate the tile's follow transform to identity at its (possibly new)
   * committed slot — the drop settle AND the cancel/Escape fly-back. The tile
   * is excluded from Flip until the animation completes so the two never
   * fight over the same transform.
   */
  function flyBack(id: string): void {
    const el = tileEls.get(id);
    if (!el) return;
    settling = id;
    void tick().then(() => {
      gsap.to(el, {
        x: 0,
        y: 0,
        scale: 1,
        duration: 0.2,
        ease: "power2.out",
        onComplete: () => {
          gsap.set(el, { clearProps: "transform" });
          settling = null;
        },
      });
    });
  }

  /** Interactive targets inside a tile (the rename input) own their gestures. */
  function insideInteractive(target: EventTarget | null): boolean {
    const el = target instanceof HTMLElement ? target : null;
    return el?.closest("input, select, textarea, button, a") !== null;
  }

  /**
   * Drag gestures live on the GRID CONTAINER, not the tiles: the container is
   * never re-parented, never toggles `pointer-events`, and never unmounts, so
   * pointer capture set here survives the whole gesture. (A captured element
   * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an
   * uncaptured pointer can't stream events over empty cells — the empty-cell
   * bug.) Capture also keeps move/up flowing when the pointer leaves the grid.
   */
  function gridPointerDown(e: PointerEvent): void {
    if (e.button !== 0) return;
    const tileEl = (e.target as Element | null)?.closest("[data-tile-id]");
    if (!tileEl || insideInteractive(e.target)) return;
    const id = tileEl.getAttribute("data-tile-id");
    if (id === null) return;
    // Capture mouse/pen so fast drags keep streaming to this container. TOUCH
    // is purposely NOT captured here: an early touch capture can suppress
    // native scrolling (WebKit), and touch already gets the browser's implicit
    // capture — after the long-press lift the moved events bubble up to this
    // container anyway.
    if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId);
    // Where within the tile the pointer grabbed — the follow transform keeps
    // THIS point under the pointer (not the tile's top-left origin).
    const rect = tileEl.getBoundingClientRect();
    grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
    pressOrigin = { x: e.clientX, y: e.clientY };
    dispatch({
      type: "pointer-down",
      id,
      x: e.clientX,
      y: e.clientY,
      touch: e.pointerType === "touch",
    });
  }

  function gridPointerMove(e: PointerEvent): void {
    if (phase.kind === "idle") return;
    dispatch({
      type: "pointer-move",
      x: e.clientX,
      y: e.clientY,
      targetCell: cellAt(e.clientX, e.clientY),
    });
  }

  function gridPointerUp(): void {
    if (phase.kind === "idle") return;
    dispatch({ type: "pointer-up" });
  }

  // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the
  // selection — the committed layout is untouched and the tile flies home.
  function windowKeydown(e: KeyboardEvent): void {
    if (e.key !== "Escape" || phase.kind === "idle") return;
    e.stopPropagation();
    dispatch({ type: "cancel" });
  }

  // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ─────
  let gridEl = $state<HTMLDivElement | undefined>();

  const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
  const pitchY = $derived(geometry.unit.height + geometry.unit.gap);

  /**
   * The drag target's clamp on the growth axis — at most 3 columns (wide) /
   * rows (tall) past the current content edge. Measured on the COMMITTED
   * placements (the drag preview never ratchets the limit mid-drag), so the
   * canvas can't endlessly grow when dragging far right / down.
   */
  const appendLimit = $derived(dragAppendLimit(placements, geometry));

  function cellAt(clientX: number, clientY: number): Cell | null {
    const el = gridEl;
    if (!el) return null;
    const rect = el.getBoundingClientRect();
    const wide = geometry.mode === "wide";
    // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns.
    const cellW = wide ? pitchX : rect.width / geometry.flowCols;
    const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1);
    const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1);
    return {
      col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols),
      row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit),
    };
  }

  // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─
  /** The dragged tile's id while a drag is in flight (else null). */
  const draggedId = $derived<string | null>(
    phase.kind === "dragging" ? phase.id : null,
  );
  const draggedPlacement = $derived(
    draggedId !== null
      ? (placements.find((placement) => placement.id === draggedId) ?? null)
      : null,
  );

  /** The pointer's hovered cell while dragging (else null). */
  const hoveredCell = $derived<Cell | null>(
    phase.kind === "dragging" ? phase.targetCell : null,
  );

  /** The landing cell: where the pointer is, else the tile's committed slot. */
  const targetCell = $derived<Cell | null>(
    hoveredCell ??
      (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null),
  );

  /**
   * What to render: while dragging, the displacement of the COMMITTED layout
   * at the target cell — recomputed from committed on EVERY hover change (as
   * if the user moved straight from the original to here); the FLIP below
   * animates from wherever the tiles currently are. Otherwise the committed
   * placements.
   */
  const rendered = $derived(
    draggedPlacement && targetCell && draggedId !== null
      ? displacePlace(placements, draggedId, targetCell, geometry)
      : placements,
  );

  // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ───
  let tileEls = new Map<string, HTMLElement>();

  function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
    tileEls.set(id, el);
    return {
      destroy() {
        tileEls.delete(id);
      },
    };
  }

  let flipState: ReturnType<typeof Flip.getState> | null = null;

  $effect.pre(() => {
    // Track the layout inputs; the DOM read below is intentionally untracked.
    void rendered
      .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`)
      .join("|");
    void geometry.mode;
    flipState = Flip.getState(
      [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el),
    );
  });

  $effect(() => {
    void rendered;
    void geometry.mode;
    const state = flipState;
    flipState = null;
    if (!state) return;
    Flip.from(state, {
      duration: 0.25,
      // Fast start, decelerating arrival — the reflow sweeps out and settles
      // into the destination (an inOut curve would ease INTO the motion too).
      ease: "power3.out",
      nested: true,
      onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }),
    });
  });

  // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ────
  let scrollerEl = $state<HTMLDivElement | undefined>();
  let scrollProgress = $state(0);
  let canPan = $state(false);

  function updateScroll(): void {
    const el = scrollerEl;
    if (!el) return;
    const max = el.scrollWidth - el.clientWidth;
    canPan = geometry.mode === "wide" && max > 1;
    scrollProgress = max > 0 ? el.scrollLeft / max : 0;
  }

  $effect(() => {
    // Re-measure when the geometry flips, the content changes, or a drag
    // starts/ends (the canvas grows with the preview and shrinks back after).
    void geometry.mode;
    void placements.length;
    void dragging;
    updateScroll();
  });

  function handleWheel(e: WheelEvent): void {
    // Vertical wheel pans the wide canvas horizontally.
    if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
    e.preventDefault();
    scrollerEl?.scrollBy({ left: e.deltaY });
  }

  function pan(): void {
    const el = scrollerEl;
    if (!el) return;
    const max = el.scrollWidth - el.clientWidth;
    const atEnd = el.scrollLeft >= max - 1;
    // One viewport sideways; past the end, pan back to the start.
    el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" });
  }

  // ── Grid + tile styles (the geometry decides; no breakpoint classes) ──────
  const dragging = $derived(phase.kind === "dragging");

  const gridStyle = $derived.by(() => {
    const wide = geometry.mode === "wide";
    // Content-driven canvas: the grid hugs its content (max-content / auto),
    // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when
    // the gesture ends — the drag sees the scrollbar move as it should. The
    // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview
    // can extend, so the canvas never grows past the committed edge + 3.
    const base =
      (wide
        ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
          ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` +
          ` gap: ${geometry.unit.gap}px; width: max-content;`
        : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
          ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` +
          ` gap: ${geometry.unit.gap}px;`);
    if (!dragging) return base;
    // The drop-target dots: one faint dot at each unit cell's CENTER, painted
    // as a background whose tile is the cell pitch. One extra pitch of padding
    // past the content end gives the append-slot a dot too.
    const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`;
    const dotX = wide ? geometry.unit.width / 2 : 0;
    const dot =
      ` background-image: radial-gradient(circle 2.5px,` +
      ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` +
      ` background-size: ${dotSize} ${pitchY}px;` +
      ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` +
      (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`);
    return base + dot;
  });

  function spanFor(placement: Placement): Span {
    if (preview && preview.id === placement.id) {
      return { colSpan: preview.colSpan, rowSpan: preview.rowSpan };
    }
    return { colSpan: placement.colSpan, rowSpan: placement.rowSpan };
  }

  function wrapperStyle(placement: Placement): string {
    const span = spanFor(placement);
    return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`;
  }

  /**
   * The landing cell's top-left in CLIENT coordinates — where the CSS grid
   * actually places a tile anchored at `targetCell` (the true column pitch:
   * fixed in wide; the fluid column width PLUS the gap in tall). The follow
   * transform cancels exactly this, so the grab point tracks the pointer.
   */
  const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => {
    const el = gridEl;
    if (!el || !targetCell) return null;
    const rect = el.getBoundingClientRect();
    const wide = geometry.mode === "wide";
    const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols;
    return {
      x: rect.left + (targetCell.col - 1) * colPitch,
      y: rect.top + (targetCell.row - 1) * pitchY,
    };
  });

  /** The dragged tile's follow transform (it keeps its committed slot). */
  function followStyle(placement: Placement): string {
    if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") {
      return "";
    }
    const origin = targetCellOrigin;
    if (!origin) return "";
    // Pointer minus the tile's GRID anchor minus the grab offset — the grab
    // point the user pressed tracks the pointer. A raw pointer-displacement
    // translate would ride the tile's origin instead and jump by the grab
    // offset (a center grab snaps the tile's top-left under the pointer).
    const dx = phase.x - origin.x - grabOffset.x;
    const dy = phase.y - origin.y - grabOffset.y;
    return `transform: translate(${dx}px, ${dy}px) scale(1.05);`;
  }

  // ── The drop ghost: a dashed footprint at the target anchor ───────────────
  const ghostStyle = $derived.by(() => {
    if (!dragging || !targetCell || !draggedPlacement) return null;
    const span = spanFor(draggedPlacement);
    const wide = geometry.mode === "wide";
    const h = span.rowSpan * pitchY - geometry.unit.gap;
    const x = wide
      ? `${(targetCell.col - 1) * pitchX}px`
      : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`;
    const width = wide
      ? `${span.colSpan * pitchX - geometry.unit.gap}px`
      : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`;
    return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`;
  });

  /**
   * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
   * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
   * tone's SATURATED color with the tone's `-content` text, like Win8's
   * colored tiles on the dark Start canvas.
   */
  const TONE_CLASSES: Record<TileTone, string> = {
    primary: "bg-primary text-primary-content border-primary",
    secondary: "bg-secondary text-secondary-content border-secondary",
    accent: "bg-accent text-accent-content border-accent",
    info: "bg-info text-info-content border-info",
    success: "bg-success text-success-content border-success",
    warning: "bg-warning text-warning-content border-warning",
  };
</script>

<svelte:window onkeydowncapture={windowKeydown} />

<div class="relative h-full">
  <div
    bind:this={scrollerEl}
    class="h-full overscroll-contain"
    class:overflow-x-auto={geometry.mode === "wide"}
    class:overflow-y-auto={geometry.mode === "tall"}
    style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`}
    onscroll={updateScroll}
    onwheel={handleWheel}
  >
    <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) -->
    <div
      bind:this={gridEl}
      class="relative grid"
      style={gridStyle}
      onpointerdown={gridPointerDown}
      onpointermove={gridPointerMove}
      onpointerup={gridPointerUp}
      onpointercancel={() => dispatch({ type: "cancel" })}
    >
      {#if ghostStyle && draggedPlacement}
        <!-- The landing footprint (the dragged tile's tone, dashed). -->
        <div
          aria-hidden="true"
          class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70"
          class:border-primary={tileTone(draggedPlacement.id) === "primary"}
          class:border-secondary={tileTone(draggedPlacement.id) === "secondary"}
          class:border-accent={tileTone(draggedPlacement.id) === "accent"}
          class:border-info={tileTone(draggedPlacement.id) === "info"}
          class:border-success={tileTone(draggedPlacement.id) === "success"}
          class:border-warning={tileTone(draggedPlacement.id) === "warning"}
          style={ghostStyle}
        ></div>
      {/if}
      {#each rendered as placement (placement.id)}
        {@const ws = workspacesById.get(placement.id)}
        {#if ws}
          {@const isDragged = draggedId === placement.id}
          <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
          <div
            data-tile-id={placement.id}
            class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${
              TONE_CLASSES[tileTone(placement.id)]
            } ${
              selectedId === placement.id
                ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
                : "border-transparent"
            } ${
              isDragged
                ? "pointer-events-none relative z-40 shadow-2xl"
                : "hover:-translate-y-0.5 hover:shadow-xl"
            }`}
            style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`}
            role="button"
            tabindex="0"
            aria-label={`Open workspace ${ws.title}`}
            use:trackTile={placement.id}
            oncontextmenu={(e) => {
              e.preventDefault();
              onSelect(placement.id);
            }}
            onkeydown={(e) => {
              if (insideInteractive(e.target)) return;
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                onOpen(placement.id);
              }
            }}
          >
            <WorkspaceTile
              {ws}
              {store}
              {placement}
              selected={selectedId === placement.id}
              renaming={renamingId === placement.id}
              active={hasActive?.(ws.id) ?? false}
              {onRenameEnd}
            />
          </div>
        {/if}
      {/each}
    </div>
  </div>

  {#if canPan}
    <!-- Pan arrow + thin scroll-position indicator (wide geometry only).
         Both sit INSIDE the canvas' outer margin. -->
    <button
      type="button"
      class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl"
      style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`}
      aria-label="Pan right"
      title="Pan one screen sideways"
      onclick={pan}
    >
      →
    </button>
    <div
      class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10"
      style={`margin: 0 ${geometry.margin}px;`}
    >
      <div
        class="h-full bg-primary transition-[width] duration-100"
        style={`width: ${Math.round(scrollProgress * 100)}%;`}
      ></div>
    </div>
  {/if}
</div>