@xingwangzhe/stalux 1.45.0 → 1.45.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.45.0",
3
+ "version": "1.45.1",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -12,10 +12,12 @@ export interface RouteMotionEvent {
12
12
 
13
13
  export interface RouteMotionOptions {
14
14
  skip?: boolean;
15
- /** Use a bounded introductory region around the marked heading. */
15
+ /** Animate the route's own content stage instead of a bounded heading area. */
16
16
  content?: boolean;
17
17
  preset?: RouteMotionPreset;
18
18
  browserMotion?: boolean;
19
+ /** Releases the outgoing fill effect inside the entry cue's sync block. */
20
+ releaseOutgoing?: () => void;
19
21
  }
20
22
 
21
23
  export interface RouteMotion {
@@ -48,13 +50,102 @@ const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion>
48
50
  error: { opacity: 0.9, duration: 80 },
49
51
  };
50
52
 
53
+ const MAIN = "main[data-stalux-route]";
54
+ const ARTICLE = "article.stalux-article";
55
+ // Authored per page type by the theme, not inferred from layout: the article
56
+ // body, archive list, tag cloud, post list, word list, link list, home block.
57
+ const CONTENT = "[data-stalux-motion-content]";
58
+ // A persist node marks the three-column article shell. Those sidebars keep
59
+ // their identity across visits and must never travel with a route cue.
60
+ const PERSIST = "[data-swup-persist]";
61
+
62
+ interface StageMotion {
63
+ opacity: number;
64
+ /** Enter offset in px. Positive y enters from below, positive x from the right. */
65
+ distance: number;
66
+ axis: "x" | "y";
67
+ duration: number;
68
+ }
69
+
70
+ // 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.
73
+ 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 },
78
+ // Entering a different article without a reading-direction trigger.
79
+ article: { opacity: 0, distance: 40, axis: "y", duration: 300 },
80
+ // 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 },
83
+ // Leaving an article for the archive or a taxonomy index.
84
+ up: { opacity: 0, distance: -44, axis: "y", duration: 300 },
85
+ // Moving between two taxonomy detail pages.
86
+ taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 280 },
87
+ // 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 },
90
+ // 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 },
93
+ };
94
+
95
+ const laidOut = (element: HTMLElement): boolean => {
96
+ const rect = element.getBoundingClientRect();
97
+ return rect.width > 0 && rect.height > 0;
98
+ };
99
+
100
+ /**
101
+ * The single node a route cue may animate for the current document.
102
+ *
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.
107
+ */
108
+ export function getRouteStage(): HTMLElement | null {
109
+ const main = document.querySelector<HTMLElement>(MAIN);
110
+ if (!main?.isConnected) return null;
111
+ const candidates: HTMLElement[] = [];
112
+ const article = main.querySelector<HTMLElement>(ARTICLE);
113
+ if (article) candidates.push(article);
114
+ for (const element of main.querySelectorAll<HTMLElement>(CONTENT)) candidates.push(element);
115
+
116
+ let stage: HTMLElement | null = null;
117
+ let area = 0;
118
+ for (const element of candidates) {
119
+ // A stage inside a persisted subtree is the shell itself.
120
+ if (!element.isConnected || element.closest(PERSIST)) continue;
121
+ const rect = element.getBoundingClientRect();
122
+ if (rect.width <= 0 || rect.height <= 0) continue;
123
+ const current = rect.width * rect.height;
124
+ if (current > area) {
125
+ area = current;
126
+ stage = element;
127
+ }
128
+ }
129
+ if (stage) return stage;
130
+ if (main.querySelector(PERSIST)) return null;
131
+ return laidOut(main) ? main : null;
132
+ }
133
+
134
+ /** Offset applied to the outgoing document, continuing the entry direction. */
135
+ export function getStageExit(preset: RouteMotionPreset): StageMotion | null {
136
+ if (preset === "none") return null;
137
+ const motion = stageMotions[preset];
138
+ // Entering from below means leaving upward, so both halves read as one
139
+ // continuous flow instead of an enter/leave bounce.
140
+ return { ...motion, distance: -Math.round(motion.distance * 0.72) };
141
+ }
142
+
51
143
  const motions = new WeakMap<object, RouteMotion>();
52
144
  const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
53
145
  Promise.resolve(promise).then(
54
146
  () => undefined,
55
147
  () => undefined,
56
148
  );
57
-
58
149
  function completion() {
59
150
  let resolve = () => {};
60
151
  const promise = new Promise<void>((done) => {
@@ -149,41 +240,80 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
149
240
  start(heading, options) {
150
241
  if (started) return;
151
242
  started = true;
243
+ // Every exit path must release the outgoing fill, or the swapped
244
+ // document stays transparent behind a cancelled cue.
245
+ const release = options?.releaseOutgoing;
152
246
  try {
153
247
  const preset = options?.preset ?? "glide";
154
248
  const mobile = window.matchMedia?.("(max-width: 768px)").matches;
155
- const target = getRouteMotionTarget(heading);
156
249
  if (
157
- !target ||
158
- preset === "none" ||
159
- (options?.content && preset === "restore") ||
160
250
  options?.skip ||
161
251
  options?.browserMotion ||
252
+ preset === "none" ||
253
+ (options?.content && preset === "restore") ||
254
+ // History never replays an entry on a phone: the reader is
255
+ // returning to a position, not arriving somewhere new.
256
+ (mobile && preset === "restore") ||
162
257
  document.hidden ||
163
- window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
164
- typeof target.animate !== "function" ||
165
- (mobile && preset === "restore")
258
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
166
259
  ) {
260
+ release?.();
261
+ animationDone.resolve();
262
+ return;
263
+ }
264
+ const stage = options?.content ? getRouteStage() : null;
265
+ const target = stage ?? getRouteMotionTarget(heading);
266
+ if (!target || typeof target.animate !== "function") {
267
+ release?.();
167
268
  animationDone.resolve();
168
269
  return;
169
270
  }
170
- const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
171
- const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
172
- // Phones use a pure fade. Explicit previous/next alone imply a
173
- // lateral reading direction; history never replays the entry.
174
- if (!mobile && (spec.x || spec.y)) {
175
- keyframes.transform = [
176
- `translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
177
- "translate3d(0px, 0px, 0)",
178
- ];
271
+ // Cancelling the outgoing fill and starting the entry cue must
272
+ // share one synchronous block. Split across frames, the reader
273
+ // sees the new document at full opacity for a frame first.
274
+ release?.();
275
+ if (stage) {
276
+ const spec = stageMotions[preset];
277
+ // Phones get a shorter throw; the same offset reads as a much
278
+ // larger move on a narrow viewport.
279
+ const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
280
+ const from =
281
+ spec.axis === "x"
282
+ ? `translate3d(${distance}px, 0, 0)`
283
+ : `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
+ );
297
+ } else {
298
+ const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
299
+ const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
300
+ // Phones use a pure fade. Explicit previous/next alone imply a
301
+ // lateral reading direction; history never replays the entry.
302
+ if (!mobile && (spec.x || spec.y)) {
303
+ keyframes.transform = [
304
+ `translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
305
+ "translate3d(0px, 0px, 0)",
306
+ ];
307
+ }
308
+ animation = target.animate(keyframes, {
309
+ duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
310
+ easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
311
+ fill: "both",
312
+ });
179
313
  }
180
- animation = target.animate(keyframes, {
181
- duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
182
- easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
183
- fill: "both",
184
- });
185
314
  void animation.finished.then(clearAnimation, clearAnimation);
186
315
  } catch {
316
+ release?.();
187
317
  clearAnimation();
188
318
  } finally {
189
319
  installed.resolve();
@@ -1,37 +1,62 @@
1
- import { getRouteMotionTarget } from "./route-motion";
1
+ import type { RouteMotionPreset } from "../utils/route-motion-policy";
2
+ import { getRouteStage, getStageExit } from "./route-motion";
2
3
 
3
- /** Fade the old content only after destination assets are ready. */
4
+ /**
5
+ * Fade and shift the current content stage out, continuing the entry direction
6
+ * so the two halves read as one movement instead of a flash.
7
+ *
8
+ * The returned release keeps the fill effect until the entry cue takes over.
9
+ * Cancelling here would expose the freshly swapped document at full opacity for
10
+ * a frame before it animates in — the exact flash this replaces.
11
+ */
4
12
  export async function animateRouteOut(
5
- element: HTMLElement | null,
13
+ preset: RouteMotionPreset,
6
14
  signal: AbortSignal,
7
15
  ): Promise<() => void> {
16
+ const spec = getStageExit(preset);
17
+ const stage = spec ? getRouteStage() : null;
8
18
  if (
9
- !element ||
19
+ !spec ||
20
+ !stage ||
10
21
  signal.aborted ||
11
22
  document.hidden ||
23
+ typeof stage.animate !== "function" ||
12
24
  window.matchMedia("(prefers-reduced-motion: reduce)").matches
13
25
  )
14
26
  return () => {};
15
- const target = getRouteMotionTarget(element);
16
- if (!target || typeof target.animate !== "function") return () => {};
17
- let animation: Animation | undefined;
18
- const cancel = () => animation?.cancel();
19
27
  const mobile = window.matchMedia("(max-width: 768px)").matches;
28
+ const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
29
+ const to =
30
+ spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
31
+ let animation: Animation | undefined;
32
+ const cancel = () => {
33
+ const active = animation;
34
+ animation = undefined;
35
+ try {
36
+ active?.cancel();
37
+ } catch {
38
+ /* An already detached effect cannot be released further. */
39
+ }
40
+ };
20
41
  try {
21
- animation = target.animate(
22
- mobile
23
- ? { opacity: [1, 0.82] }
24
- : { opacity: [1, 0.82], transform: ["translateY(0)", "translateY(-4px)"] },
25
- { duration: 80, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" },
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
+ },
26
53
  );
27
54
  signal.addEventListener("abort", cancel, { once: true });
28
55
  await animation.finished;
29
- // Keep the bounded outgoing cue until swap; the body stays readable.
30
- // Swup detaches this node; no persistent shell is animated.
56
+ // The stage node survives the swap, so this filled effect is what holds
57
+ // the incoming document hidden until its own cue starts.
31
58
  } catch {
32
59
  /* Cancellation and unsupported animation must not block a visit. */
33
- } finally {
34
- if (signal.aborted) cancel();
35
60
  }
36
61
  return () => {
37
62
  signal.removeEventListener("abort", cancel);
@@ -60,6 +60,16 @@ function clearMotionState(): void {
60
60
  delete document.documentElement.dataset[key];
61
61
  }
62
62
 
63
+ /** The exit fill is released by whichever path finishes first; never twice. */
64
+ function once(effect: () => void): () => void {
65
+ let done = false;
66
+ return () => {
67
+ if (done) return;
68
+ done = true;
69
+ effect();
70
+ };
71
+ }
72
+
63
73
  /** Astro renders pages; Swup alone owns their client navigation and animation. */
64
74
  export function installSwupRouter(): Swup {
65
75
  if (window.swup) return window.swup;
@@ -133,6 +143,7 @@ export function installSwupRouter(): Swup {
133
143
  content: true,
134
144
  browserMotion: context.browserMotion,
135
145
  skip: context.controller.signal.aborted,
146
+ releaseOutgoing: context.releaseOutgoing,
136
147
  },
137
148
  );
138
149
  await context.motion.finished;
@@ -252,17 +263,14 @@ export function installSwupRouter(): Swup {
252
263
  const next = visit.to.document;
253
264
  if (!context || !next || !replace) throw new Error("Missing Swup document");
254
265
  if (context.controller.signal.aborted) return;
255
- // Two cues overlap on the outgoing document: a bounded fade on the
256
- // changing content region, and the whole page walking back to the top.
257
- // The frame, sidebars and header are never part of either one.
266
+ // Two cues overlap on the outgoing document: the content stage leaving,
267
+ // and the page walking back to the top. Persisted sidebars, the header
268
+ // and the frame are never part of either one.
258
269
  if (context.navigationType !== "traverse" && context.preset !== "none") {
259
- const outgoing = animateRouteOut(
260
- document.querySelector<HTMLElement>("[data-stalux-motion-focus]"),
261
- context.controller.signal,
262
- );
270
+ const outgoing = animateRouteOut(context.preset, context.controller.signal);
263
271
  if (!context.to.hash) context.glide = glideToTop();
264
272
  await context.glide?.promise;
265
- context.releaseOutgoing = await outgoing;
273
+ context.releaseOutgoing = once(await outgoing);
266
274
  if (context.controller.signal.aborted || visit !== swup.visit) {
267
275
  context.releaseOutgoing();
268
276
  return;
@@ -276,8 +284,10 @@ export function installSwupRouter(): Swup {
276
284
  context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
277
285
  if (context.controller.signal.aborted) return;
278
286
  await replace(visit, args);
279
- // The old region is detached; drop any fill effect left on it.
280
- context.releaseOutgoing?.();
287
+ // The stage node survives the swap, so the filled exit effect still holds
288
+ // the incoming document at zero opacity. It is released inside
289
+ // motion.start, in the same synchronous block as the entry cue: releasing
290
+ // it here would paint the new document fully visible for one frame.
281
291
  if (context.controller.signal.aborted) return;
282
292
  for (const element of context.obsoleteStyles) element.remove();
283
293
  const incomingStyles = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
@@ -340,6 +350,7 @@ export function installSwupRouter(): Swup {
340
350
  content: true,
341
351
  skip: !visit.animation.animate || context.controller.signal.aborted,
342
352
  browserMotion: context.browserMotion,
353
+ releaseOutgoing: context.releaseOutgoing,
343
354
  });
344
355
  }
345
356
 
@@ -349,7 +360,8 @@ export function installSwupRouter(): Swup {
349
360
  await context.scriptsReady;
350
361
  if (context.controller.signal.aborted) return;
351
362
  dispatchNavigation("astro:page-load", context);
352
- if (!visit.animation.animate) context.motion?.start(null, { skip: true });
363
+ if (!visit.animation.animate)
364
+ context.motion?.start(null, { skip: true, releaseOutgoing: context.releaseOutgoing });
353
365
  });
354
366
  swup.hooks.replace("link:self", () => undefined);
355
367
 
@@ -357,6 +369,9 @@ export function installSwupRouter(): Swup {
357
369
  const context = contexts.get(visit);
358
370
  context?.controller.abort();
359
371
  context?.glide?.cancel();
372
+ // An aborted visit keeps no exit fill: without this the stage would stay
373
+ // transparent until the next successful navigation.
374
+ context?.releaseOutgoing?.();
360
375
  context?.motion?.cancel();
361
376
  if (context === activeContext) {
362
377
  clearMotionState();
@@ -369,6 +384,7 @@ export function installSwupRouter(): Swup {
369
384
  swup.hooks.on("visit:end", (visit) => {
370
385
  const context = contexts.get(visit);
371
386
  context?.glide?.cancel();
387
+ context?.releaseOutgoing?.();
372
388
  context?.motion?.start(null, { skip: true });
373
389
  if (activeContext === context) {
374
390
  clearMotionState();
@@ -1,3 +1,3 @@
1
1
  @layer theme, base, components, utilities;
2
- @layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;max-width:900px;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border-left:2px solid var(--accent-50p);background:linear-gradient(135deg, rgb(var(--stalux-accent-rgb) / .06), transparent 55%), #121418eb;overflow-wrap:anywhere;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
2
+ @layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;width:100%;max-width:900px;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;min-width:0;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border-left:2px solid var(--accent-50p);background:rgb(var(--stalux-accent-rgb) / .045);backdrop-filter:blur(3px);overflow-wrap:anywhere;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
3
3
  /*$vite$:1*/