@escape-game-over/atlas 0.1.30 → 0.1.32

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.
@@ -49,6 +49,17 @@ analytics need no permission, and remembers, expires and applies the answer. The
49
49
  markup marks its buttons `data-consent="grant"` and `data-consent="deny"`,
50
50
  and the control that brings it back `data-consent-reopen hidden`.
51
51
 
52
+ `Document` ships one more: scroll restoration, on unless `restoreScroll={false}`.
53
+ WebKit restores a page's position only at `load`, after every image, so a page
54
+ it reloads on back paints its top and then snaps down — and Firefox on iOS
55
+ reloads on nearly every back. An inline script at the end of `<body>` saves the
56
+ position on the history entry once scrolling settles, and puts it back before
57
+ the first paint. On the entry, not in storage keyed by URL, so a link to the same
58
+ page still opens at the top. Never on `pagehide`: WebKit fires it after moving to
59
+ the previous entry, and the write wipes the position about to be restored. A
60
+ site on Astro's `ClientRouter` turns it off; the router keeps its own position
61
+ in the same history state.
62
+
52
63
  **Browser code has one import path: `@escape-game-over/atlas/client`**, which
53
64
  re-exports every module above. None of it touches the DOM at import, so an
54
65
  element's contract can be imported in frontmatter from the same path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -5,6 +5,11 @@
5
5
  * `head-start` comes before every tag Atlas writes — a consent manager that must
6
6
  * load ahead of the analytics goes there; `head` comes after.
7
7
  *
8
+ * It also restores the scroll position on back and reload; see
9
+ * `docs/client-scripts.md`. A site on Astro's `ClientRouter` passes
10
+ * `restoreScroll={false}`: the router keeps its own position in the same
11
+ * history state.
12
+ *
8
13
  * ```astro
9
14
  * <Document meta={meta} html={{ class: "scroll-smooth" }} body={{ class: "…" }}>
10
15
  * <Fragment slot="head"><link rel="preload" … /></Fragment>
@@ -15,15 +20,18 @@
15
20
  import type { HTMLAttributes } from "astro/types";
16
21
  import type { PageMeta } from "../site/index.ts";
17
22
  import MetaTags from "./MetaTags.astro";
23
+ import ScrollRestore from "./ScrollRestore.astro";
18
24
 
19
25
  interface Props {
20
26
  readonly meta: PageMeta;
21
27
  /** `lang` and `dir` are the page's locale's, from `meta`. */
22
28
  readonly html?: Omit<HTMLAttributes<"html">, "lang" | "dir">;
23
29
  readonly body?: HTMLAttributes<"body">;
30
+ /** Defaults to `true`; `false` for a site on `ClientRouter`. */
31
+ readonly restoreScroll?: boolean;
24
32
  }
25
33
 
26
- const { meta, html, body } = Astro.props;
34
+ const { meta, html, body, restoreScroll = true } = Astro.props;
27
35
  ---
28
36
 
29
37
  <!doctype html>
@@ -40,5 +48,6 @@ const { meta, html, body } = Astro.props;
40
48
  <body {...body}>
41
49
  <MetaTags tags={meta.bodyTags} />
42
50
  <slot />
51
+ {restoreScroll && <ScrollRestore />}
43
52
  </body>
44
53
  </html>
@@ -0,0 +1,57 @@
1
+ ---
2
+ /**
3
+ * Puts a page back where the reader left it, before the first paint.
4
+ *
5
+ * WebKit restores scroll only at `load`, after every image, so a page it
6
+ * reloads on back shows its top and then snaps down — and Firefox on iOS
7
+ * reloads on nearly every back. The position is kept on the history entry, as
8
+ * the browser keeps its own, so a link to the same page still opens at the top.
9
+ *
10
+ * Rendered by `Document` after the page, where the layout already exists.
11
+ * Inline, because a bundled module runs too late: after the first paint.
12
+ */
13
+ ---
14
+
15
+ <script is:inline>
16
+ {
17
+ history.scrollRestoration = "manual";
18
+
19
+ const save = () => {
20
+ try {
21
+ history.replaceState({ ...history.state, scrollY }, "");
22
+ } catch {
23
+ // Safari throttles replaceState; the last saved position stands.
24
+ }
25
+ };
26
+
27
+ // Once scrolling settles, not per frame: Safari caps replaceState calls.
28
+ let settle = 0;
29
+ addEventListener(
30
+ "scroll",
31
+ () => {
32
+ clearTimeout(settle);
33
+ settle = setTimeout(save, 300);
34
+ },
35
+ { passive: true }
36
+ );
37
+ // No save on `pagehide`. WebKit fires it after moving to the previous entry,
38
+ // and Firefox on iOS restores a page at the top and reloads it at once, so
39
+ // either way the save would wipe the position about to be restored.
40
+
41
+ // `instant`, or a site with `scroll-behavior: smooth` glides down from the
42
+ // top — the jump this exists to remove, in slow motion.
43
+ const restore = (state) => {
44
+ if (typeof state?.scrollY === "number") {
45
+ scrollTo({ top: state.scrollY, behavior: "instant" });
46
+ }
47
+ };
48
+
49
+ restore(history.state);
50
+
51
+ // Filters push entries on the same page; `manual` turned off restoring
52
+ // those too. A frame later, so the filtered list has re-rendered first.
53
+ addEventListener("popstate", (event) => {
54
+ requestAnimationFrame(() => restore(event.state));
55
+ });
56
+ }
57
+ </script>
@@ -62,6 +62,17 @@ export interface CarouselOptions {
62
62
  * renders, and calling would make a project write the first position twice.
63
63
  */
64
64
  readonly onChange: (index: number) => void;
65
+ /**
66
+ * Called with `autoplayMs` whenever a countdown to the next automatic
67
+ * advance begins, and with `null` when autoplay stops.
68
+ *
69
+ * For a progress bar. A countdown begins on attach, after every tick and
70
+ * after every manual move, and each one starts from zero — so the bar
71
+ * restarts on every number and stops on `null`, and never has to know why.
72
+ * A hold (hover, focus, a hidden tab, `pause`) and a detach report `null`
73
+ * once, not once per reason.
74
+ */
75
+ readonly onCountdown?: (ms: number | null) => void;
65
76
  }
66
77
 
67
78
  export interface Carousel {
@@ -107,6 +118,7 @@ export function carousel(options: CarouselOptions): Carousel {
107
118
  swipeThreshold = 10,
108
119
  lockMs = 500,
109
120
  onChange,
121
+ onCountdown,
110
122
  } = options;
111
123
 
112
124
  /**
@@ -143,9 +155,19 @@ export function carousel(options: CarouselOptions): Carousel {
143
155
  */
144
156
  const holds = new Set<string>();
145
157
 
158
+ /** Whether a countdown is running, so `null` is reported once per stop. */
159
+ let counting = false;
160
+
161
+ const stopAutoplay = (): void => {
162
+ clearInterval(autoplayTimer);
163
+ if (!counting) return;
164
+ counting = false;
165
+ onCountdown?.(null);
166
+ };
167
+
146
168
  const hold = (reason: string): void => {
147
169
  holds.add(reason);
148
- clearInterval(autoplayTimer);
170
+ stopAutoplay();
149
171
  };
150
172
 
151
173
  const release = (reason: string): void => {
@@ -171,16 +193,28 @@ export function carousel(options: CarouselOptions): Carousel {
171
193
  };
172
194
 
173
195
  const restartAutoplay = (): void => {
174
- clearInterval(autoplayTimer);
175
196
  // Nothing to rotate through, nobody watching, or no autoplay asked
176
197
  // for — in each case, nothing to schedule.
177
- if (autoplayMs === undefined || !canMove || !attached) return;
178
- if (holds.size > 0) return;
198
+ if (
199
+ autoplayMs === undefined ||
200
+ !canMove ||
201
+ !attached ||
202
+ holds.size > 0
203
+ ) {
204
+ stopAutoplay();
205
+ return;
206
+ }
207
+ clearInterval(autoplayTimer);
179
208
  autoplayTimer = setInterval(() => {
180
209
  // No `restartAutoplay` here: the interval already paces itself, and
181
210
  // resetting it from inside its own tick would only churn timers.
182
211
  if (claim()) move(index + 1);
212
+ // Reported even when the lock refused the move: the next tick is
213
+ // still `autoplayMs` away either way.
214
+ onCountdown?.(autoplayMs);
183
215
  }, autoplayMs);
216
+ counting = true;
217
+ onCountdown?.(autoplayMs);
184
218
  };
185
219
 
186
220
  /** The only place `index` changes. `at` may be out of range or negative. */
@@ -326,7 +360,7 @@ export function carousel(options: CarouselOptions): Carousel {
326
360
  return () => {
327
361
  listeners.abort();
328
362
  attached = false;
329
- clearInterval(autoplayTimer);
363
+ stopAutoplay();
330
364
 
331
365
  // The automatic holds belong to this attachment: an element
332
366
  // detached while hovered would otherwise keep "hover" forever,