@escape-game-over/atlas 0.1.31 → 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.31",
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>