@mystravil/hardpoint 0.0.0-stage → 0.2.0

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +625 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +235 -0
  10. package/dist/components/Checkbox.svelte.d.ts +23 -0
  11. package/dist/components/CheckboxGroup.svelte +207 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
  13. package/dist/components/Combobox.svelte +901 -0
  14. package/dist/components/Combobox.svelte.d.ts +72 -0
  15. package/dist/components/Dialog.svelte +359 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/ImageViewer.svelte +764 -0
  24. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  25. package/dist/components/KeyValueList.svelte +114 -0
  26. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  27. package/dist/components/ListDetail.svelte +218 -0
  28. package/dist/components/ListDetail.svelte.d.ts +37 -0
  29. package/dist/components/LoadRegion.svelte +219 -0
  30. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  31. package/dist/components/MediaFrame.svelte +110 -0
  32. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  33. package/dist/components/MediaImage.svelte +256 -0
  34. package/dist/components/MediaImage.svelte.d.ts +16 -0
  35. package/dist/components/Menu.svelte +258 -0
  36. package/dist/components/Menu.svelte.d.ts +36 -0
  37. package/dist/components/MenuCheckboxItem.svelte +119 -0
  38. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  39. package/dist/components/MenuGroup.svelte +50 -0
  40. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  41. package/dist/components/MenuItem.svelte +94 -0
  42. package/dist/components/MenuItem.svelte.d.ts +19 -0
  43. package/dist/components/MenuRadioGroup.svelte +62 -0
  44. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  45. package/dist/components/MenuRadioItem.svelte +110 -0
  46. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  47. package/dist/components/MenuSeparator.svelte +28 -0
  48. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  49. package/dist/components/Meter.svelte +283 -0
  50. package/dist/components/Meter.svelte.d.ts +66 -0
  51. package/dist/components/NativeSelect.svelte +135 -0
  52. package/dist/components/NativeSelect.svelte.d.ts +52 -0
  53. package/dist/components/Notice.svelte +262 -0
  54. package/dist/components/Notice.svelte.d.ts +30 -0
  55. package/dist/components/Pagination.svelte +132 -0
  56. package/dist/components/Pagination.svelte.d.ts +18 -0
  57. package/dist/components/Popover.svelte +222 -0
  58. package/dist/components/Popover.svelte.d.ts +35 -0
  59. package/dist/components/Progress.svelte +157 -0
  60. package/dist/components/Progress.svelte.d.ts +34 -0
  61. package/dist/components/RadioGroup.svelte +440 -0
  62. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  63. package/dist/components/Select.svelte +471 -0
  64. package/dist/components/Select.svelte.d.ts +48 -0
  65. package/dist/components/Skeleton.svelte +149 -0
  66. package/dist/components/Skeleton.svelte.d.ts +24 -0
  67. package/dist/components/Spinner.svelte +118 -0
  68. package/dist/components/Spinner.svelte.d.ts +13 -0
  69. package/dist/components/Stat.svelte +253 -0
  70. package/dist/components/Stat.svelte.d.ts +46 -0
  71. package/dist/components/Steps.svelte +189 -0
  72. package/dist/components/Steps.svelte.d.ts +31 -0
  73. package/dist/components/Switch.svelte +222 -0
  74. package/dist/components/Switch.svelte.d.ts +18 -0
  75. package/dist/components/Table.svelte +504 -0
  76. package/dist/components/Table.svelte.d.ts +92 -0
  77. package/dist/components/Tabs.svelte +246 -0
  78. package/dist/components/Tabs.svelte.d.ts +44 -0
  79. package/dist/components/Tag.svelte +40 -0
  80. package/dist/components/Tag.svelte.d.ts +15 -0
  81. package/dist/components/TextArea.svelte +272 -0
  82. package/dist/components/TextArea.svelte.d.ts +28 -0
  83. package/dist/components/TextField.svelte +256 -0
  84. package/dist/components/TextField.svelte.d.ts +32 -0
  85. package/dist/components/Timeline.svelte +156 -0
  86. package/dist/components/Timeline.svelte.d.ts +30 -0
  87. package/dist/components/Toaster.svelte +411 -0
  88. package/dist/components/Toaster.svelte.d.ts +11 -0
  89. package/dist/components/Tooltip.svelte +200 -0
  90. package/dist/components/Tooltip.svelte.d.ts +38 -0
  91. package/dist/components/TooltipGroup.svelte +46 -0
  92. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  93. package/dist/components/internal/Scrim.svelte +52 -0
  94. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  95. package/dist/components/internal/Sheet.svelte +263 -0
  96. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  97. package/dist/components/internal/fade-in.d.ts +10 -0
  98. package/dist/components/internal/fade-in.js +20 -0
  99. package/dist/components/internal/groups.d.ts +14 -0
  100. package/dist/components/internal/groups.js +23 -0
  101. package/dist/components/internal/resolve.d.ts +8 -0
  102. package/dist/components/internal/resolve.js +247 -0
  103. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  104. package/dist/components/internal/swipe-dismiss.js +220 -0
  105. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  106. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  107. package/dist/components/toast.svelte.d.ts +57 -0
  108. package/dist/components/toast.svelte.js +84 -0
  109. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  110. package/dist/fonts/OFL.txt +93 -0
  111. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  112. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  113. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  114. package/dist/fonts.css +71 -0
  115. package/dist/index.d.ts +45 -0
  116. package/dist/index.js +45 -0
  117. package/dist/motion/Motion.svelte +15 -0
  118. package/dist/motion/Motion.svelte.d.ts +9 -0
  119. package/dist/motion/context.d.ts +18 -0
  120. package/dist/motion/context.js +26 -0
  121. package/dist/motion/index.d.ts +3 -0
  122. package/dist/motion/index.js +3 -0
  123. package/dist/motion/transitions.d.ts +31 -0
  124. package/dist/motion/transitions.js +112 -0
  125. package/dist/tokens.css +283 -0
  126. package/package.json +76 -6
@@ -0,0 +1,247 @@
1
+ // Image resolve (BRIEF, motion rule 10; da3-57, da3-59), for MediaImage in
2
+ // the expressive tier. Internal: the package does not export it.
3
+ //
4
+ // An image that arrives after its skeleton showed appears as a mosaic of
5
+ // square blocks, each about the average colour of the part of the image it
6
+ // covers, and sharpens in discrete steps to the image itself:
7
+ //
8
+ // step 1 blocks of about 32 CSS px step 4 about 4 CSS px
9
+ // step 2 about 16 step 5 about 2
10
+ // step 3 about 8 then the image
11
+ //
12
+ // Each step lasts `--resolve-step` (42ms, one reel frame at 24 fps; the
13
+ // caller reads it from the element when the resolve starts), counted from
14
+ // the first frame on screen, so the image shows five steps (210ms) after
15
+ // the mosaic first did, inside the rule's 300ms. The reel's photo goes from
16
+ // blocks of about 36px to crisp in 7 frames (da3-57), and its mosaics settle
17
+ // in about 8 frames, halving about every 1.5 (measurements §4.7 to §4.9);
18
+ // this halves every step, a shorter form of the same ramp.
19
+ //
20
+ // Crisp squares: the canvas bitmap has exactly the device pixels its box
21
+ // covers on screen (a ResizeObserver on `device-pixel-content-box`, where
22
+ // the browser has it), and every block is a whole number of device pixels:
23
+ // `round(2 × devicePixelRatio)` for the finest step, doubled for each
24
+ // coarser one, on a grid from the top left corner. So each block splits
25
+ // into four of the next step and every edge lands on a device pixel. The
26
+ // box rarely holds a whole number of blocks: the last column and row cover
27
+ // what is left, and when that is less than half a block, the column or row
28
+ // beside it widens over it, so no thin strip lines the right or bottom
29
+ // edge. Blocks are drawn by scaling a canvas of one pixel per block with
30
+ // smoothing off, by a whole factor except along those edges, and the
31
+ // canvas element has `image-rendering: pixelated`, so where the browser
32
+ // cannot report device pixels the blocks may differ by a pixel but never
33
+ // blur.
34
+ //
35
+ // No pixel is ever read back. The image is drawn once, whole, into a copy
36
+ // of about two pixels per finest block, with high-quality smoothing; that
37
+ // draw decodes it. The finest step takes each block's part of the copy down
38
+ // to one pixel, and each coarser step is the step before it drawn at half
39
+ // size with bilinear smoothing, which makes each block the mean of the four
40
+ // under it. Taking the parts from the copy, not from the image, matters:
41
+ // in Chrome, four draws of fractional parts of a 1200 x 800 JPEG cost two
42
+ // decodes (measured 2026-11-11). So the work is one decode and a few draws
43
+ // of small canvases (178 x 119 pixels for the finest step of a 356 x 237
44
+ // CSS px box at a ratio of 3), and an image from another origin served
45
+ // without CORS headers resolves like any other: drawing it only taints the
46
+ // canvases, which stay on screen and are never read.
47
+ //
48
+ // const stop = resolveImage(canvas, image, step, done);
49
+ //
50
+ // The image is drawn as `object-fit: cover` draws it. The caller keeps the
51
+ // real <img> (with its alt) in the document under the aria-hidden canvas,
52
+ // and removes the canvas when `done` runs. The mosaic is computed and its
53
+ // first step drawn before the canvas is first painted, in the
54
+ // ResizeObserver callback.
55
+ /** Steps from the coarsest blocks to the finest, before the image itself. */
56
+ const STEPS = 5;
57
+ /** The finest blocks, in CSS px; each coarser step doubles them. */
58
+ const FINEST = 2;
59
+ /**
60
+ * Shows `image` on `canvas` as a mosaic that sharpens one step per `step`
61
+ * ms, then calls `done`. The canvas carries `data-step` (1 to 5) and
62
+ * `data-block` (the block size in device pixels) for the step on screen.
63
+ * When the box is empty or the image has no size, `done` runs at once.
64
+ * Returns a stop function.
65
+ */
66
+ export function resolveImage(canvas, image, step, done) {
67
+ let stop;
68
+ // The first report comes after layout and before the canvas is first
69
+ // painted. Later size changes keep the bitmap, scaled. The device-pixel
70
+ // size counts only when it agrees with the CSS size times the ratio
71
+ // (Chromium's device emulation reports it unscaled).
72
+ const observer = new ResizeObserver(([entry]) => {
73
+ observer.disconnect();
74
+ const ratio = devicePixelRatio;
75
+ const css = entry.contentRect;
76
+ const near = (device, size) => device !== undefined && Math.abs(device - size * ratio) <= 1 ? device : Math.round(size * ratio);
77
+ const device = entry.devicePixelContentBoxSize?.[0];
78
+ const width = near(device?.inlineSize, css.width);
79
+ const height = near(device?.blockSize, css.height);
80
+ const steps = mosaic(image, width, height, ratio);
81
+ if (!steps) {
82
+ stop = () => { };
83
+ return done();
84
+ }
85
+ stop = play(canvas, width, height, steps, step, done);
86
+ });
87
+ try {
88
+ observer.observe(canvas, { box: 'device-pixel-content-box' });
89
+ }
90
+ catch {
91
+ observer.observe(canvas);
92
+ }
93
+ return () => {
94
+ observer.disconnect();
95
+ stop?.();
96
+ };
97
+ }
98
+ /** A canvas of `width` x `height` pixels and its context, smoothing at `quality`. */
99
+ function surface(width, height, quality) {
100
+ const canvas = document.createElement('canvas');
101
+ canvas.width = width;
102
+ canvas.height = height;
103
+ const ctx = canvas.getContext('2d');
104
+ if (!ctx)
105
+ return null;
106
+ ctx.imageSmoothingEnabled = true;
107
+ ctx.imageSmoothingQuality = quality;
108
+ return { canvas, ctx };
109
+ }
110
+ /**
111
+ * The grid's whole blocks and its last block along one axis, as
112
+ * `[first cell, cells, device px from, device px across]`: `count` cells of
113
+ * `block` px over `size` px, the last one covering what is left.
114
+ */
115
+ function spans(count, block, size) {
116
+ const whole = (count - 1) * block;
117
+ const last = [count - 1, 1, whole, size - whole];
118
+ return count > 1 ? [[0, count - 1, 0, whole], last] : [last];
119
+ }
120
+ /**
121
+ * Halving `count` cells along one axis, as `[from, cells, to, cells]`:
122
+ * pairs draw at half size into one cell each, and an odd last cell is
123
+ * copied as it is.
124
+ */
125
+ function halves(count) {
126
+ const paired = count - (count % 2);
127
+ const out = [];
128
+ if (paired)
129
+ out.push([0, paired, 0, paired / 2]);
130
+ if (count % 2)
131
+ out.push([count - 1, 1, paired / 2, 1]);
132
+ return out;
133
+ }
134
+ /** The mosaic steps of `image` over a `width` x `height` device pixel box, coarsest first. */
135
+ function mosaic(image, width, height, ratio) {
136
+ const iw = image.naturalWidth;
137
+ const ih = image.naturalHeight;
138
+ if (width < 1 || height < 1 || iw < 1 || ih < 1)
139
+ return null;
140
+ // The image as `object-fit: cover` draws it: `scale` device pixels per
141
+ // image pixel, centred.
142
+ const scale = Math.max(width / iw, height / ih);
143
+ const left = (iw - width / scale) / 2;
144
+ const top = (ih - height / scale) / 2;
145
+ // The image drawn once, whole, at about two pixels per finest block (at
146
+ // most its own size): the one draw that decodes it.
147
+ let block = Math.max(1, Math.round(FINEST * ratio));
148
+ const shrink = Math.min(1, (2 * scale) / block);
149
+ const copy = surface(Math.max(1, Math.round(iw * shrink)), Math.max(1, Math.round(ih * shrink)), 'high');
150
+ if (!copy)
151
+ return null;
152
+ copy.ctx.drawImage(image, 0, 0, copy.canvas.width, copy.canvas.height);
153
+ const kx = copy.canvas.width / iw;
154
+ const ky = copy.canvas.height / ih;
155
+ // The finest step: each block's part of the copy drawn down to one pixel.
156
+ let cols = Math.ceil(width / block);
157
+ let rows = Math.ceil(height / block);
158
+ let cells = surface(cols, rows, 'low');
159
+ if (!cells)
160
+ return null;
161
+ for (const [dx, dw, sx, sw] of spans(cols, block, width)) {
162
+ for (const [dy, dh, sy, sh] of spans(rows, block, height)) {
163
+ const x = (left + sx / scale) * kx;
164
+ const y = (top + sy / scale) * ky;
165
+ cells.ctx.drawImage(copy.canvas, x, y, (sw / scale) * kx, (sh / scale) * ky, dx, dy, dw, dh);
166
+ }
167
+ }
168
+ const steps = [];
169
+ for (let k = 0;; k++) {
170
+ const restX = width - (cols - 1) * block;
171
+ const restY = height - (rows - 1) * block;
172
+ const joinX = cols > 1 && restX < block / 2;
173
+ const joinY = rows > 1 && restY < block / 2;
174
+ steps.unshift({
175
+ block,
176
+ cells: cells.canvas,
177
+ cols: joinX ? cols - 1 : cols,
178
+ rows: joinY ? rows - 1 : rows,
179
+ lastWidth: joinX ? block + restX : restX,
180
+ lastHeight: joinY ? block + restY : restY
181
+ });
182
+ if (k === STEPS - 1)
183
+ return steps;
184
+ // Four blocks make one of the next step: half size, bilinear, so each
185
+ // new pixel is the mean of the four under it.
186
+ const next = surface(Math.ceil(cols / 2), Math.ceil(rows / 2), 'low');
187
+ if (!next)
188
+ return null;
189
+ for (const [sx, sw, dx, dw] of halves(cols)) {
190
+ for (const [sy, sh, dy, dh] of halves(rows)) {
191
+ next.ctx.drawImage(cells.canvas, sx, sy, sw, sh, dx, dy, dw, dh);
192
+ }
193
+ }
194
+ cells = next;
195
+ cols = next.canvas.width;
196
+ rows = next.canvas.height;
197
+ block *= 2;
198
+ }
199
+ }
200
+ /** Draws the first step now and one more per `step` ms after it, then calls `done`. */
201
+ function play(canvas, width, height, steps, step, done) {
202
+ canvas.width = width;
203
+ canvas.height = height;
204
+ const ctx = canvas.getContext('2d');
205
+ if (!ctx) {
206
+ done();
207
+ return () => { };
208
+ }
209
+ ctx.imageSmoothingEnabled = false;
210
+ let shown = 0;
211
+ const show = (n) => {
212
+ const { block, cells, cols, rows, lastWidth, lastHeight } = steps[n];
213
+ // Whole blocks at a whole scale, then the last column, the last row
214
+ // and the corner, stretched to the edges. Cleared first, so an image
215
+ // with transparent parts never stacks two steps.
216
+ const c = cols - 1;
217
+ const r = rows - 1;
218
+ const x = c * block;
219
+ const y = r * block;
220
+ ctx.clearRect(0, 0, width, height);
221
+ if (c && r)
222
+ ctx.drawImage(cells, 0, 0, c, r, 0, 0, x, y);
223
+ if (r)
224
+ ctx.drawImage(cells, c, 0, 1, r, x, 0, lastWidth, y);
225
+ if (c)
226
+ ctx.drawImage(cells, 0, r, c, 1, 0, y, x, lastHeight);
227
+ ctx.drawImage(cells, c, r, 1, 1, x, y, lastWidth, lastHeight);
228
+ canvas.dataset.step = String(n + 1);
229
+ canvas.dataset.block = String(block);
230
+ shown = n;
231
+ };
232
+ show(0);
233
+ // The first step is painted with this frame, once the work above is
234
+ // done, so its time starts now.
235
+ const start = performance.now();
236
+ let raf = 0;
237
+ const tick = (now) => {
238
+ const n = Math.floor((now - start) / step);
239
+ if (n >= steps.length)
240
+ return done();
241
+ if (n > shown)
242
+ show(n);
243
+ raf = requestAnimationFrame(tick);
244
+ };
245
+ raf = requestAnimationFrame(tick);
246
+ return () => cancelAnimationFrame(raf);
247
+ }
@@ -0,0 +1,47 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ /**
3
+ * A way a swipe sends the panel off screen: `down` for a bottom sheet,
4
+ * `left` for a drawer from the left edge, and `left`, `right` or `down` for
5
+ * the toast.
6
+ */
7
+ export type SwipeDirection = 'down' | 'left' | 'right';
8
+ export interface SwipeDismissOptions {
9
+ /** The way a swipe dismisses, or several ways. Default `down`. */
10
+ direction?: SwipeDirection | readonly SwipeDirection[];
11
+ /** Share of the panel's height (`down`) or width (`left`, `right`) a release must pass to dismiss. Default 0.25. */
12
+ distance?: number;
13
+ /** Release speed in px/ms towards a direction that dismisses at any distance. Default 0.5. */
14
+ velocity?: number;
15
+ }
16
+ /**
17
+ * Swipe to dismiss: down for a bottom sheet (BRIEF, Apps and phones:
18
+ * Overlays), left for the shell's drawer, and left, right or down for the
19
+ * toast. Attach it to the panel:
20
+ * `{@attach swipeDismiss(() => (open = false))}`,
21
+ * `{@attach swipeDismiss(close, { direction: 'left' })}`, or
22
+ * `{@attach swipeDismiss(away, { direction: ['left', 'right', 'down'] })}`.
23
+ *
24
+ * The panel follows the finger through the CSS `translate` property, so
25
+ * `transform` stays free for the transitions: after a dismiss the exit plays
26
+ * on from where the finger let go, and under reduced motion it fades in
27
+ * place. A release past `distance` of the panel's size along the swipe, or a
28
+ * flick the way the panel moved, calls `onDismiss` with that direction, so
29
+ * the exit can carry on that way; a shorter drag springs back over
30
+ * `--dur-sheet-exit` (at once under reduced motion).
31
+ *
32
+ * With several directions, a drag takes the first one the finger moves
33
+ * towards more than across it and stays on that axis: a panel that takes
34
+ * `left` and `right` follows the finger both ways along it, with the slop
35
+ * on either side of where the touch began.
36
+ *
37
+ * A touch that starts inside a list that can still scroll the way the
38
+ * finger moves scrolls the list: a sheet moves only once its list sits at
39
+ * its top. A move no direction takes (sideways on a sheet, up or down in a
40
+ * drawer) is the browser's, so the gesture never fights the scroll; give a
41
+ * drawer `touch-action: pan-y`, so a sideways move reaches the swipe, and a
42
+ * panel that swipes along both axes `touch-action: pinch-zoom`. A drag never
43
+ * activates what it started on: its `pointerup` and any `click` stop at the
44
+ * panel. Touch only; a mouse closes a panel through its close button, the
45
+ * scrim or Escape.
46
+ */
47
+ export declare function swipeDismiss(onDismiss: (direction: SwipeDirection) => void, { direction, distance, velocity }?: SwipeDismissOptions): Attachment<HTMLElement>;
@@ -0,0 +1,220 @@
1
+ import { prefersReducedMotion } from 'svelte/motion';
2
+ /** Each direction's axis, and the sign of a screen coordinate moving that way. */
3
+ const WAYS = {
4
+ down: { axis: 'y', sign: 1 },
5
+ left: { axis: 'x', sign: -1 },
6
+ right: { axis: 'x', sign: 1 }
7
+ };
8
+ /** Travel before a touch counts as a drag; it is not added to the panel's offset. */
9
+ const SLOP = 8;
10
+ /** A flick counts only if the finger was still moving this recently (ms). */
11
+ const FLICK_WINDOW = 100;
12
+ /**
13
+ * Swipe to dismiss: down for a bottom sheet (BRIEF, Apps and phones:
14
+ * Overlays), left for the shell's drawer, and left, right or down for the
15
+ * toast. Attach it to the panel:
16
+ * `{@attach swipeDismiss(() => (open = false))}`,
17
+ * `{@attach swipeDismiss(close, { direction: 'left' })}`, or
18
+ * `{@attach swipeDismiss(away, { direction: ['left', 'right', 'down'] })}`.
19
+ *
20
+ * The panel follows the finger through the CSS `translate` property, so
21
+ * `transform` stays free for the transitions: after a dismiss the exit plays
22
+ * on from where the finger let go, and under reduced motion it fades in
23
+ * place. A release past `distance` of the panel's size along the swipe, or a
24
+ * flick the way the panel moved, calls `onDismiss` with that direction, so
25
+ * the exit can carry on that way; a shorter drag springs back over
26
+ * `--dur-sheet-exit` (at once under reduced motion).
27
+ *
28
+ * With several directions, a drag takes the first one the finger moves
29
+ * towards more than across it and stays on that axis: a panel that takes
30
+ * `left` and `right` follows the finger both ways along it, with the slop
31
+ * on either side of where the touch began.
32
+ *
33
+ * A touch that starts inside a list that can still scroll the way the
34
+ * finger moves scrolls the list: a sheet moves only once its list sits at
35
+ * its top. A move no direction takes (sideways on a sheet, up or down in a
36
+ * drawer) is the browser's, so the gesture never fights the scroll; give a
37
+ * drawer `touch-action: pan-y`, so a sideways move reaches the swipe, and a
38
+ * panel that swipes along both axes `touch-action: pinch-zoom`. A drag never
39
+ * activates what it started on: its `pointerup` and any `click` stop at the
40
+ * panel. Touch only; a mouse closes a panel through its close button, the
41
+ * scrim or Escape.
42
+ */
43
+ export function swipeDismiss(onDismiss, { direction = 'down', distance = 0.25, velocity = 0.5 } = {}) {
44
+ const ways = typeof direction === 'string' ? [direction] : direction;
45
+ const usesX = ways.some((w) => WAYS[w].axis === 'x');
46
+ const usesY = ways.some((w) => WAYS[w].axis === 'y');
47
+ /** The panel's direction along `axis` towards `sign`, if it takes one. */
48
+ const wayTo = (axis, sign) => ways.find((w) => WAYS[w].axis === axis && WAYS[w].sign === sign);
49
+ return (node) => {
50
+ let phase = 'idle';
51
+ let dragged = false;
52
+ let startX = 0;
53
+ let startY = 0;
54
+ // While dragging: the axis it moves on, the panel's offset along it in
55
+ // screen pixels, and the finger's last travel from the start and its
56
+ // speed along it (px/ms).
57
+ let axis = 'y';
58
+ let offset = 0;
59
+ let last = 0;
60
+ let lastT = 0;
61
+ let speed = 0;
62
+ let scrollerX = null;
63
+ let scrollerY = null;
64
+ function place(shift, animate) {
65
+ node.style.transition = animate ? 'translate var(--dur-sheet-exit) var(--settle)' : '';
66
+ node.style.translate = shift !== 0 ? (axis === 'x' ? `${shift}px 0` : `0 ${shift}px`) : '';
67
+ }
68
+ /** The nearest ancestor of the touch, inside the panel, that scrolls along `along`. */
69
+ function scrollerOf(target, along) {
70
+ for (let el = target instanceof Element ? target : null; el && el !== node; el = el.parentElement) {
71
+ if (!(el instanceof HTMLElement))
72
+ continue;
73
+ if (along === 'x' ? el.scrollWidth <= el.clientWidth : el.scrollHeight <= el.clientHeight)
74
+ continue;
75
+ const style = getComputedStyle(el);
76
+ const overflow = along === 'x' ? style.overflowX : style.overflowY;
77
+ if (overflow === 'auto' || overflow === 'scroll')
78
+ return el;
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * Whether a list under the touch still scrolls the way the finger
84
+ * moves: a sheet's list away from its top, a drawer's row short of its
85
+ * end, a row scrolled away from its start.
86
+ */
87
+ function scrolls(way) {
88
+ if (way === 'down')
89
+ return !!scrollerY && scrollerY.scrollTop > 0;
90
+ if (!scrollerX)
91
+ return false;
92
+ if (way === 'left')
93
+ return Math.ceil(scrollerX.scrollLeft) + scrollerX.clientWidth < scrollerX.scrollWidth;
94
+ return scrollerX.scrollLeft > 0;
95
+ }
96
+ function release() {
97
+ if (phase === 'dragging')
98
+ place(0, !prefersReducedMotion.current);
99
+ phase = 'idle';
100
+ offset = 0;
101
+ }
102
+ function onTouchStart(e) {
103
+ if (e.touches.length !== 1) {
104
+ // A second finger is not a swipe.
105
+ release();
106
+ return;
107
+ }
108
+ const touch = e.touches[0];
109
+ startX = touch.clientX;
110
+ startY = touch.clientY;
111
+ lastT = e.timeStamp;
112
+ speed = 0;
113
+ offset = 0;
114
+ scrollerX = usesX ? scrollerOf(e.target, 'x') : null;
115
+ scrollerY = usesY ? scrollerOf(e.target, 'y') : null;
116
+ phase = 'pending';
117
+ }
118
+ function onTouchMove(e) {
119
+ if (phase === 'idle')
120
+ return;
121
+ const touch = e.touches[0];
122
+ const dx = touch.clientX - startX;
123
+ const dy = touch.clientY - startY;
124
+ if (phase === 'pending') {
125
+ // The first direction the finger moves towards more than across it.
126
+ const way = ways.find((w) => {
127
+ const { axis: a, sign } = WAYS[w];
128
+ const travel = sign * (a === 'x' ? dx : dy);
129
+ return travel > 0 && travel >= Math.abs(a === 'x' ? dy : dx);
130
+ });
131
+ if (!way || scrolls(way)) {
132
+ // Back, across, or a list with room to scroll: the browser's gesture.
133
+ if (Math.abs(dx) + Math.abs(dy) > 2)
134
+ phase = 'idle';
135
+ return;
136
+ }
137
+ // Towards dismissal: the panel takes the gesture, so the list
138
+ // neither scrolls nor overscrolls.
139
+ if (e.cancelable)
140
+ e.preventDefault();
141
+ const { axis: a, sign } = WAYS[way];
142
+ const along = a === 'x' ? dx : dy;
143
+ if (sign * along < SLOP)
144
+ return;
145
+ phase = 'dragging';
146
+ dragged = true;
147
+ axis = a;
148
+ last = along;
149
+ lastT = e.timeStamp;
150
+ }
151
+ if (e.cancelable)
152
+ e.preventDefault();
153
+ // The travel from the touch's start less the slop, towards the
154
+ // panel's directions only.
155
+ const moved = axis === 'x' ? dx : dy;
156
+ offset = moved > SLOP && wayTo(axis, 1) ? moved - SLOP : moved < -SLOP && wayTo(axis, -1) ? moved + SLOP : 0;
157
+ const dt = e.timeStamp - lastT;
158
+ if (dt > 0)
159
+ speed = (moved - last) / dt;
160
+ last = moved;
161
+ lastT = e.timeStamp;
162
+ place(offset, false);
163
+ }
164
+ function onTouchEnd(e) {
165
+ if (phase !== 'dragging') {
166
+ phase = 'idle';
167
+ return;
168
+ }
169
+ // The way the panel moved; at rest, the way the finger was going.
170
+ const way = wayTo(axis, Math.sign(offset || speed));
171
+ const flick = !!way && WAYS[way].sign * speed > velocity && e.timeStamp - lastT < FLICK_WINDOW;
172
+ const size = axis === 'x' ? node.offsetWidth : node.offsetHeight;
173
+ if (way && (Math.abs(offset) > size * distance || flick)) {
174
+ // Keep the offset: the exit transition continues from here.
175
+ phase = 'idle';
176
+ onDismiss(way);
177
+ }
178
+ else {
179
+ release();
180
+ }
181
+ }
182
+ // Any new press clears the flag left by the last drag.
183
+ function onPointerDown() {
184
+ dragged = false;
185
+ }
186
+ // Pointer events fire before their touch events, so the drag is still
187
+ // in progress when its pointerup arrives.
188
+ function onPointerUp(e) {
189
+ if (e.pointerType === 'touch' && phase === 'dragging')
190
+ e.stopPropagation();
191
+ }
192
+ // A drag's click, if the browser sends one, never reaches the content.
193
+ // A click from the keyboard or from code (detail 0) is never a drag's,
194
+ // and it comes with no press to clear the flag.
195
+ function onClick(e) {
196
+ if (!dragged || e.detail === 0)
197
+ return;
198
+ dragged = false;
199
+ e.stopPropagation();
200
+ e.preventDefault();
201
+ }
202
+ const capture = { capture: true };
203
+ node.addEventListener('touchstart', onTouchStart, { passive: true });
204
+ node.addEventListener('touchmove', onTouchMove, { passive: false });
205
+ node.addEventListener('touchend', onTouchEnd, { passive: true });
206
+ node.addEventListener('touchcancel', release, { passive: true });
207
+ node.addEventListener('pointerdown', onPointerDown, capture);
208
+ node.addEventListener('pointerup', onPointerUp, capture);
209
+ node.addEventListener('click', onClick, capture);
210
+ return () => {
211
+ node.removeEventListener('touchstart', onTouchStart);
212
+ node.removeEventListener('touchmove', onTouchMove);
213
+ node.removeEventListener('touchend', onTouchEnd);
214
+ node.removeEventListener('touchcancel', release);
215
+ node.removeEventListener('pointerdown', onPointerDown, capture);
216
+ node.removeEventListener('pointerup', onPointerUp, capture);
217
+ node.removeEventListener('click', onClick, capture);
218
+ };
219
+ };
220
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The visual viewport while it is at least 1px shorter than the layout
3
+ * viewport, as when the on-screen keyboard is up: its top and bottom edges,
4
+ * measured from the layout viewport's top, and its height, in px.
5
+ */
6
+ export interface VisualViewportLift {
7
+ top: number;
8
+ bottom: number;
9
+ height: number;
10
+ }
11
+ /**
12
+ * The on-screen keyboard for a panel fixed to the layout viewport: the
13
+ * shared Sheet and the full-screen Dialog follow the visual viewport while
14
+ * they are open (decided 2026-11-10). A fixed panel sits on the layout
15
+ * viewport, and the keyboard shrinks only the visual viewport (Chrome on
16
+ * Android by default, iOS always), so it covers the panel's lower part.
17
+ * Call this while the component initialises; place the panel from
18
+ * `current`, which is null while the visual viewport is not shorter.
19
+ *
20
+ * While `open()` is true it listens to `visualViewport` `resize` and
21
+ * `scroll`. A pinch zoom shrinks the visual viewport on purpose; the panel
22
+ * then keeps its last place, as omp-remote's shell does. Each opening
23
+ * starts from the layout viewport, so a keyboard line left from the last
24
+ * opening never carries over; closing keeps the last place, so the exit
25
+ * plays from there. The listeners start before the panel mounts, so it
26
+ * opens in place.
27
+ *
28
+ * When the panel moves or shrinks, the focused element inside `body()` (the
29
+ * panel's scrolling body) stays in sight: the body scrolls it into view,
30
+ * the nearest way, top first if it is taller than the body. Only the body,
31
+ * so neither the page nor the visual viewport moves.
32
+ */
33
+ export declare function followVisualViewport(open: () => boolean, body: () => HTMLElement | null): {
34
+ readonly current: VisualViewportLift | null;
35
+ };
@@ -0,0 +1,79 @@
1
+ import { untrack } from 'svelte';
2
+ /** Room kept round a field the body scrolls into view: its focus contour (2px, 4px out) and some air. */
3
+ const REVEAL_MARGIN = 8;
4
+ /**
5
+ * The on-screen keyboard for a panel fixed to the layout viewport: the
6
+ * shared Sheet and the full-screen Dialog follow the visual viewport while
7
+ * they are open (decided 2026-11-10). A fixed panel sits on the layout
8
+ * viewport, and the keyboard shrinks only the visual viewport (Chrome on
9
+ * Android by default, iOS always), so it covers the panel's lower part.
10
+ * Call this while the component initialises; place the panel from
11
+ * `current`, which is null while the visual viewport is not shorter.
12
+ *
13
+ * While `open()` is true it listens to `visualViewport` `resize` and
14
+ * `scroll`. A pinch zoom shrinks the visual viewport on purpose; the panel
15
+ * then keeps its last place, as omp-remote's shell does. Each opening
16
+ * starts from the layout viewport, so a keyboard line left from the last
17
+ * opening never carries over; closing keeps the last place, so the exit
18
+ * plays from there. The listeners start before the panel mounts, so it
19
+ * opens in place.
20
+ *
21
+ * When the panel moves or shrinks, the focused element inside `body()` (the
22
+ * panel's scrolling body) stays in sight: the body scrolls it into view,
23
+ * the nearest way, top first if it is taller than the body. Only the body,
24
+ * so neither the page nor the visual viewport moves.
25
+ */
26
+ export function followVisualViewport(open, body) {
27
+ let lifted = $state(null);
28
+ $effect.pre(() => {
29
+ if (!open())
30
+ return;
31
+ const viewport = window.visualViewport;
32
+ if (!viewport)
33
+ return;
34
+ const sync = () => {
35
+ if (Math.abs(viewport.scale - 1) > 0.01)
36
+ return;
37
+ if (document.documentElement.clientHeight - viewport.height < 1) {
38
+ lifted = null;
39
+ return;
40
+ }
41
+ const top = viewport.offsetTop;
42
+ const bottom = top + viewport.height;
43
+ if (lifted?.top !== top || lifted.bottom !== bottom || lifted.height !== viewport.height) {
44
+ lifted = { top, bottom, height: viewport.height };
45
+ }
46
+ };
47
+ untrack(() => {
48
+ lifted = null;
49
+ sync();
50
+ });
51
+ viewport.addEventListener('resize', sync);
52
+ viewport.addEventListener('scroll', sync);
53
+ return () => {
54
+ viewport.removeEventListener('resize', sync);
55
+ viewport.removeEventListener('scroll', sync);
56
+ };
57
+ });
58
+ $effect(() => {
59
+ const scroller = lifted && body();
60
+ if (!scroller)
61
+ return;
62
+ const field = document.activeElement;
63
+ if (!(field instanceof HTMLElement) || !scroller.contains(field) || field === scroller)
64
+ return;
65
+ const view = scroller.getBoundingClientRect();
66
+ const box = field.getBoundingClientRect();
67
+ const above = view.top - (box.top - REVEAL_MARGIN);
68
+ const below = box.bottom + REVEAL_MARGIN - view.bottom;
69
+ if (above > 0)
70
+ scroller.scrollTop -= above;
71
+ else if (below > 0)
72
+ scroller.scrollTop += Math.min(below, -above);
73
+ });
74
+ return {
75
+ get current() {
76
+ return lifted;
77
+ }
78
+ };
79
+ }