website-xp-phone 2.2.1 → 2.2.11

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 (69) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/Makefile +5 -1
  3. package/README.md +9 -0
  4. package/build/routes.ts +27 -10
  5. package/package.json +2 -1
  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 -47
  17. package/public/documents/drift-report.json +46 -148
  18. package/public/loading.css +7 -0
  19. package/scripts/asset-reference.mjs +37 -0
  20. package/scripts/mutable-assets.mjs +46 -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 +27 -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
@@ -35,11 +35,25 @@ html.theme-page-slide .page-slide-ghost > .page {
35
35
  min-height: 0;
36
36
  }
37
37
 
38
- /* Fixed footer bars of the old page drop out of the bottom with it. */
38
+ /* Fixed footer bars of the old page exit on their own beat: the slide
39
+ starts immediately (no fold-delay) and fades as it goes, so the bar
40
+ visibly rides out instead of blinking away at the route commit. */
39
41
  html.theme-page-slide .page-slide-bar {
40
42
  pointer-events: none;
41
- animation: page-slide-drop var(--ps-drop-dur) linear var(--ps-drop-delay)
42
- forwards;
43
+ animation: page-slide-bar-exit 280ms ease-in forwards;
44
+ }
45
+
46
+ @keyframes page-slide-bar-exit {
47
+ to {
48
+ translate: 0 100vh;
49
+ opacity: 0;
50
+ }
51
+ }
52
+
53
+ /* The live bar yields to its ride-out clone for the exit (visibility —
54
+ display would collapse the reserved body padding row). */
55
+ html.theme-page-slide .page-slide-bar-hide {
56
+ visibility: hidden;
43
57
  }
44
58
 
45
59
  /* The new page eases from the old page's bottom padding to its own, so its
@@ -55,16 +69,22 @@ html.theme-page-slide[data-page-slide] main:not(.page-slide-ghost) > .page {
55
69
  edge falls to meet it (the outline sucks downward, as in XP), while the
56
70
  whole window is drawn the last stretch toward the measured fold point.
57
71
  Contents ride the squash, as in XP itself. */
58
- html.theme-page-slide .page-slide-ghost .page > .window {
72
+ html.theme-page-slide .page-slide-ghost .page > .window,
73
+ html.theme-page-slide .page-slide-ghost .page > .maximized-shell > .window {
59
74
  transform-origin: bottom center;
60
75
  /* Linear, per preference: the top edge falls towards the taskbar line
61
76
  at a constant rate for the entire fold. */
62
77
  animation: page-slide-fold var(--ps-minimize, 98ms) linear forwards;
63
78
  }
64
79
 
65
- /* Paper look for the swapped-out document iframes (resume previews etc.). */
80
+ /* Fallback look for the swapped-out iframes (music embeds etc.) — the
81
+ PAGE's background colour: the placeholder is what shows through while
82
+ the page fades/slides, and a white flash reads as visual pollution
83
+ against the themed background. Document frames that carry their own
84
+ opaque background (the white resume preview) adopt it in JS instead —
85
+ a dark placeholder there reads as the PDF unloading mid-exit. */
66
86
  html.theme-page-slide .page-slide-ghost .page-slide-placeholder-frame {
67
- background: #fff;
87
+ background: var(--background-color);
68
88
  }
69
89
 
70
90
  @keyframes page-slide-fold {
@@ -79,6 +99,79 @@ html.theme-page-slide .page-slide-ghost .page-slide-placeholder-frame {
79
99
  }
80
100
  }
81
101
 
102
+ /* Departing dimmed overlays: the dark level fades in step with the
103
+ departing windows (slide-off on dismissal, page fold on navigation);
104
+ ride clones keep the fade going even when the portals unmount
105
+ mid-animation. The ride carries its OWN full backdrop look — it must
106
+ drop the .maximized-backdrop class, or body:has() would keep the page
107
+ raised over the menu bar for the ride's whole lifetime. */
108
+ .maximized-backdrop.page-slide-backdrop-close,
109
+ .page-slide-backdrop-ride {
110
+ position: fixed;
111
+ inset: 0;
112
+ z-index: 4000;
113
+ pointer-events: none;
114
+ background: rgba(0, 0, 0, 0.6);
115
+ /* The ride carries its OWN flex alignment that used to come from
116
+ .maximized-backdrop (dropped from the clone): without it, the
117
+ window copy block-docks left of the page. */
118
+ display: flex;
119
+ align-items: safe center;
120
+ justify-content: safe center;
121
+ animation: page-slide-backdrop-fade 180ms ease-out forwards;
122
+ }
123
+
124
+ @keyframes page-slide-backdrop-fade {
125
+ to {
126
+ opacity: 0;
127
+ background: rgba(0, 0, 0, 0);
128
+ }
129
+ }
130
+
131
+ /* Departing exit on a closing maximized window: MaximizedWindow adds
132
+ .window-minimize to the ride-out clone and measures --page-fold-dy to
133
+ the taskbar line exactly like the ghost clone above, and the squish
134
+ plays the same fold: the bottom edge slides to the taskbar line while
135
+ the top edge falls to meet it. The !important outranks theme
136
+ animations (theme-experimental-flash on maximized experimental windows
137
+ would otherwise replace the fold); the clone paints above the raised
138
+ page, above its fading dimmed layer. Since the ride clone is inert and
139
+ the real window returns to the flow the moment the fold starts, the
140
+ collapsing content never glitches beneath the squash. */
141
+ html.theme-page-slide .window.window-minimize {
142
+ z-index: 4001;
143
+ transform-origin: bottom center;
144
+ animation: window-minimize-exit var(--ps-minimize, 98ms) linear forwards
145
+ !important;
146
+ }
147
+
148
+ @keyframes window-minimize-exit {
149
+ to {
150
+ /* Fold onto the taskbar line, then clear it: without the extra
151
+ 100% offset the squashed sliver would rest exactly AT the bottom
152
+ edge (the page drop that normally carries it away doesn't exist
153
+ for a standalone dismissal). */
154
+ transform: translateY(calc(var(--page-fold-dy, 80vh) + 100%))
155
+ scaleY(0.06);
156
+ }
157
+ }
158
+
159
+ /* Parked (a hyperlink/maximized seed load): play NOTHING. Aiming the
160
+ entrance at zero distance would still emit translate(0,0) — a live
161
+ transform on <main> makes it the containing block for the in-tree
162
+ position:fixed maximized backdrop, which then centers against main's
163
+ box instead of the viewport. No animation = no transform = the
164
+ backdrop stays viewport-anchored. First navigation applies a real
165
+ direction (update of data-page-slide) and restarts the animations. */
166
+ html.theme-page-slide[data-page-slide="parked"] main:not(.page-slide-ghost) {
167
+ animation: none;
168
+ }
169
+
170
+ html.theme-page-slide[data-page-slide="parked"] main:not(.page-slide-ghost)
171
+ > .page {
172
+ animation: none;
173
+ }
174
+
82
175
  @keyframes page-slide-pad {
83
176
  from {
84
177
  padding-bottom: var(--ps-pad-from, 0px);
@@ -34,7 +34,9 @@ export function EmbedView({ embed }: { embed: EmbedItem }) {
34
34
  height={spec.height}
35
35
  allowFullScreen
36
36
  allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
37
- loading="lazy"
37
+ // NO loading="lazy": these frames live inside fixed-size window
38
+ // cards, so lazy loads never trigger on mobile and leave tall
39
+ // blank areas behind.
38
40
  referrerPolicy="strict-origin-when-cross-origin"
39
41
  />
40
42
  );
@@ -0,0 +1,250 @@
1
+ import { useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import type { WindowFlags } from "./state";
3
+ import { descendantIds } from "./model";
4
+ import { permalinkFor, useWindowStore } from "./state";
5
+
6
+ /** Machinery for maximized-foreground windows: the dimmed sibling layer,
7
+ * the reserved flow slot beneath the fixed overlay, the dismissal paths
8
+ * (silent restore / title-bar-minimize slide) and the URL-hash hand-off.
9
+ *
10
+ * The component ALWAYS wraps the window element so React keeps its DOM
11
+ * identity across maximize/dismiss — the window lives in the last child
12
+ * slot in both states and is never remounted. */
13
+
14
+ export type MaximizedController = {
15
+ /** Enters the maximized view: anchors the section hash, reserves the
16
+ * flow slot (measured pre-lift) and lifts the window. Silent. */
17
+ maximize: () => void;
18
+ /** Silent, instant restore: ✕ on a maximized window, Escape and the
19
+ * out-of-bounds backdrop click all restore without sound (sounds
20
+ * stay with the callers) and without the slide. */
21
+ dismiss: () => void;
22
+ /** OK entry: restore AND reveal the window's content on return. */
23
+ dismissRevealed: () => void;
24
+ /** Title-bar minimize only: restores with the slide-off ride. */
25
+ dismissWithSlide: () => void;
26
+ backdropRef: RefObject<HTMLDivElement | null>;
27
+ parkedSize: { width: number; height: number } | null;
28
+ };
29
+
30
+ export function useMaximizedController(
31
+ nodeId: string,
32
+ patch: (id: string, flags: WindowFlags) => void,
33
+ flagsOf: (id: string) => WindowFlags,
34
+ windowRef: RefObject<HTMLDivElement | null>,
35
+ ): MaximizedController {
36
+ const backdropRef = useRef<HTMLDivElement | null>(null);
37
+ const { windows, permalinkMode } = useWindowStore();
38
+ /** The window's own flags at maximize time: dismissal restores them
39
+ * instead of expanding anything ("only scroll to" — and a window that
40
+ * was minimized before maximizing gets minimized back). */
41
+ const preFlags = useRef<WindowFlags | null>(null);
42
+ /** The maximized window keeps its flow slot reserved underneath: the
43
+ * surrounding windows never reflow when it lifts or returns. */
44
+ const [parkedSize, setParkedSize] = useState<{
45
+ width: number;
46
+ height: number;
47
+ } | null>(null);
48
+
49
+ /** Hands the URL to the topmost remaining maximized window's permalink
50
+ * after a dismissal (the page's own address form — ?post=slug on the
51
+ * blog — so a refresh lands back there), or clears it entirely when
52
+ * none remain — pathname WITHOUT the query, so a stale ?post can't
53
+ * resurrect a maximized window after the last one was dismissed. The
54
+ * dismissed window's own backdrop is excluded via backdropRef, and
55
+ * ride-out clones carry no id to ever qualify. */
56
+ const settleHashAfterDismiss = () => {
57
+ const remaining = [
58
+ ...document.querySelectorAll<HTMLElement>(
59
+ ".maximized-backdrop[data-window-id]",
60
+ ),
61
+ ].filter((candidate) => candidate !== backdropRef.current);
62
+ const topId = remaining[remaining.length - 1]?.dataset.windowId;
63
+ const topNode = topId ? windows.find((w) => w.id === topId) : undefined;
64
+ history.replaceState(
65
+ history.state,
66
+ "",
67
+ topNode ? permalinkFor(topNode, permalinkMode) : `${location.pathname}`,
68
+ );
69
+ };
70
+
71
+ const dismiss = (slide: boolean, revealed = false) => {
72
+ const riders: HTMLElement[] = [];
73
+ const shell = backdropRef.current;
74
+ if (shell) {
75
+ // The whole foreground (dimmed layer + the window inside it) leaves
76
+ // with a FADE, not a blink — even on non-slide dismissals. Clone
77
+ // first, then hide the real one at once so the two never stack on
78
+ // screen: two rgba(0,0,0,.6) layers read as complete darkness
79
+ // before the commit removes the real one. The clone mounts INSIDE
80
+ // the page tree (the raised page paints above body-level content
81
+ // while a backdrop exists) — see the z-index ladder in extras.css.
82
+ const riding = shell.cloneNode(true) as HTMLElement;
83
+ riding.classList.add("page-slide-backdrop-ride");
84
+ // Drop the backdrop class: body:has(.maximized-backdrop) raises the
85
+ // whole page over the menu bar, and it must unlock the instant the
86
+ // real backdrop unmounts — the ride's darkening carries its own
87
+ // fixed/full-size look via .page-slide-backdrop-ride instead.
88
+ riding.classList.remove("maximized-backdrop");
89
+ riding.removeAttribute("data-window-id");
90
+ riding.setAttribute("inert", "");
91
+ if (!slide) {
92
+ // Non-slide dismissals restore the real window at once; the ride
93
+ // rides only the darkening, so strip the frozen window copy.
94
+ riding.querySelector(".window")?.remove();
95
+ } else {
96
+ const popped = windowRef.current;
97
+ if (popped) {
98
+ const rect = popped.getBoundingClientRect();
99
+ riding
100
+ .querySelector<HTMLElement>(".window")
101
+ ?.style.setProperty(
102
+ "--page-fold-dy",
103
+ `${Math.max(0, Math.round(window.innerHeight - rect.bottom))}px`,
104
+ );
105
+ riding
106
+ .querySelector<HTMLElement>(".window")
107
+ ?.classList.add("window-minimize");
108
+ }
109
+ }
110
+ const page = shell.closest(".page") ?? document.body;
111
+ page.appendChild(riding);
112
+ riders.push(riding);
113
+ shell.style.opacity = "0";
114
+ }
115
+ settleHashAfterDismiss();
116
+ // The title-bar minimize parks the restored window MINIMIZED (the
117
+ // mascot's un-hide list / summon brings it back). Every other exit
118
+ // restores the window's OWN flags as they stood at maximize time —
119
+ // never maximizing content, only scrolling back to it; hash-navigated
120
+ // windows hold no snapshot and simply persist their inline state.
121
+ if (slide) {
122
+ patch(nodeId, { maximized: false, minimized: true });
123
+ } else {
124
+ patch(nodeId, { maximized: false, ...(preFlags.current ?? {}) });
125
+ if (revealed) {
126
+ const root = windows.find((w) => w.id === nodeId);
127
+ const direct =
128
+ root?.children.flatMap((c) =>
129
+ c.kind === "window" ? [c.node.id] : [],
130
+ ) ?? [];
131
+ for (const id of [nodeId, ...direct]) {
132
+ patch(id, { expanded: true, minimized: false });
133
+ }
134
+ }
135
+ }
136
+ if (riders.length) {
137
+ window.setTimeout(() => riders.forEach((rider) => rider.remove()), 400);
138
+ }
139
+ // Parked windows land nowhere: a minimized window has no content to
140
+ // settle, so only the non-minimize exits bring the page back to
141
+ // their hash position.
142
+ if (!slide) {
143
+ window.setTimeout(
144
+ () =>
145
+ windowRef.current?.scrollIntoView({
146
+ behavior: "smooth",
147
+ block: "start",
148
+ }),
149
+ riders.length ? 240 : 60,
150
+ );
151
+ }
152
+ };
153
+
154
+ return {
155
+ maximize: () => {
156
+ // Snapshot the window's own flags first: the dismissal restores
157
+ // exactly these (never maximizes content, re-minimizes if it was
158
+ // minimized before the lift).
159
+ preFlags.current = { ...flagsOf(nodeId) };
160
+ // The hash anchors the maximized window so a refresh lands back on
161
+ // the revealed section. replaceState is untouched by the pageSlide
162
+ // pushState patch — no transition, no sound.
163
+ const el = windowRef.current;
164
+ if (el) {
165
+ const rect = el.getBoundingClientRect();
166
+ setParkedSize({
167
+ width: Math.round(rect.width),
168
+ height: Math.round(rect.height),
169
+ });
170
+ }
171
+ // Anchor the URL so a refresh lands back on the revealed section —
172
+ // in the page's own permalink form (?post=slug on the blog, hash
173
+ // elsewhere). Mobile still PUSHes a #tree-index hash: the OS back
174
+ // button's dismissal keys off the hash leaving THIS window; a
175
+ // query-only change never fires hashchange, so the permalink form
176
+ // can't drive that choreography.
177
+ if (matchMedia("(max-width: 768px)").matches) {
178
+ history.pushState(null, "", `#${nodeId}`);
179
+ } else {
180
+ const node = windows.find((w) => w.id === nodeId);
181
+ const url = node ? permalinkFor(node, permalinkMode) : `#${nodeId}`;
182
+ history.replaceState(null, "", url);
183
+ }
184
+ // A maximized window always shows its body — a stale minimized
185
+ // flag would leave it title-bar-only in the foreground — and the
186
+ // WHOLE subtree expands: every child window reveals beneath it.
187
+ patch(nodeId, { maximized: true, minimized: false });
188
+ for (const id of descendantIds(windows, nodeId)) {
189
+ patch(id, { expanded: true, minimized: false });
190
+ }
191
+ },
192
+ dismiss: () => dismiss(false),
193
+ dismissRevealed: () => dismiss(false, true),
194
+ dismissWithSlide: () => dismiss(true),
195
+ backdropRef,
196
+ parkedSize,
197
+ };
198
+ }
199
+
200
+ export function MaximizedWindow({
201
+ maximized,
202
+ controller,
203
+ nodeId,
204
+ onOutsideClick,
205
+ windowEl,
206
+ }: {
207
+ /** Whether the window is currently lifted over the backdrop. */
208
+ maximized: boolean;
209
+ controller: MaximizedController;
210
+ nodeId: string;
211
+ onOutsideClick: () => void;
212
+ /** The live window element — always rendered INSIDE the shell (the
213
+ * background element while maximized), in the same child slot, so its
214
+ * DOM identity survives maximize/dismiss round-trips. */
215
+ windowEl: ReactNode;
216
+ }) {
217
+ return (
218
+ <>
219
+ {maximized && controller.parkedSize && (
220
+ // The flow slot the lifted window left behind: measured at lift
221
+ // time, keeps the surrounding windows from reflowing (no jitter
222
+ // on maximize/unmaximize).
223
+ <div
224
+ className="window-parked"
225
+ style={{
226
+ width: controller.parkedSize.width,
227
+ height: controller.parkedSize.height,
228
+ }}
229
+ aria-hidden
230
+ />
231
+ )}
232
+ <div
233
+ ref={controller.backdropRef}
234
+ className={maximized ? "maximized-backdrop" : "maximized-shell"}
235
+ data-window-id={nodeId}
236
+ onClick={
237
+ maximized
238
+ ? (event) => {
239
+ // Clicks inside the window bubble up to the backdrop
240
+ // (containment!) — only dimmed-area clicks dismiss.
241
+ if (event.target === event.currentTarget) onOutsideClick();
242
+ }
243
+ : undefined
244
+ }
245
+ >
246
+ {windowEl}
247
+ </div>
248
+ </>
249
+ );
250
+ }
@@ -23,13 +23,18 @@ export function OkButton({
23
23
  return;
24
24
  }
25
25
  const { link } = node;
26
+ // OK revealing a section no longer rewrites the hash — the hash follows
27
+ // the maximized window (Window.tsx) and off-site departures only.
26
28
  if (!link) {
27
29
  expand(node.id);
28
- history.replaceState(null, "", `#${node.id}`);
29
30
  return;
30
31
  }
31
32
  if (/^https?:/.test(link) || node.externalLink || /\.\w+$/.test(link)) {
33
+ // Leaving the site: bookmark this window into the hash first, so a
34
+ // return visit (via back or the external page) lands back on the
35
+ // last revealed maximized section.
32
36
  remember(node.id);
37
+ history.replaceState(null, "", `#${node.id}`);
33
38
  window.location.assign(link);
34
39
  } else {
35
40
  navigate(link);
@@ -10,15 +10,9 @@ import {
10
10
  } from "./state";
11
11
  import { Window } from "./Window";
12
12
 
13
- function Roots({
14
- mode,
15
- children,
16
- }: {
17
- mode: PermalinkMode;
18
- children?: ReactNode;
19
- }) {
13
+ function Roots({ children }: { children?: ReactNode }) {
20
14
  const { windows } = useWindowStore();
21
- usePermalinkTarget(mode);
15
+ usePermalinkTarget();
22
16
  useRememberLeave();
23
17
  return (
24
18
  <>
@@ -55,7 +49,7 @@ export function PageWindows({
55
49
  permalinkMode={permalinkMode}
56
50
  onLeave={onLeave}
57
51
  >
58
- <Roots mode={permalinkMode}>{children}</Roots>
52
+ <Roots>{children}</Roots>
59
53
  </WindowProvider>
60
54
  );
61
55
  }