website-xp-phone 2.2.1 → 2.2.12

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 (70) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/Makefile +5 -1
  3. package/README.md +15 -0
  4. package/build/routes.ts +27 -10
  5. package/package.json +5 -2
  6. package/public/Click.ogg +0 -0
  7. package/public/addons/rage-volley.txt +1 -1
  8. package/public/addons/rueg-wa-druid.txt +1 -1
  9. package/public/addons/rueg-wa-mage.txt +1 -1
  10. package/public/addons/rueg-wa-paladin.txt +1 -0
  11. package/public/addons/rueg-wa-priest.txt +1 -1
  12. package/public/addons/rueg-wa-rueg.txt +1 -1
  13. package/public/addons/rueg-wa-shaman.txt +1 -1
  14. package/public/blog/random/branch.md +29 -0
  15. package/public/blog/random/google.md +25 -0
  16. package/public/checksums.txt +70 -48
  17. package/public/documents/drift-report.json +48 -148
  18. package/public/loading.css +7 -0
  19. package/scripts/asset-reference.mjs +37 -0
  20. package/scripts/mutable-assets.mjs +53 -39
  21. package/scripts/refresh-soundcloud.mjs +152 -414
  22. package/scripts/verify-policy.mjs +206 -0
  23. package/scripts/write-dist-checksums.mjs +251 -135
  24. package/src/content/addons.json +13 -17
  25. package/src/content/blog.json +5 -26
  26. package/src/content/characters.json +6 -6
  27. package/src/content/configs.json +6 -2
  28. package/src/content/index.ts +105 -12
  29. package/src/content/pages.json +10 -1
  30. package/src/content/projects/development.json +9 -1
  31. package/src/content/projects/doftiles.json +9 -1
  32. package/src/content/projects/frontend.json +9 -1
  33. package/src/content/projects/graphbrowser.json +9 -1
  34. package/src/content/projects/herbstutil.json +93 -4
  35. package/src/content/projects/lightdni-jssas-toggle.json +9 -1
  36. package/src/content/projects/pagerts.json +13 -2
  37. package/src/content/projects/rarebert.json +12 -2
  38. package/src/content/projects/webuser.json +9 -1
  39. package/src/content/schema.ts +10 -1
  40. package/src/content/sections.json +2 -3
  41. package/src/content/soundcloud.json +204 -72
  42. package/src/lib/audio.ts +14 -0
  43. package/src/lib/clippy.ts +1 -7
  44. package/src/lib/embeds.ts +1 -1
  45. package/src/lib/musicSchema.ts +1 -0
  46. package/src/main.tsx +8 -0
  47. package/src/pages/MusicPage.tsx +0 -1
  48. package/src/pages/SitemapPage.tsx +1 -1
  49. package/src/pages/WowPage.tsx +1 -1
  50. package/src/shell/App.tsx +4 -1
  51. package/src/styles/extras.css +85 -30
  52. package/src/styles/layout.css +27 -18
  53. package/src/styles/window.css +53 -50
  54. package/src/themes/pageSlide/index.ts +115 -13
  55. package/src/themes/pageSlide/pageSlide.css +99 -6
  56. package/src/window/Embed.tsx +3 -1
  57. package/src/window/MaximizedWindow.tsx +250 -0
  58. package/src/window/OkButton.tsx +6 -1
  59. package/src/window/PageWindows.tsx +3 -9
  60. package/src/window/Window.tsx +129 -53
  61. package/src/window/model.ts +22 -0
  62. package/src/window/state.tsx +54 -30
  63. package/test/asset-reference.test.js +53 -0
  64. package/test/content.test.js +60 -6
  65. package/test/mutable-assets.test.js +34 -0
  66. package/test/verify-policy.test.js +92 -0
  67. package/tsconfig.node.tsbuildinfo +1 -1
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/xp.css/README.md +10 -0
  70. package/public/addons/sidetip-0.1.19.zip +0 -0
@@ -3,12 +3,20 @@
3
3
  justify-content: space-between;
4
4
  align-items: center;
5
5
  flex-wrap: wrap;
6
+ /* The OK button wraps below the content (margin-left: auto line) but the
7
+ row is full-width (the display:contents maximize-shell leaves the
8
+ window as a direct flex item of .page), so wrapped flex lines would
9
+ stack flush against the last window above. Row-gap = the window-body
10
+ padding gives the buttons the same vertical separation as windows. */
11
+ row-gap: 8px;
6
12
  }
7
13
 
8
14
  .ok-row > button {
9
15
  margin-left: auto;
10
16
  }
11
17
 
18
+ /* Buttons get the window padding above them via .ok-row's row-gap
19
+ (above); no page-specific spacing needed. */
12
20
  .ok-row-bottom {
13
21
  align-items: flex-end;
14
22
  }
@@ -18,15 +26,65 @@
18
26
  inset: 0;
19
27
  z-index: 2000;
20
28
  background: rgba(0, 0, 0, 0.6);
21
- /* The dimmed layer itself is the scroll surface: a maximized window
22
- taller than the viewport scrolls as a whole (title bar and all)
23
- instead of scrolling its interior. */
29
+ /* The dimmed shell is the scroll surface: a maximized window taller
30
+ than the viewport scrolls as a whole (title bar and all), keeping
31
+ one consistent piece of geometry for content, banners and rail. */
24
32
  overflow-y: auto;
25
33
  display: flex;
26
- /* The window docks top-left; margin: auto on it centers it whenever it
27
- fits, and lets it pin to the top edge when it overflows. */
28
- align-items: flex-start;
29
- justify-content: flex-start;
34
+ /* SAFE centering: a fitting window centers in the scrollport; an
35
+ overflowing one docks to the top edge instead (auto margins misbehave
36
+ on an overflowing scroll surface — bottom-anchoring the window). */
37
+ align-items: safe center;
38
+ justify-content: safe center;
39
+ }
40
+
41
+ /* Every window is wrapped by the shell: display: contents keeps the
42
+ window's own flow box while inline; the SAME wrapper becomes the
43
+ fixed darkened backdrop when the window lifts — the window element
44
+ stays inside the background element in every state. */
45
+ .maximized-shell {
46
+ display: contents;
47
+ }
48
+
49
+ .maximized-backdrop:not(.page-slide-backdrop-ride):not(
50
+ .page-slide-backdrop-close
51
+ ) {
52
+ /* The darkening fades rather than blinking in when the window lifts
53
+ over the page (its removal always rides the fade-out clone,
54
+ whose own mount animation must not be overridden by this one). */
55
+ animation: page-slide-backdrop-in 180ms ease-out;
56
+ }
57
+
58
+ @keyframes page-slide-backdrop-in {
59
+ from {
60
+ opacity: 0;
61
+ background: rgba(0, 0, 0, 0);
62
+ }
63
+ }
64
+
65
+ /* The maximized window flows INSIDE its shell (the backdrop): hugging
66
+ content — no viewport clamp, no auto margins (they misalign on the
67
+ overflowing scroll surface). Alignment is the shell's safe centering.
68
+ Taller content extends the window; the dimmed shell scrolls it as one
69
+ piece (banner, posts and rail keep their DOM order and geometry). */
70
+ .window.maximized {
71
+ width: max-content;
72
+ max-width: 96vw;
73
+ min-width: min(800px, 96vw);
74
+ height: fit-content;
75
+ min-height: 0;
76
+ max-height: none;
77
+ box-sizing: border-box;
78
+ display: flex;
79
+ flex-direction: column;
80
+ }
81
+
82
+ .window-minimized-body {
83
+ padding: 0.5em;
84
+ }
85
+
86
+ .window-minimized-body .ok-row {
87
+ margin: 0;
30
88
  }
31
89
 
32
90
  .artist-background {
@@ -50,6 +108,17 @@
50
108
  background: #fff;
51
109
  border-top: 1px solid #808080;
52
110
  color: #2f2f2f;
111
+ /* The bar rides out with its page (a page-slide-bar clone drops it);
112
+ each arrival — the music page remounts it on every navigation —
113
+ slides it back in from the bottom edge. */
114
+ animation: debug-bar-enter 240ms ease-out backwards;
115
+ }
116
+
117
+ @keyframes debug-bar-enter {
118
+ from {
119
+ opacity: 0;
120
+ transform: translateY(100%);
121
+ }
53
122
  }
54
123
 
55
124
  .debug-bar strong {
@@ -91,34 +160,13 @@
91
160
  margin-left: 0;
92
161
  }
93
162
 
94
- .maximized-backdrop > .window.maximized {
95
- /* Desktop maximize = a foreground modal, not a fullscreen takeover:
96
- centered (margin: auto), hugging its content — shrink-wrapped width
97
- with an 800px floor (narrow content must not become a sliver) and a
98
- 96vw cap. Content taller than the viewport extends past the backdrop
99
- edge and the dimmed layer scrolls the window as one piece — no inner
100
- scrolling. */
101
- margin: auto;
102
- width: max-content;
103
- max-width: 96vw;
104
- min-width: min(800px, 96vw);
105
- height: auto;
106
- /* Override the base .window.maximized min sizes, or the window stays at
107
- full viewport size. */
108
- min-height: 0;
109
- box-sizing: border-box;
110
- max-height: none;
111
- display: flex;
112
- flex-direction: column;
113
- }
114
-
115
163
  /* Leaf windows (no nested section nodes) size purely to their inner content
116
164
  on maximize — no 800px floor below. */
117
- .maximized-backdrop > .window.maximized.content-size {
165
+ .window.maximized.content-size {
118
166
  min-width: 0;
119
167
  }
120
168
 
121
- .maximized-backdrop > .window.maximized > .window-body {
169
+ .window.maximized > .window-body {
122
170
  /* Drop the 8px XP window-body margin so the scrollbar hugs the window
123
171
  frame; the inset moves to padding, which scrollbars do not overlap. */
124
172
  margin: 0;
@@ -131,6 +179,13 @@
131
179
  body:has(.maximized-backdrop) {
132
180
  overflow: hidden;
133
181
  }
182
+ /* The maximized window is an in-tree fixed child of .page; the z-index
183
+ raise lets the dimmed shell cover the menu bar like the old body-level
184
+ portal did. (.page itself carries no perspective/transform-style — see
185
+ window.css — so fixed descendants stay viewport-anchored.) */
186
+ body:has(.maximized-backdrop) .page {
187
+ z-index: 3000;
188
+ }
134
189
  body:has(.maximized-backdrop) .debug-bar {
135
190
  display: none;
136
191
  }
@@ -119,35 +119,43 @@ body:has([data-debug-bar]) .page {
119
119
  with equal specificity. dvh (with vh fallback) tracks the *visible*
120
120
  viewport, so the Android Firefox/Chrome navigation bar cannot hide the
121
121
  window's top edge underneath it. */
122
- body .maximized-backdrop > .window.maximized {
122
+ body .window.maximized {
123
123
  margin: 0;
124
124
  width: 100vw;
125
- height: 100vh;
126
- height: 100dvh;
125
+ max-width: 100vw;
126
+ /* Content-hugging like desktop, NO viewport-height clamp: taller
127
+ content extends the window and the dimmed shell scrolls it as one
128
+ piece (the artist background survives the scroll, the rail lands
129
+ at the true geometry end). */
127
130
  min-width: 0;
128
131
  min-height: 0;
132
+ scrollbar-width: none;
133
+ }
134
+ body .window.maximized::-webkit-scrollbar,
135
+ body .window.maximized > .window-body::-webkit-scrollbar,
136
+ body .window.maximized > .maximized-window-body::-webkit-scrollbar {
137
+ display: none;
138
+ }
139
+ body .window.maximized > .window-body,
140
+ body .window.maximized > .maximized-window-body {
141
+ scrollbar-width: none;
129
142
  }
130
143
 
131
- body
132
- .maximized-backdrop
133
- > .window.maximized
134
- > .window-body,
135
- body
136
- .maximized-backdrop
137
- > .window.maximized
138
- > .maximized-window-body {
144
+ body .window.maximized > .window-body,
145
+ body .window.maximized > .maximized-window-body {
139
146
  margin: 0;
140
147
  padding: 0;
148
+ scrollbar-width: none;
141
149
  }
142
150
 
143
151
  /* Leaf windows (no nested sections) keep their natural size on maximize
144
- instead of ballooning to the viewport; margin: auto centers them in
145
- the dimmed layer. */
146
- body .maximized-backdrop > .window.maximized.content-size {
147
- margin: auto;
152
+ instead of ballooning to the viewport; auto margins center them
153
+ horizontally, docked to the top edge. */
154
+ body .window.maximized.content-size {
155
+ margin: 0 auto;
148
156
  width: min(100vw, 1100px);
149
- height: auto;
150
- max-height: 100dvh;
157
+ max-width: 100vw;
158
+ min-height: 0;
151
159
  }
152
160
 
153
161
  /* Give the unhide button a visible chinbar on mobile so it isn't
@@ -180,7 +188,8 @@ body:has([data-debug-bar]) .page {
180
188
  :root {
181
189
  --menu-bar-height: 24px;
182
190
  }
183
- .page > .window {
191
+ .page > .window,
192
+ .page > .maximized-shell > .window {
184
193
  box-sizing: border-box;
185
194
  margin: 0 auto;
186
195
  /* width: fit-content; */
@@ -101,61 +101,52 @@
101
101
  }
102
102
  }
103
103
 
104
- .window[data-theme~="experimental"] {
105
- /* The 3D perspective is provided by the .page ancestor so the raise/wobble
106
- isn't clipped by this element's own box; here we just preserve the 3D
107
- context and animate the transform. */
108
- transform-style: preserve-3d;
109
- transition:
110
- transform 120ms ease-out,
111
- box-shadow 120ms ease-out;
112
- will-change: transform;
113
- }
104
+ /* Nothing at the window level: experimental sections keep their plain
105
+ sizing/backgrounds — wobbling the frame itself used to clobber the
106
+ lifted window's background and position. */
114
107
 
115
- /* #feef69 box-shadow glow only while maximized: the maximized window is a
116
- remounted element in the backdrop, so the flash plays exactly one pulse
117
- on entry into the fullscreen view (highlighting what is being focused). */
118
- .window.maximized[data-theme~="experimental"] {
119
- animation: theme-experimental-flash 850ms ease-in-out;
108
+ /* The one-pulse entry glow lives on the TITLE TEXT instead: maximized
109
+ experimental windows flash their heading #FEEF69 exactly once on
110
+ entry into the fullscreen view (highlighting what is being focused). */
111
+ .window.maximized[data-theme~="experimental"] .title-bar-text {
112
+ animation: title-flash-feef69 850ms ease-in-out;
120
113
  }
121
114
 
122
- @keyframes theme-experimental-flash {
115
+ @keyframes title-flash-feef69 {
123
116
  0%,
124
117
  100% {
125
- box-shadow: 0 0 0 0 rgba(254, 239, 105, 0);
118
+ color: inherit;
126
119
  }
127
120
  40%,
128
121
  75% {
129
- box-shadow:
130
- 0 0 0 4px #feef69,
131
- 0 0 1.2em #feef69;
132
- }
133
- }
134
-
135
- /* Respect reduced-motion: no fullscreen glow or wobble. */
136
- @media (prefers-reduced-motion: reduce) {
137
- .window.maximized[data-theme~="experimental"] {
138
- animation: none;
139
- }
140
- }
141
-
142
- /* The hover/focus wobble stays a page-level affair: maximized windows must
143
- not retrigger anything on mouse movement (hovering would override the
144
- one-time glow, and un-hovering would replay it). */
145
- @media (hover: hover) {
146
- .window[data-theme~="experimental"]:not(.maximized):hover {
147
- /* Bob up and down (translateY) in the page's 3D space. */
148
- animation: theme-experimental-wobble 0.6s ease-in-out;
122
+ color: #feef69;
149
123
  }
150
124
  }
151
125
 
126
+ /* The WOBBLE is back on the window itself: an experimental section bobs
127
+ gently on hover/focus in the page (a11y + character); the window root
128
+ stays untouched when lifted (nothing retriggers in the fullscreen
129
+ view) — see the descendant suppression right below. */
130
+ .window[data-theme~="experimental"]:not(.maximized):hover,
152
131
  .window[data-theme~="experimental"]:not(.maximized):focus-within {
153
- /* Keyboard focus also triggers the wobble (a11y). */
154
132
  animation: theme-experimental-wobble 0.6s ease-in-out;
155
133
  }
156
134
 
157
- /* Respect reduced-motion: skip the wobble animation, keep the raise. */
135
+ /* The :not(.maximized) guard above only covers the lifted ROOT window.
136
+ Maximizing force-expands the whole subtree (MaximizedWindow patches
137
+ every descendant expanded), so inline experimental child windows
138
+ inside the fullscreen view still match hover/focus and bob with a
139
+ translateY animation inside the fixed overlay's overflow-y scroll
140
+ surface — breaking its scroll positioning. Suppress them: the page
141
+ wobble is a page-context effect only. */
142
+ .window.maximized .window[data-theme~="experimental"]:hover,
143
+ .window.maximized .window[data-theme~="experimental"]:focus-within {
144
+ animation: none;
145
+ }
146
+
147
+ /* Respect reduced-motion: no glow, no wobble. */
158
148
  @media (prefers-reduced-motion: reduce) {
149
+ .window.maximized[data-theme~="experimental"] .title-bar-text,
159
150
  .window[data-theme~="experimental"]:not(.maximized):hover,
160
151
  .window[data-theme~="experimental"]:not(.maximized):focus-within {
161
152
  animation: none;
@@ -224,6 +215,7 @@
224
215
 
225
216
  /* Hide border on first-level windows to avoid double border */
226
217
  .page > .window,
218
+ .page > .maximized-shell > .window,
227
219
  main > .page {
228
220
  border: none;
229
221
  }
@@ -232,7 +224,8 @@ main > .page {
232
224
  inner content (e.g. a Spotify iframe) can't push itself past the rest
233
225
  of the page. Defaults to 768px to match the website's compact reading
234
226
  width; only grows when the viewport is narrower than that. */
235
- .page > .window {
227
+ .page > .window,
228
+ .page > .maximized-shell > .window {
236
229
  width: min(768px, 100% - (var(--gap-size) * 2));
237
230
  max-width: min(768px, 100% - (var(--gap-size) * 2));
238
231
  }
@@ -299,12 +292,10 @@ li {
299
292
  flex-direction: column;
300
293
  place-items: center;
301
294
  min-height: 100%;
302
- /* Establish the 3D viewing space for themed windows so their raise/wobble
303
- transforms (translateZ, scale) render relative to the page rather than
304
- being clipped by the page's own box. */
305
- perspective: 800px;
306
- /* Let a raised window overflow the page bounds without clipping. */
307
- transform-style: preserve-3d;
295
+ /* NO perspective / transform-style here: the themed raise/wobble
296
+ transforms are plain 2D translateY, and any 3D viewing space would
297
+ turn .page into the containing block for fixed-position descendants
298
+ (maximized windows ride in-tree over the page). */
308
299
  }
309
300
 
310
301
  .window.maximized {
@@ -392,8 +383,7 @@ form#form > label {
392
383
 
393
384
  /* In a maximized "centered" window the content hugs the bottom left corner
394
385
  and grows upward and rightward as the space allows. */
395
- .maximized-backdrop
396
- > .window.maximized[data-theme~="centered"]
386
+ .window.maximized[data-theme~="centered"]
397
387
  > .maximized-window-body
398
388
  > ul {
399
389
  display: flex;
@@ -401,8 +391,7 @@ form#form > label {
401
391
  justify-content: flex-end;
402
392
  }
403
393
 
404
- .maximized-backdrop
405
- > .window.maximized[data-theme~="centered"]
394
+ .window.maximized[data-theme~="centered"]
406
395
  > .maximized-window-body
407
396
  > ul
408
397
  > li {
@@ -482,6 +471,20 @@ form#form > label {
482
471
  padding: 0.5em;
483
472
  }
484
473
 
474
+ /* Desktop keeps the liked fixed-height card; mobile squares the player
475
+ at the player's own footprint size, so square can never balloon tall
476
+ (spotify favourite-link players included via .embed). */
477
+ @media (max-width: 768px) {
478
+ .embed,
479
+ [data-testid="embed-iframe"] {
480
+ display: block;
481
+ width: min(100%, 250px);
482
+ aspect-ratio: 1 / 1;
483
+ height: auto;
484
+ margin: 0 auto;
485
+ }
486
+ }
487
+
485
488
  .music-favourite-link-window ul {
486
489
  min-width: 100%;
487
490
  }
@@ -4,6 +4,7 @@ import {
4
4
  setTheme,
5
5
  type ThemeDefinition,
6
6
  } from "lightdni-jssas-toggle";
7
+ import { playMinimizeClick } from "@/lib/audio";
7
8
  import { resolveDirection, type Direction } from "./direction";
8
9
  import "./pageSlide.css";
9
10
 
@@ -39,14 +40,19 @@ const slide = (name: string, inX: string, inY: string): ThemeDefinition => ({
39
40
  },
40
41
  });
41
42
 
42
- const themes: Record<Direction, ThemeDefinition> = {
43
+ const themes: Record<Direction | "parked", ThemeDefinition> = {
43
44
  right: slide("right", "100vw", "0"),
44
45
  left: slide("left", "-100vw", "0"),
45
46
  down: slide("down", "0", "-100vh"),
47
+ /** Initial load ONTO a hyperlinked window target: the slide theme is
48
+ * fully installed (its class drives minimize folds and the navigation
49
+ * hooks) but with a zero-distance entrance — nothing visibly moves
50
+ * while the maximized overlay is the page's first paint. */
51
+ parked: slide("parked", "0", "0"),
46
52
  };
47
53
 
48
54
  const root = () => document.documentElement;
49
- const applyDirection = (themeName: Direction) =>
55
+ const applyDirection = (themeName: Direction | "parked") =>
50
56
  setTheme({
51
57
  themeName,
52
58
  themes,
@@ -95,22 +101,39 @@ function leaveGhost(): void {
95
101
  ghost.querySelectorAll("iframe, audio, video").forEach((el, i) => {
96
102
  const box = mediaBoxes[i];
97
103
  if (!box) return;
104
+ const live = liveMedias[i];
105
+ const isIframe = live?.tagName === "IFRAME";
98
106
  const placeholder = document.createElement("div");
99
107
  // Media can't be cloned into the static ghost; placeholders only carry
100
108
  // the layout box, with the document-paper look for iframes.
101
- placeholder.className =
102
- liveMedias[i]?.tagName === "IFRAME"
103
- ? "page-slide-placeholder page-slide-placeholder-frame"
104
- : "page-slide-placeholder";
109
+ placeholder.className = isIframe
110
+ ? "page-slide-placeholder page-slide-placeholder-frame"
111
+ : "page-slide-placeholder";
105
112
  placeholder.style.cssText = `width:${box.width}px;height:${box.height}px`;
113
+ if (isIframe && live) {
114
+ // Document frames keep their own paper background while the page
115
+ // drops: the resume preview is styled white, and falling back to the
116
+ // themed (dark) background reads as the PDF unloading mid-exit.
117
+ // Transparent iframes (music embeds) keep the themed placeholder
118
+ // background — a white flash there is visual pollution.
119
+ const background = getComputedStyle(live).backgroundColor;
120
+ if (
121
+ background &&
122
+ background !== "transparent" &&
123
+ background !== "rgba(0, 0, 0, 0)"
124
+ ) {
125
+ placeholder.style.backgroundColor = background;
126
+ }
127
+ }
106
128
  el.replaceWith(placeholder);
107
129
  });
108
130
  // XP-style minimise: each top-level window folds down onto the taskbar
109
131
  // line — the bottom edge of the page — sucked toward it while shrinking.
110
132
  // The fold distance is measured per window; CSS does the squash.
111
133
  const taskbarLine = window.innerHeight;
112
- ghost.querySelectorAll<HTMLElement>(".page > .window").forEach((win, i) => {
113
- const liveWin = main.querySelectorAll(".page > .window")[i];
134
+ const topLevel = ".page > .window, .page > .maximized-shell > .window";
135
+ ghost.querySelectorAll<HTMLElement>(topLevel).forEach((win, i) => {
136
+ const liveWin = main.querySelectorAll(topLevel)[i];
114
137
  if (!liveWin) return;
115
138
  const dy = Math.max(
116
139
  0,
@@ -119,13 +142,28 @@ function leaveGhost(): void {
119
142
  win.style.setProperty("--page-fold-dy", `${Math.round(dy)}px`);
120
143
  });
121
144
  document.body.appendChild(ghost);
145
+ // Maximized overlays ride INSIDE the ghost now (they are in-tree), so
146
+ // the dimmed layer and the maximized window fold and drop with the
147
+ // page — no portal ride-out is needed on navigation.
122
148
  document.querySelectorAll<HTMLElement>("body > .debug-bar").forEach((bar) => {
149
+ // A bar hidden by the maximized overlay rides nothing: cloning it
150
+ // only paints a flash after the unlock (the folded ghost unparks
151
+ // body:has while the ride is still alive).
152
+ if (getComputedStyle(bar).display === "none") return;
123
153
  const leaving = bar.cloneNode(true) as HTMLElement;
124
154
  leaving.classList.add("page-slide-bar");
125
155
  // Layout rules key off this attribute; the clone must not keep them alive.
126
156
  leaving.removeAttribute("data-debug-bar");
127
157
  document.body.appendChild(leaving);
128
- window.setTimeout(() => leaving.remove(), REMOVAL_MS);
158
+ // The live bar yields to the clone in the same frame — visibility,
159
+ // not display, so the body padding rules keep reserving its row —
160
+ // and the clone drops (and fades) with the page instead of the bar
161
+ // blinking out at the route commit.
162
+ bar.classList.add("page-slide-bar-hide");
163
+ window.setTimeout(() => {
164
+ leaving.remove();
165
+ bar.classList.remove("page-slide-bar-hide");
166
+ }, REMOVAL_MS);
129
167
  });
130
168
  const remove = (event?: Event) => {
131
169
  // Child windows bubbling their minimise-end must NOT retire the ghost —
@@ -146,14 +184,23 @@ function leaveGhost(): void {
146
184
 
147
185
  let current = "";
148
186
  let originalPushState: History["pushState"] | null = null;
187
+ /** Mutes the next transition's fold click: a window ✕ that navigates
188
+ * closes with its own crunchy kick — no Click.ogg on top of it. */
189
+ let clickSuppressed = false;
190
+
191
+ export function suppressNextSlideClick(): void {
192
+ clickSuppressed = true;
193
+ }
149
194
 
150
195
  /** On mobile screens the appearing page always slides straight down
151
196
  * (from above) — the side-by-side menu-order metaphor doesn't read on
152
197
  * a narrow, wrapped menu. Matches the layout.css 768px breakpoint. */
153
198
  const isMobileLayout = () => window.matchMedia("(max-width: 768px)").matches;
154
199
 
155
- function transition(to: string): void {
200
+ function transition(to: string, hash: string): void {
156
201
  if (to === current) return;
202
+ if (clickSuppressed) clickSuppressed = false;
203
+ else playMinimizeClick();
157
204
  const direction = isMobileLayout()
158
205
  ? "down"
159
206
  : resolveDirection(current, to, menuOrder());
@@ -161,6 +208,9 @@ function transition(to: string): void {
161
208
  applyDirection(direction);
162
209
  restartArrival();
163
210
  current = to;
211
+ // A new page starts at the top — unless the url carries a hash, whose
212
+ // element the navigation scrolls itself to (scrollToHash).
213
+ if (!hash) window.scrollTo({ top: 0 });
164
214
  }
165
215
 
166
216
  /** Restart the just-applied destination slide so it runs in step with the
@@ -179,7 +229,39 @@ function restartArrival(): void {
179
229
  }
180
230
  }
181
231
 
182
- const onPopState = () => transition(location.pathname);
232
+ const onPopState = () => {
233
+ if (location.pathname === current) {
234
+ // Same-page history traversal only moves the hash: bring the
235
+ // hyperlinked element into view (inside its scrolling window).
236
+ if (location.hash) scrollToHash(location.hash);
237
+ return;
238
+ }
239
+ transition(location.pathname, location.hash);
240
+ };
241
+
242
+ /** Scrolls the hash element into view — inside its scrolling window
243
+ * (a maximized window scrolls its own revealed content) or the page.
244
+ * The element can mount after the route commit (lazy pages), hence the
245
+ * deferred retries. */
246
+ function scrollToHash(hash: string, attempts = 12): void {
247
+ const id = decodeURIComponent(hash.slice(1));
248
+ if (!id) return;
249
+ const tick = () => {
250
+ const el = document.getElementById(id);
251
+ if (el) {
252
+ // Maximized windows are viewport-fixed overlays; scrolling the page
253
+ // behind them just resets the background while the overlay hides
254
+ // it. The restored window scrolls itself to the hash element on
255
+ // dismissal instead.
256
+ if (!el.closest(".maximized-backdrop")) {
257
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
258
+ }
259
+ return;
260
+ }
261
+ if (attempts > 0) window.setTimeout(tick, 100);
262
+ };
263
+ tick();
264
+ }
183
265
 
184
266
  /**
185
267
  * Adds the sliding page transition as a lightdni-jssas-toggle theme: the
@@ -190,14 +272,34 @@ const onPopState = () => transition(location.pathname);
190
272
  export function enablePageSlide(): void {
191
273
  if (originalPushState || typeof document === "undefined") return;
192
274
  current = location.pathname;
193
- applyDirection("down");
275
+ // Initial load ONTO a hyperlinked window target (#hash or a blog
276
+ // ?post= the route maximizes): install the theme "parked" so the
277
+ // entrance slide plays from nowhere — the maximized overlay is the
278
+ // only visible first paint, and minimize folds / navigation hooks
279
+ // stay functional. Plain loads slide in from the top as before.
280
+ const linkedWindowTarget =
281
+ !!location.hash || new URLSearchParams(location.search).has("post");
282
+ applyDirection(linkedWindowTarget ? "parked" : "down");
194
283
 
195
284
  const original = history.pushState.bind(history);
196
285
  originalPushState = history.pushState;
197
286
  history.pushState = (data, unused, url) => {
198
- if (url != null) transition(new URL(url, location.href).pathname);
287
+ if (url != null) {
288
+ const target = new URL(url, location.href);
289
+ if (target.pathname !== current) {
290
+ transition(target.pathname, target.hash);
291
+ if (target.hash) scrollToHash(target.hash);
292
+ } else if (target.hash && target.hash !== location.hash) {
293
+ // Same pathname, new hash: no page transition — scroll instead.
294
+ scrollToHash(target.hash);
295
+ }
296
+ }
199
297
  original(data, unused, url);
200
298
  };
299
+ // Same-page hyperlinks to hash elements change the hash natively.
300
+ window.addEventListener("hashchange", (event) =>
301
+ scrollToHash(new URL(event.newURL).hash),
302
+ );
201
303
  window.addEventListener("popstate", onPopState);
202
304
  }
203
305