@xingwangzhe/stalux 1.46.2 → 1.47.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 (60) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -2
  3. package/src/components/stalux/archives/archivesList.astro +1 -1
  4. package/src/components/stalux/categories/categoriesList.astro +6 -1
  5. package/src/components/stalux/common/Weather.astro +1 -1
  6. package/src/components/stalux/common/date.astro +1 -1
  7. package/src/components/stalux/common/search.astro +2 -0
  8. package/src/components/stalux/footer/FooterBeian.astro +2 -0
  9. package/src/components/stalux/layout/head.astro +1 -1
  10. package/src/components/stalux/links/linkList.astro +1 -1
  11. package/src/components/stalux/posts/ArticleSummary.astro +8 -1
  12. package/src/components/stalux/posts/postContent.astro +20 -2
  13. package/src/components/stalux/posts/postList.astro +6 -1
  14. package/src/components/stalux/posts/postRight.astro +11 -4
  15. package/src/components/stalux/words/wordList.astro +11 -8
  16. package/src/index.ts +16 -4
  17. package/src/internal/cjk-search-index.ts +130 -0
  18. package/src/internal/compact-styles.ts +1 -1
  19. package/src/internal/font-slices.ts +3 -3
  20. package/src/internal/page-output-cache.ts +6 -7
  21. package/src/internal/search-style.ts +16 -0
  22. package/src/internal/social-card-renderer.ts +3 -5
  23. package/src/layouts/PostLayout.astro +2 -0
  24. package/src/pages/og/[image].[format].ts +1 -1
  25. package/src/pages/posts/[post].astro +1 -0
  26. package/src/schemas/collections.ts +1 -0
  27. package/src/schemas/config.ts +5 -5
  28. package/src/scripts/analytics-runtime.ts +1 -1
  29. package/src/scripts/archive-descriptions.ts +1 -1
  30. package/src/scripts/back-to-top.ts +7 -6
  31. package/src/scripts/background.ts +2 -2
  32. package/src/scripts/countdown.ts +1 -1
  33. package/src/scripts/date-time.ts +1 -1
  34. package/src/scripts/list-reveal.ts +39 -18
  35. package/src/scripts/module-motion.ts +5 -3
  36. package/src/scripts/navigation-font.ts +4 -1
  37. package/src/scripts/navigation.ts +1 -1
  38. package/src/scripts/page-runtime.ts +6 -4
  39. package/src/scripts/pagefind-api.ts +1 -1
  40. package/src/scripts/random-posts.ts +2 -2
  41. package/src/scripts/route-motion.ts +67 -56
  42. package/src/scripts/route-out.ts +12 -14
  43. package/src/scripts/runtime-counter.ts +1 -1
  44. package/src/scripts/search-engine-entry.ts +48 -0
  45. package/src/scripts/search-engine.ts +219 -0
  46. package/src/scripts/shell-hydration.ts +60 -0
  47. package/src/scripts/smooth-scroll.ts +4 -4
  48. package/src/scripts/swup-internals.d.ts +31 -0
  49. package/src/scripts/swup-router.ts +26 -11
  50. package/src/scripts/waline.ts +1 -1
  51. package/src/scripts/weather.ts +148 -82
  52. package/src/scripts/webmcp.ts +24 -20
  53. package/src/utils/ai-discovery.ts +1 -1
  54. package/src/utils/cc.ts +2 -2
  55. package/src/utils/cjk-search.ts +40 -0
  56. package/src/utils/collections-stats.ts +4 -2
  57. package/src/utils/dayjs.ts +2 -2
  58. package/src/utils/i18n.ts +2 -2
  59. package/src/utils/seo-document.ts +4 -4
  60. package/src/utils/taxonomy.ts +10 -12
@@ -164,7 +164,7 @@ export const commentSchema = z.object({
164
164
  .object({
165
165
  serverURL: z.url("comment.waline.serverURL must be a valid URL").optional(),
166
166
  lang: z.string().optional().default("zh-CN"),
167
- locale: z.any().optional(),
167
+ locale: z.unknown().optional(),
168
168
  emoji: z
169
169
  .array(z.string())
170
170
  .optional()
@@ -181,10 +181,10 @@ export const commentSchema = z.object({
181
181
  dark: z.union([z.string(), z.boolean()]).optional().default(true),
182
182
  noCopyright: z.boolean().optional().default(false),
183
183
  commentSorting: z.string().optional().default("latest"),
184
- imageUploader: z.any().optional(),
185
- highlighter: z.any().optional(),
186
- texRenderer: z.any().optional(),
187
- search: z.any().optional(),
184
+ imageUploader: z.unknown().optional(),
185
+ highlighter: z.unknown().optional(),
186
+ texRenderer: z.unknown().optional(),
187
+ search: z.unknown().optional(),
188
188
  wordLimit: z.number().optional().default(200),
189
189
  pageSize: z.number().optional().default(10),
190
190
  })
@@ -244,7 +244,7 @@ function trackGooglePage(): void {
244
244
 
245
245
  function trackUmamiPage(): void {
246
246
  const config = document.body?.dataset;
247
- if (!config?.staluxUmamiId || !config.staluxUmamiUrl) return;
247
+ if (!(config?.staluxUmamiId && config.staluxUmamiUrl)) return;
248
248
  // Snapshot now: a slow SDK must not attribute every queued visit to the last URL.
249
249
  const payload = {
250
250
  website: config.staluxUmamiId,
@@ -36,7 +36,7 @@ export function getArchiveDescriptionPosition(
36
36
  export function mountArchiveDescriptions() {
37
37
  const root = document.querySelector<HTMLElement>(".stalux-archive-container");
38
38
  const tooltip = document.querySelector<HTMLElement>("[data-archive-tooltip]");
39
- if (!root || !tooltip) return;
39
+ if (!(root && tooltip)) return;
40
40
  const usePopover =
41
41
  typeof tooltip.showPopover === "function" && typeof tooltip.hidePopover === "function";
42
42
  if (usePopover) tooltip.setAttribute("popover", "manual");
@@ -1,5 +1,7 @@
1
- interface BackToTopWindow extends Window {
2
- __STALUX_BACK_TO_TOP__?: boolean;
1
+ declare global {
2
+ interface Window {
3
+ __STALUX_BACK_TO_TOP__?: boolean;
4
+ }
3
5
  }
4
6
 
5
7
  const SENTINEL_SELECTOR = "[data-stalux-scroll-sentinel]";
@@ -14,12 +16,11 @@ const BUTTON_SELECTOR = "[data-stalux-back-to-top]";
14
16
  * not reached `ready`.
15
17
  */
16
18
  export function installBackToTop(): void {
17
- const host = window as BackToTopWindow;
18
- if (host.__STALUX_BACK_TO_TOP__) return;
19
+ if (window.__STALUX_BACK_TO_TOP__) return;
19
20
  const sentinel = document.querySelector(SENTINEL_SELECTOR);
20
21
  const button = document.querySelector<HTMLButtonElement>(BUTTON_SELECTOR);
21
- if (!sentinel || !button) return;
22
- host.__STALUX_BACK_TO_TOP__ = true;
22
+ if (!(sentinel && button)) return;
23
+ window.__STALUX_BACK_TO_TOP__ = true;
23
24
 
24
25
  button.addEventListener("click", () => {
25
26
  window.scrollTo({
@@ -34,7 +34,7 @@ export function installSessionBackground(): void {
34
34
  if (connection?.saveData) return;
35
35
  const a = document.querySelector<HTMLElement>('[data-stalux-background="a"]');
36
36
  const b = document.querySelector<HTMLElement>('[data-stalux-background="b"]');
37
- if (!a || !b || a.style.backgroundImage || b.style.backgroundImage) return;
37
+ if (!(a && b) || a.style.backgroundImage || b.style.backgroundImage) return;
38
38
  const custom = Boolean(window.__STALUX_BG_URLS__?.length);
39
39
  const urls = window.__STALUX_BG_URLS__?.length
40
40
  ? window.__STALUX_BG_URLS__
@@ -50,7 +50,7 @@ export function installSessionBackground(): void {
50
50
  () => {
51
51
  // These two shell nodes retain their identities during Swup visits.
52
52
  // No navigation event can start another image or reset this result.
53
- if (!a.isConnected || !b.isConnected) return;
53
+ if (!(a.isConnected && b.isConnected)) return;
54
54
  // Original SVGs have only a viewBox: auto sizes them as contain.
55
55
  // Raster files have intrinsic dimensions, so retain that sizing
56
56
  // explicitly. Custom URLs keep the existing auto semantics.
@@ -3,7 +3,7 @@ import { registerPageLifecycle } from "./page-runtime";
3
3
  registerPageLifecycle("not-found-countdown", () => {
4
4
  const el = document.getElementById("countdown");
5
5
  const template = el?.dataset.countdownTemplate;
6
- if (!el || !template) return;
6
+ if (!(el && template)) return;
7
7
  let seconds = 5;
8
8
  const timer = window.setInterval(() => {
9
9
  seconds -= 1;
@@ -25,7 +25,7 @@ function toDisplayDate(date: Date, lang: string): string {
25
25
  registerPageLifecycle("date-time", () => {
26
26
  const dateElement = document.querySelector<HTMLTimeElement>('[data-ref="date"]');
27
27
  const timeElement = document.querySelector<HTMLTimeElement>('[data-ref="time"]');
28
- if (!dateElement || !timeElement) return;
28
+ if (!(dateElement && timeElement)) return;
29
29
  const lang = document.documentElement.lang || "zh-CN";
30
30
  let lastDate = "";
31
31
  const update = () => {
@@ -4,13 +4,17 @@ const ITEM = ":scope > [data-stalux-reveal-item]";
4
4
  // never a queue long enough to keep the reader waiting for the last item.
5
5
  const MAX_CONTAINERS = 48;
6
6
  const MAX_ITEMS = 16;
7
- const STAGGER_MS = 45;
8
- const MAX_DELAY_MS = 400;
9
- const DURATION_MS = 380;
7
+ // The whole unroll budget is lead-in + max delay + row duration. At the previous
8
+ // 150 + 400 + 380 the tail alone ran 930ms after the swap, which readers report
9
+ // as the page still moving a second later. Every number here is half of that, so
10
+ // the same sheet reads in one motion without the wait.
11
+ const STAGGER_MS = 22;
12
+ const MAX_DELAY_MS = 200;
13
+ const DURATION_MS = 190;
10
14
  // When a route cue is already moving the page body, the sheet waits for it to
11
15
  // mostly settle before unrolling. Starting both at once makes the rows wipe
12
16
  // open while the whole page is still semi-transparent, which reads as a smudge.
13
- const ROUTE_LEAD_IN_MS = 150;
17
+ const ROUTE_LEAD_IN_MS = 75;
14
18
  // Strong ease-out: the row arrives fast and settles softly. The built-in
15
19
  // ease-out is too weak to read at this size.
16
20
  const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
@@ -46,7 +50,10 @@ interface Held {
46
50
  stagger: number;
47
51
  timer: ReturnType<typeof setTimeout>;
48
52
  }
49
- const held = new Map<HTMLElement, Held>();
53
+ // Rows and groups are only ever queried, measured and animated, all of which
54
+ // Element already provides. Typing them as HTMLElement forced a cast on the
55
+ // observer's target for no gain.
56
+ const held = new Map<Element, Held>();
50
57
 
51
58
  function canReveal(): boolean {
52
59
  if (typeof IntersectionObserver !== "function") return false;
@@ -57,14 +64,22 @@ function canReveal(): boolean {
57
64
  return document.documentElement.dataset.staluxVtPreset !== "restore";
58
65
  }
59
66
 
60
- function groups(): HTMLElement[] {
61
- return [...document.querySelectorAll<HTMLElement>(CONTAINER)]
62
- .filter((element) => !element.closest("[data-swup-persist]"))
67
+ function groups(): Element[] {
68
+ return [...document.querySelectorAll<Element>(CONTAINER)]
69
+ .filter(
70
+ (element) =>
71
+ // A retained shell kept its identity, so its list was already
72
+ // read: cueing it again would be a second animation. Contents
73
+ // that shell-hydration just replaced are new content and are
74
+ // explicitly marked as such.
75
+ element.hasAttribute("data-stalux-shell-fresh") ||
76
+ !element.closest("[data-swup-persist]"),
77
+ )
63
78
  .slice(0, MAX_CONTAINERS);
64
79
  }
65
80
 
66
- function rows(group: HTMLElement): HTMLElement[] {
67
- return [...group.querySelectorAll<HTMLElement>(ITEM)].slice(0, MAX_ITEMS);
81
+ function rows(group: Element): Element[] {
82
+ return [...group.querySelectorAll<Element>(ITEM)].slice(0, MAX_ITEMS);
68
83
  }
69
84
 
70
85
  /**
@@ -76,7 +91,7 @@ function rows(group: HTMLElement): HTMLElement[] {
76
91
  * the entry cue needed. Groups further down the page keep the authored visible
77
92
  * state and start their wipe when they scroll in, which never flashed anyway.
78
93
  */
79
- function nearViewport(group: HTMLElement): boolean {
94
+ function nearViewport(group: Element): boolean {
80
95
  const rect = group.getBoundingClientRect();
81
96
  if (rect.width <= 0 || rect.height <= 0) return false;
82
97
  const margin = window.innerHeight * 0.5;
@@ -130,6 +145,11 @@ export function primeListReveal(): void {
130
145
  fill: "backwards",
131
146
  });
132
147
  animation.pause();
148
+ // A paused effect only ever settles by rejecting when the hold is
149
+ // released, and nothing awaits it yet: without a handler that is an
150
+ // unhandled rejection in the console. `reveal` attaches the real
151
+ // handler when it takes the effect over.
152
+ void animation.finished.catch(() => undefined);
133
153
  const timer = setTimeout(() => {
134
154
  // The visit was abandoned: release the row so the authored
135
155
  // visible state returns rather than staying clipped.
@@ -179,7 +199,7 @@ export function mountListReveal(): (() => void) | undefined {
179
199
  for (const animation of active) animation.cancel();
180
200
  active.clear();
181
201
  };
182
- const reveal = (container: HTMLElement) => {
202
+ const reveal = (container: Element) => {
183
203
  if (document.hidden || preference.matches) return;
184
204
  const lead = leadIn();
185
205
  for (const [index, row] of rows(container).entries()) {
@@ -226,7 +246,7 @@ export function mountListReveal(): (() => void) | undefined {
226
246
  for (const entry of entries) {
227
247
  if (!entry.isIntersecting) continue;
228
248
  observer.unobserve(entry.target);
229
- reveal(entry.target as HTMLElement);
249
+ reveal(entry.target);
230
250
  }
231
251
  },
232
252
  { threshold: 0.05 },
@@ -249,13 +269,14 @@ export function mountListReveal(): (() => void) | undefined {
249
269
  // before the browser paints, which is the only moment where the incoming rows
250
270
  // can be held without a visible flash. Registered once per document even when
251
271
  // both the shared layout and the article layout import this module.
252
- interface PrimeHost {
253
- __staluxListRevealPrime?: boolean;
272
+ declare global {
273
+ interface Window {
274
+ __staluxListRevealPrime?: boolean;
275
+ }
254
276
  }
255
277
  if (typeof document !== "undefined" && typeof window !== "undefined") {
256
- const host = document as Document & PrimeHost;
257
- if (!host.__staluxListRevealPrime) {
258
- host.__staluxListRevealPrime = true;
278
+ if (!window.__staluxListRevealPrime) {
279
+ window.__staluxListRevealPrime = true;
259
280
  document.addEventListener("astro:after-swap", primeListReveal);
260
281
  }
261
282
  }
@@ -11,7 +11,7 @@ export function mountModuleMotion(): (() => void) | undefined {
11
11
  typeof IntersectionObserver !== "function"
12
12
  )
13
13
  return;
14
- const elements = [...document.querySelectorAll<HTMLElement>(SELECTOR)].slice(0, MAX_REGIONS);
14
+ const elements = [...document.querySelectorAll<Element>(SELECTOR)].slice(0, MAX_REGIONS);
15
15
  if (!elements.length) return;
16
16
  const active = new Set<Animation>();
17
17
  const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
@@ -23,7 +23,9 @@ export function mountModuleMotion(): (() => void) | undefined {
23
23
  (entries) => {
24
24
  for (const entry of entries) {
25
25
  if (!entry.isIntersecting) continue;
26
- const element = entry.target as HTMLElement;
26
+ // Element already covers everything used below (closest, animate),
27
+ // so the observer's target needs no assertion.
28
+ const element = entry.target;
27
29
  observer.unobserve(element);
28
30
  // The router owns a single bounded lead region. Do not layer an
29
31
  // observer entry over that motion or make long comments slide.
@@ -44,7 +46,7 @@ export function mountModuleMotion(): (() => void) | undefined {
44
46
  const animation = element.animate(
45
47
  { opacity: [0.88, 1] },
46
48
  {
47
- duration: 140,
49
+ duration: 70,
48
50
  easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
49
51
  },
50
52
  );
@@ -16,7 +16,10 @@ function warmFont(href: string): Promise<FontFace> {
16
16
  });
17
17
  const ready = face.load();
18
18
  fonts.set(href, { face, ready });
19
- if (fonts.size > MAX_FONTS) fonts.delete(fonts.keys().next().value as string);
19
+ if (fonts.size > MAX_FONTS) {
20
+ const oldest = fonts.keys().next();
21
+ if (!oldest.done) fonts.delete(oldest.value);
22
+ }
20
23
  void ready.catch(() => {
21
24
  if (fonts.get(href)?.ready === ready) fonts.delete(href);
22
25
  });
@@ -38,7 +38,7 @@ registerPageLifecycle("navigation", () => {
38
38
  const navOverlay = document.querySelector<HTMLElement>("[data-ref='stalux-nav-overlay']");
39
39
  const buttonLeft = document.querySelector<HTMLElement>("[data-ref='stalux-nav-scroll-left']");
40
40
  const buttonRight = document.querySelector<HTMLElement>("[data-ref='stalux-nav-scroll-right']");
41
- if (!navList || !buttonLeft || !buttonRight) return;
41
+ if (!(navList && buttonLeft && buttonRight)) return;
42
42
 
43
43
  const controller = new AbortController();
44
44
  const listenerOptions = { signal: controller.signal };
@@ -12,12 +12,14 @@ interface RuntimeRegistration {
12
12
  unregister: PageDisposer;
13
13
  }
14
14
 
15
- interface RuntimeHost {
16
- __staluxPageRuntime?: Map<string, RuntimeRegistration>;
15
+ declare global {
16
+ interface Window {
17
+ __staluxPageRuntime?: Map<string, RuntimeRegistration>;
18
+ }
17
19
  }
18
20
 
19
21
  export interface PageRuntimeEnvironment {
20
- host: RuntimeHost;
22
+ host: Pick<Window, "__staluxPageRuntime">;
21
23
  target: RuntimeTarget;
22
24
  queueTask: (callback: () => void) => void;
23
25
  }
@@ -25,7 +27,7 @@ export interface PageRuntimeEnvironment {
25
27
  function getBrowserEnvironment(): PageRuntimeEnvironment | undefined {
26
28
  if (typeof window === "undefined" || typeof document === "undefined") return undefined;
27
29
  return {
28
- host: window as RuntimeHost,
30
+ host: window,
29
31
  target: document,
30
32
  queueTask: queueMicrotask,
31
33
  };
@@ -30,7 +30,7 @@ export function createPagefindLoader(
30
30
  if (!pending) {
31
31
  pending = (async () => {
32
32
  try {
33
- const url = new URL(sitePath("pagefind/pagefind.js", base), origin());
33
+ const url = new URL(sitePath("pagefind-enhanced/pagefind.js", base), origin());
34
34
  // Browsers can retain a failed ESM fetch; retry with a fresh module URL.
35
35
  if (failedAttempts)
36
36
  url.searchParams.set("stalux_retry", String(failedAttempts));
@@ -10,7 +10,7 @@ function shuffle<T extends object>(items: T[]): T[] {
10
10
  const j = (crypto.getRandomValues(bytes)[0] ?? 0) % (i + 1);
11
11
  const current = result[i];
12
12
  const target = result[j];
13
- if (!current || !target) throw new RangeError("Shuffle index is out of bounds");
13
+ if (!(current && target)) throw new RangeError("Shuffle index is out of bounds");
14
14
  result[i] = target;
15
15
  result[j] = current;
16
16
  }
@@ -35,7 +35,7 @@ function render(posts: { abbrlink: string; title: string }[]) {
35
35
  export function mountRandomPosts(): (() => void) | undefined {
36
36
  const container = document.querySelector<HTMLElement>("[data-stalux-random-posts]");
37
37
  const list = container?.querySelector<HTMLElement>("#random-posts-list");
38
- if (!container || !list) return;
38
+ if (!(container && list)) return;
39
39
  const controller = new AbortController();
40
40
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
41
41
  const fallback = container.dataset.loadError ?? "Failed to load posts";
@@ -37,17 +37,17 @@ interface HeadingMotion {
37
37
  // Legacy heading cues remain available to integrations. The router requests
38
38
  // one content animation; it never stacks a second animation on the heading.
39
39
  const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
40
- glide: { opacity: 0.86, duration: 100 },
41
- article: { opacity: 0.86, duration: 100 },
42
- enter: { opacity: 0.86, duration: 140, y: 6 },
43
- up: { opacity: 0.9, duration: 120, y: -4 },
44
- taxonomy: { opacity: 0.85, duration: 120 },
45
- previous: { opacity: 0.9, duration: 140, x: -8 },
46
- next: { opacity: 0.9, duration: 140, x: 8 },
47
- panel: { opacity: 0.9, duration: 120, y: 4 },
48
- home: { opacity: 0.9, duration: 120, y: 4 },
49
- restore: { opacity: 0.96, duration: 80 },
50
- error: { opacity: 0.9, duration: 80 },
40
+ glide: { opacity: 0.86, duration: 50 },
41
+ article: { opacity: 0.86, duration: 50 },
42
+ enter: { opacity: 0.86, duration: 70, y: 6 },
43
+ up: { opacity: 0.9, duration: 60, y: -4 },
44
+ taxonomy: { opacity: 0.85, duration: 60 },
45
+ previous: { opacity: 0.9, duration: 70, x: -8 },
46
+ next: { opacity: 0.9, duration: 70, x: 8 },
47
+ panel: { opacity: 0.9, duration: 60, y: 4 },
48
+ home: { opacity: 0.9, duration: 60, y: 4 },
49
+ restore: { opacity: 0.96, duration: 40 },
50
+ error: { opacity: 0.9, duration: 40 },
51
51
  };
52
52
 
53
53
  const MAIN = "main[data-stalux-route]";
@@ -55,41 +55,50 @@ const ARTICLE = "article.stalux-article";
55
55
  // Authored per page type by the theme, not inferred from layout: the article
56
56
  // body, archive list, tag cloud, post list, word list, link list, home block.
57
57
  const CONTENT = "[data-stalux-motion-content]";
58
+ // The one node a page nominates as its cue stage. Preferred over measuring,
59
+ // because measuring an article column forces a full layout of a document that
60
+ // is tens of thousands of pixels tall, before it has ever been painted.
61
+ const STAGE = "[data-stalux-motion-stage]";
58
62
  // A persist node marks the three-column article shell. Those sidebars keep
59
63
  // their identity across visits and must never travel with a route cue.
60
64
  const PERSIST = "[data-swup-persist]";
61
65
 
62
66
  interface StageMotion {
63
67
  opacity: number;
64
- /** Enter offset in px. Positive y enters from below, positive x from the right. */
68
+ /**
69
+ * Enter offset in px. Positive y enters from below, positive x from the right.
70
+ * Zero means a pure cross-fade: the column is replaced in place.
71
+ */
65
72
  distance: number;
66
73
  axis: "x" | "y";
67
74
  duration: number;
68
75
  }
69
76
 
70
77
  // One entry per route kind, because the components are not interchangeable: an
71
- // article opens from below, the next article slides in from the side that the
72
- // reader is heading toward, and going back up to an index pulls away upward.
78
+ // article opens from below and going back up to an index pulls away upward.
79
+ //
80
+ // Article-to-article is the exception: the reader keeps the same shell (sidebars,
81
+ // header) and only the column changes, so the column fades in place instead of
82
+ // travelling. Sliding a 28,000px column sideways made the whole page look like it
83
+ // jerked, and the shell already tells the reader where they are.
73
84
  const stageMotions: Record<Exclude<RouteMotionPreset, "none">, StageMotion> = {
74
- // Article-to-article keeps a lateral direction: previous/next already says
75
- // which way the reading order goes, so the cue repeats that, not a fade.
76
- previous: { opacity: 0, distance: -36, axis: "x", duration: 300 },
77
- next: { opacity: 0, distance: 36, axis: "x", duration: 300 },
85
+ previous: { opacity: 0, distance: 0, axis: "x", duration: 150 },
86
+ next: { opacity: 0, distance: 0, axis: "x", duration: 150 },
78
87
  // Entering a different article without a reading-direction trigger.
79
- article: { opacity: 0, distance: 40, axis: "y", duration: 300 },
88
+ article: { opacity: 0, distance: 0, axis: "y", duration: 150 },
80
89
  // Reaching an article from any list or the home block.
81
- enter: { opacity: 0, distance: 56, axis: "y", duration: 340 },
82
- home: { opacity: 0, distance: 48, axis: "y", duration: 320 },
90
+ enter: { opacity: 0, distance: 56, axis: "y", duration: 170 },
91
+ home: { opacity: 0, distance: 48, axis: "y", duration: 160 },
83
92
  // Leaving an article for the archive or a taxonomy index.
84
- up: { opacity: 0, distance: -44, axis: "y", duration: 300 },
93
+ up: { opacity: 0, distance: -44, axis: "y", duration: 150 },
85
94
  // Moving between two taxonomy detail pages.
86
- taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 280 },
95
+ taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 140 },
87
96
  // About / links / words: single-panel pages.
88
- panel: { opacity: 0, distance: 44, axis: "y", duration: 300 },
89
- glide: { opacity: 0, distance: 40, axis: "y", duration: 280 },
97
+ panel: { opacity: 0, distance: 44, axis: "y", duration: 150 },
98
+ glide: { opacity: 0, distance: 40, axis: "y", duration: 140 },
90
99
  // History traversal restores what the reader already saw.
91
- restore: { opacity: 0, distance: -36, axis: "y", duration: 280 },
92
- error: { opacity: 0, distance: 28, axis: "y", duration: 260 },
100
+ restore: { opacity: 0, distance: -36, axis: "y", duration: 140 },
101
+ error: { opacity: 0, distance: 28, axis: "y", duration: 130 },
93
102
  };
94
103
 
95
104
  const laidOut = (element: HTMLElement): boolean => {
@@ -100,14 +109,17 @@ const laidOut = (element: HTMLElement): boolean => {
100
109
  /**
101
110
  * The single node a route cue may animate for the current document.
102
111
  *
103
- * Picks the largest authored content stage, so an article moves its own column
104
- * and an archive moves its list, while sidebars (persisted shells), the header
105
- * and the footer stay untouched. Falls back to `main` only for pages without a
106
- * persisted shell inside it.
112
+ * A page can nominate its stage; only when it does not is the largest authored
113
+ * content stage measured. Measuring is the fallback, not the rule: on an article
114
+ * the column is tens of thousands of pixels tall and measuring it forces a full
115
+ * layout of the incoming document at the exact moment the entry cue needs to
116
+ * start. Sidebars (persisted shells), the header and the footer never qualify.
107
117
  */
108
118
  export function getRouteStage(): HTMLElement | null {
109
119
  const main = document.querySelector<HTMLElement>(MAIN);
110
120
  if (!main?.isConnected) return null;
121
+ const authored = main.querySelector<HTMLElement>(STAGE);
122
+ if (authored?.isConnected && !authored.closest(PERSIST)) return authored;
111
123
  const candidates: HTMLElement[] = [];
112
124
  const article = main.querySelector<HTMLElement>(ARTICLE);
113
125
  if (article) candidates.push(article);
@@ -156,7 +168,7 @@ function completion() {
156
168
 
157
169
  /** A route transition never captures a long article, list, or off-screen heading. */
158
170
  export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | null {
159
- if (!focus?.isConnected || !focus.hasAttribute("data-stalux-motion-focus")) return null;
171
+ if (!(focus?.isConnected && focus.hasAttribute("data-stalux-motion-focus"))) return null;
160
172
  const visible = (element: HTMLElement, maxHeight: number) => {
161
173
  const rect = element.getBoundingClientRect();
162
174
  return (
@@ -177,17 +189,17 @@ export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | n
177
189
  }
178
190
 
179
191
  const regionMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
180
- glide: { opacity: 0.72, duration: 160 },
181
- article: { opacity: 0.72, duration: 180, y: 4 },
182
- enter: { opacity: 0.72, duration: 180, y: 6 },
183
- up: { opacity: 0.8, duration: 160, y: -4 },
184
- taxonomy: { opacity: 0.72, duration: 160, x: 6 },
185
- previous: { opacity: 0.76, duration: 180, x: -8 },
186
- next: { opacity: 0.76, duration: 180, x: 8 },
187
- panel: { opacity: 0.76, duration: 160, y: 4 },
188
- home: { opacity: 0.76, duration: 180, y: 4 },
192
+ glide: { opacity: 0.72, duration: 80 },
193
+ article: { opacity: 0.72, duration: 90, y: 4 },
194
+ enter: { opacity: 0.72, duration: 90, y: 6 },
195
+ up: { opacity: 0.8, duration: 80, y: -4 },
196
+ taxonomy: { opacity: 0.72, duration: 80, x: 6 },
197
+ previous: { opacity: 0.76, duration: 90, x: -8 },
198
+ next: { opacity: 0.76, duration: 90, x: 8 },
199
+ panel: { opacity: 0.76, duration: 80, y: 4 },
200
+ home: { opacity: 0.76, duration: 90, y: 4 },
189
201
  restore: { opacity: 1, duration: 0 },
190
- error: { opacity: 0.85, duration: 100 },
202
+ error: { opacity: 0.85, duration: 50 },
191
203
  };
192
204
 
193
205
  /** Share Swup's actual content animation with background and analytics consumers. */
@@ -281,19 +293,18 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
281
293
  spec.axis === "x"
282
294
  ? `translate3d(${distance}px, 0, 0)`
283
295
  : `translate3d(0, ${distance}px, 0)`;
284
- animation = stage.animate(
285
- {
286
- opacity: [spec.opacity, 1],
287
- transform: [from, "translate3d(0, 0, 0)"],
288
- },
289
- {
290
- duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
291
- // Strong ease-out: quick arrival, soft settle. The
292
- // built-in ease-out is too weak to read at this size.
293
- easing: "cubic-bezier(0.23, 1, 0.32, 1)",
294
- fill: "both",
295
- },
296
- );
296
+ // A zero distance is a cross-fade in place. Authoring a
297
+ // translate3d(0,0,0) would put a 28,000px column on its own
298
+ // compositor layer for nothing.
299
+ const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
300
+ if (distance !== 0) keyframes.transform = [from, "translate3d(0, 0, 0)"];
301
+ animation = stage.animate(keyframes, {
302
+ duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
303
+ // Strong ease-out: quick arrival, soft settle. The
304
+ // built-in ease-out is too weak to read at this size.
305
+ easing: "cubic-bezier(0.23, 1, 0.32, 1)",
306
+ fill: "both",
307
+ });
297
308
  } else {
298
309
  const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
299
310
  const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
@@ -306,7 +317,7 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
306
317
  ];
307
318
  }
308
319
  animation = target.animate(keyframes, {
309
- duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
320
+ duration: mobile ? (options?.content ? 60 : 40) : spec.duration,
310
321
  easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
311
322
  fill: "both",
312
323
  });
@@ -16,8 +16,7 @@ export async function animateRouteOut(
16
16
  const spec = getStageExit(preset);
17
17
  const stage = spec ? getRouteStage() : null;
18
18
  if (
19
- !spec ||
20
- !stage ||
19
+ !(spec && stage) ||
21
20
  signal.aborted ||
22
21
  document.hidden ||
23
22
  typeof stage.animate !== "function" ||
@@ -28,6 +27,10 @@ export async function animateRouteOut(
28
27
  const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
29
28
  const to =
30
29
  spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
30
+ // Article-to-article has no distance: the column fades out in place, matching
31
+ // the entry fade, so the shell stays put and only the content disappears.
32
+ const keyframes: PropertyIndexedKeyframes = { opacity: [1, 0] };
33
+ if (distance !== 0) keyframes.transform = ["translate3d(0, 0, 0)", to];
31
34
  let animation: Animation | undefined;
32
35
  const cancel = () => {
33
36
  const active = animation;
@@ -39,18 +42,13 @@ export async function animateRouteOut(
39
42
  }
40
43
  };
41
44
  try {
42
- animation = stage.animate(
43
- { opacity: [1, 0], transform: ["translate3d(0, 0, 0)", to] },
44
- {
45
- duration: mobile
46
- ? Math.round(spec.duration * 0.62)
47
- : Math.round(spec.duration * 0.58),
48
- // Strong ease-in: the outgoing document accelerates away. An exit
49
- // is the one place ease-in is correct; entrances use ease-out.
50
- easing: "cubic-bezier(0.4, 0, 1, 1)",
51
- fill: "forwards",
52
- },
53
- );
45
+ animation = stage.animate(keyframes, {
46
+ duration: mobile ? Math.round(spec.duration * 0.62) : Math.round(spec.duration * 0.58),
47
+ // Strong ease-in: the outgoing document accelerates away. An exit
48
+ // is the one place ease-in is correct; entrances use ease-out.
49
+ easing: "cubic-bezier(0.4, 0, 1, 1)",
50
+ fill: "forwards",
51
+ });
54
52
  signal.addEventListener("abort", cancel, { once: true });
55
53
  await animation.finished;
56
54
  // The stage node survives the swap, so this filled effect is what holds
@@ -4,7 +4,7 @@ import { observeVisibleClock } from "./visible-clock";
4
4
  registerPageLifecycle("runtime-counter", () => {
5
5
  const container = document.querySelector<HTMLElement>("[data-stalux-buildtime]");
6
6
  const counter = document.getElementById("runtime-counter");
7
- if (!container || !counter) return;
7
+ if (!(container && counter)) return;
8
8
  const start = new Date(container.dataset.staluxBuildtime ?? "");
9
9
  const format =
10
10
  container.dataset.staluxRuntimeFormat ?? "{days}d {hours}h {minutes}m {seconds}s";