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.
- package/.github/workflows/ci.yml +8 -0
- package/Makefile +5 -1
- package/README.md +15 -0
- package/build/routes.ts +27 -10
- package/package.json +5 -2
- package/public/Click.ogg +0 -0
- package/public/addons/rage-volley.txt +1 -1
- package/public/addons/rueg-wa-druid.txt +1 -1
- package/public/addons/rueg-wa-mage.txt +1 -1
- package/public/addons/rueg-wa-paladin.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -1
- package/public/addons/rueg-wa-rueg.txt +1 -1
- package/public/addons/rueg-wa-shaman.txt +1 -1
- package/public/blog/random/branch.md +29 -0
- package/public/blog/random/google.md +25 -0
- package/public/checksums.txt +70 -48
- package/public/documents/drift-report.json +48 -148
- package/public/loading.css +7 -0
- package/scripts/asset-reference.mjs +37 -0
- package/scripts/mutable-assets.mjs +53 -39
- package/scripts/refresh-soundcloud.mjs +152 -414
- package/scripts/verify-policy.mjs +206 -0
- package/scripts/write-dist-checksums.mjs +251 -135
- package/src/content/addons.json +13 -17
- package/src/content/blog.json +5 -26
- package/src/content/characters.json +6 -6
- package/src/content/configs.json +6 -2
- package/src/content/index.ts +105 -12
- package/src/content/pages.json +10 -1
- package/src/content/projects/development.json +9 -1
- package/src/content/projects/doftiles.json +9 -1
- package/src/content/projects/frontend.json +9 -1
- package/src/content/projects/graphbrowser.json +9 -1
- package/src/content/projects/herbstutil.json +93 -4
- package/src/content/projects/lightdni-jssas-toggle.json +9 -1
- package/src/content/projects/pagerts.json +13 -2
- package/src/content/projects/rarebert.json +12 -2
- package/src/content/projects/webuser.json +9 -1
- package/src/content/schema.ts +10 -1
- package/src/content/sections.json +2 -3
- package/src/content/soundcloud.json +204 -72
- package/src/lib/audio.ts +14 -0
- package/src/lib/clippy.ts +1 -7
- package/src/lib/embeds.ts +1 -1
- package/src/lib/musicSchema.ts +1 -0
- package/src/main.tsx +8 -0
- package/src/pages/MusicPage.tsx +0 -1
- package/src/pages/SitemapPage.tsx +1 -1
- package/src/pages/WowPage.tsx +1 -1
- package/src/shell/App.tsx +4 -1
- package/src/styles/extras.css +85 -30
- package/src/styles/layout.css +27 -18
- package/src/styles/window.css +53 -50
- package/src/themes/pageSlide/index.ts +115 -13
- package/src/themes/pageSlide/pageSlide.css +99 -6
- package/src/window/Embed.tsx +3 -1
- package/src/window/MaximizedWindow.tsx +250 -0
- package/src/window/OkButton.tsx +6 -1
- package/src/window/PageWindows.tsx +3 -9
- package/src/window/Window.tsx +129 -53
- package/src/window/model.ts +22 -0
- package/src/window/state.tsx +54 -30
- package/test/asset-reference.test.js +53 -0
- package/test/content.test.js +60 -6
- package/test/mutable-assets.test.js +34 -0
- package/test/verify-policy.test.js +92 -0
- package/tsconfig.node.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/xp.css/README.md +10 -0
- package/public/addons/sidetip-0.1.19.zip +0 -0
package/src/styles/extras.css
CHANGED
|
@@ -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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
/*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
.
|
|
165
|
+
.window.maximized.content-size {
|
|
118
166
|
min-width: 0;
|
|
119
167
|
}
|
|
120
168
|
|
|
121
|
-
.
|
|
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
|
}
|
package/src/styles/layout.css
CHANGED
|
@@ -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 .
|
|
122
|
+
body .window.maximized {
|
|
123
123
|
margin: 0;
|
|
124
124
|
width: 100vw;
|
|
125
|
-
|
|
126
|
-
height:
|
|
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
|
-
|
|
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;
|
|
145
|
-
the
|
|
146
|
-
body .
|
|
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
|
-
|
|
150
|
-
|
|
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; */
|
package/src/styles/window.css
CHANGED
|
@@ -101,61 +101,52 @@
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
/*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.window.maximized[data-theme~="experimental"] {
|
|
119
|
-
animation:
|
|
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
|
|
115
|
+
@keyframes title-flash-feef69 {
|
|
123
116
|
0%,
|
|
124
117
|
100% {
|
|
125
|
-
|
|
118
|
+
color: inherit;
|
|
126
119
|
}
|
|
127
120
|
40%,
|
|
128
121
|
75% {
|
|
129
|
-
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
303
|
-
transforms
|
|
304
|
-
|
|
305
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
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)
|
|
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
|
|