@vp-tw/dirwell 0.1.0-alpha.3 → 0.1.0-alpha.6

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.
@@ -0,0 +1,1090 @@
1
+ (() => {
2
+ "use strict";
3
+ const root = document.documentElement;
4
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)");
5
+ // Registered before first paint for cross-document navigation and BFCache restores.
6
+ for (const name of ["pageswap", "pagereveal"]) {
7
+ addEventListener(name, (event) => {
8
+ const transition = event.viewTransition;
9
+ if (!transition) return;
10
+ // A skipped or interrupted animation must not reject normal file navigation.
11
+ transition.ready.catch(() => {});
12
+ if (reduced.matches || root.dataset.cwTransitions === "false") transition.skipTransition();
13
+ });
14
+ }
15
+ // Included in the theme's private runtime closure, never installed as a global API.
16
+ function createCrosswaveNavigator({ main, reduced, getState, commit }) {
17
+ const rootUrl = new URL(main.dataset.cwRoot, document.baseURI);
18
+ const initialUrl = new URL(location.href);
19
+ initialUrl.hash = "";
20
+ const local = initialUrl.protocol === "file:";
21
+ const owner = rootUrl.href;
22
+ const scopePath = rootUrl.pathname.endsWith("/")
23
+ ? rootUrl.pathname
24
+ : new URL(".", rootUrl).pathname;
25
+ let current = {
26
+ url: initialUrl.href,
27
+ asset: new URL(main.dataset.cwPage, document.baseURI).href,
28
+ path: main.dataset.cwPath,
29
+ };
30
+ const recent = new Map();
31
+ const historyViews = new Map();
32
+ const savedEntry = history.state?.dirwellCrosswave;
33
+ let index =
34
+ savedEntry?.owner === owner && Number.isInteger(savedEntry.index) ? savedEntry.index : 0,
35
+ request = 0,
36
+ pending,
37
+ animations = [];
38
+ const notice = document.createElement("div");
39
+ notice.className = "cw-navigation-notice";
40
+ notice.setAttribute("role", "status");
41
+ notice.hidden = true;
42
+ document.body.append(notice);
43
+ const allowed = (url) =>
44
+ url.protocol === rootUrl.protocol &&
45
+ url.host === rootUrl.host &&
46
+ url.pathname.startsWith(scopePath);
47
+ const absoluteLinks = (scope, base) => {
48
+ for (const link of scope.querySelectorAll("a[href]")) {
49
+ const url = new URL(link.getAttribute("href"), base);
50
+ if (local && url.pathname.endsWith("/")) url.pathname += "index.html";
51
+ link.href = url.href;
52
+ }
53
+ for (const link of scope.querySelectorAll("[data-cw-page]"))
54
+ link.dataset.cwPage = new URL(link.dataset.cwPage, base).href;
55
+ };
56
+ absoluteLinks(document, document.baseURI);
57
+ const ownsCurrentEntry = () => {
58
+ const entry = history.state?.dirwellCrosswave;
59
+ return entry?.owner === owner && entry.index === index && entry.route.path === current.path;
60
+ };
61
+ const state = (route = current, position = index, view = getState()) => ({
62
+ ...history.state,
63
+ dirwellCrosswave: { owner, route, index: position, view },
64
+ });
65
+ try {
66
+ history.replaceState(state(), "");
67
+ } catch {
68
+ return;
69
+ } // Restricted hosts retain ordinary links and native transitions.
70
+
71
+ const stopAnimations = () => {
72
+ for (const { animation, layer } of animations) {
73
+ animation.cancel();
74
+ layer?.remove();
75
+ }
76
+ animations = [];
77
+ };
78
+ const show = (text, actions = []) => {
79
+ notice.replaceChildren(document.createTextNode(text));
80
+ for (const { label, run, href } of actions) {
81
+ const action = document.createElement(href ? "a" : "button");
82
+ action.textContent = label;
83
+ if (href) action.href = href;
84
+ else {
85
+ action.type = "button";
86
+ action.addEventListener("click", run);
87
+ }
88
+ notice.append(action);
89
+ }
90
+ notice.hidden = false;
91
+ };
92
+ const load = (route, signal) =>
93
+ new Promise((resolve, reject) => {
94
+ const asset = new URL(route.asset);
95
+ if (
96
+ !allowed(asset) ||
97
+ !/^crosswave-page-[a-f0-9]{24}\.js$/.test(asset.pathname.split("/").at(-1))
98
+ ) {
99
+ reject(new Error("Unsupported folder data"));
100
+ return;
101
+ }
102
+ const script = document.createElement("script");
103
+ script.async = true;
104
+ // Revalidate on every visit: a watcher may have rebuilt this directory.
105
+ asset.searchParams.set("cw-visit", String(Date.now()));
106
+ script.src = asset.href;
107
+ let payload;
108
+ const received = (event) => {
109
+ if (document.currentScript !== script) return;
110
+ const data = event.detail;
111
+ if (
112
+ data?.version === 1 &&
113
+ data.id === asset.pathname.split("/").at(-1) &&
114
+ data.path === route.path &&
115
+ typeof data.html === "string" &&
116
+ typeof data.outputName === "string" &&
117
+ (data.baseHref === null || typeof data.baseHref === "string")
118
+ )
119
+ payload = data;
120
+ };
121
+ const cleanup = () => {
122
+ clearTimeout(timer);
123
+ removeEventListener("dirwell:crosswave-page", received);
124
+ signal.removeEventListener("abort", aborted);
125
+ script.remove();
126
+ script.onload = script.onerror = null;
127
+ };
128
+ const fail = (error) => {
129
+ cleanup();
130
+ reject(error);
131
+ };
132
+ const aborted = () => fail(new DOMException("Navigation superseded", "AbortError"));
133
+ const timer = setTimeout(() => fail(new Error("Folder loading timed out")), 8000);
134
+ script.onerror = () => fail(new Error("Folder data is unavailable"));
135
+ script.onload = () => {
136
+ cleanup();
137
+ try {
138
+ if (!payload) {
139
+ reject(new Error("Folder data is incompatible"));
140
+ return;
141
+ }
142
+ const doc = new DOMParser().parseFromString(payload.html, "text/html");
143
+ const required = [
144
+ "[data-cw-root]",
145
+ ".cw-files",
146
+ ".cw-directory",
147
+ ".cw-location",
148
+ ".cw-stage > h1",
149
+ ".cw-directory > :first-child",
150
+ ".cw-empty",
151
+ ];
152
+ if (required.some((selector) => !doc.querySelector(selector))) {
153
+ reject(new Error("Folder data is incomplete"));
154
+ return;
155
+ }
156
+ const url = new URL(route.url);
157
+ if (local && url.pathname.endsWith("/")) url.pathname += payload.outputName;
158
+ const base = new URL(payload.baseHref ?? ".", url);
159
+ absoluteLinks(doc, base);
160
+ resolve({ doc, route: { ...route, url: url.href } });
161
+ } catch (error) {
162
+ reject(error);
163
+ }
164
+ };
165
+ addEventListener("dirwell:crosswave-page", received);
166
+ signal.addEventListener("abort", aborted, { once: true });
167
+ try {
168
+ document.head.append(script);
169
+ } catch (error) {
170
+ fail(error);
171
+ }
172
+ });
173
+ const address = (route) => {
174
+ if (!local) return route.url;
175
+ const url = new URL(initialUrl);
176
+ const relative = (value) => new URL(value).pathname.slice(scopePath.length);
177
+ url.hash = `cw=${encodeURIComponent(relative(route.url))}&cw-data=${encodeURIComponent(relative(route.asset))}&cw-path=${encodeURIComponent(route.path)}`;
178
+ return url.href;
179
+ };
180
+ const snapshot = (element) => {
181
+ const rect = element.getBoundingClientRect();
182
+ const viewportBottom = Math.min(
183
+ innerHeight,
184
+ document.querySelector(".cw-bottom").getBoundingClientRect().top,
185
+ );
186
+ const clone = element.cloneNode(false);
187
+ if (element.matches(".cw-browser")) {
188
+ clone.append(element.querySelector(".cw-list-head").cloneNode(true));
189
+ const source = element.querySelector(".cw-list-scroll");
190
+ const scroll = source.cloneNode(false),
191
+ list = element.querySelector(".cw-files").cloneNode(false);
192
+ const filesRect = element.querySelector(".cw-files").getBoundingClientRect();
193
+ const sourceRect = source.getBoundingClientRect();
194
+ const visibleTop = Math.max(0, sourceRect.top);
195
+ const visibleBottom = Math.min(viewportBottom, sourceRect.bottom);
196
+ list.style.cssText = `position:relative;height:${source.scrollHeight}px`;
197
+ // Clone visible rows only; large directories must not duplicate their entire DOM for motion.
198
+ for (const row of element.querySelectorAll(".cw-row:not([hidden])")) {
199
+ const rowRect = row.getBoundingClientRect();
200
+ if (rowRect.bottom < visibleTop) continue;
201
+ if (rowRect.top > visibleBottom) break;
202
+ const copy = row.cloneNode(true);
203
+ copy.style.cssText = `position:absolute;left:0;right:0;top:${rowRect.top - filesRect.top}px;width:100%`;
204
+ list.append(copy);
205
+ }
206
+ scroll.append(list, element.querySelector(".cw-empty").cloneNode(true));
207
+ clone.append(scroll);
208
+ clone._scroll = { element: scroll, top: source.scrollTop };
209
+ } else clone.replaceChildren(...[...element.childNodes].map((node) => node.cloneNode(true)));
210
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
211
+ for (const name of node.getAttributeNames())
212
+ if (name.startsWith("data-cw-")) node.removeAttribute(name);
213
+ }
214
+ clone.removeAttribute("id");
215
+ clone.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id"));
216
+ clone.setAttribute("aria-hidden", "true");
217
+ clone.inert = true;
218
+ clone.classList.add("cw-transition-layer");
219
+ clone.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;margin:0;z-index:3;pointer-events:none;view-transition-name:none`;
220
+ clone.style.overflow = "hidden";
221
+ clone.style.clipPath = `inset(${Math.max(0, -rect.top)}px 0 ${Math.max(0, rect.bottom - viewportBottom)}px 0)`;
222
+ document.body.append(clone);
223
+ if (clone._scroll) clone._scroll.element.scrollTop = clone._scroll.top;
224
+ return clone;
225
+ };
226
+ const exchange = (loaded, view, direction, writeHistory, position) => {
227
+ const moving =
228
+ !reduced.matches &&
229
+ document.documentElement.dataset.cwTransitions !== "false" &&
230
+ typeof main.animate === "function";
231
+ stopAnimations();
232
+ const elements = [
233
+ document.querySelector(".cw-browser"),
234
+ document.querySelector(".cw-detail"),
235
+ ];
236
+ const layers = moving
237
+ ? elements.map((element) =>
238
+ element.hidden || !element.getBoundingClientRect().height ? null : snapshot(element),
239
+ )
240
+ : [];
241
+ try {
242
+ if (writeHistory) {
243
+ // Save the view at departure, including edits made while a request was pending.
244
+ if (ownsCurrentEntry()) history.replaceState(state(), "");
245
+ history.pushState(state(loaded.route, position, view ?? {}), "", address(loaded.route));
246
+ for (const key of historyViews.keys())
247
+ if (key > position || key < position - history.length + 1) historyViews.delete(key);
248
+ }
249
+ commit(loaded.doc, view);
250
+ current = loaded.route;
251
+ index = position;
252
+ historyViews.set(index, { path: current.path, view: getState() });
253
+ try {
254
+ history.replaceState(state(), "");
255
+ } catch {}
256
+ } catch (error) {
257
+ layers.forEach((layer) => layer?.remove());
258
+ throw error;
259
+ }
260
+ if (!moving) return;
261
+ try {
262
+ elements.forEach((element, i) => {
263
+ const layer = layers[i];
264
+ if (layer) {
265
+ const animation = layer.animate(
266
+ [
267
+ { opacity: 1, transform: "translateX(0)" },
268
+ { opacity: 0, transform: `translateX(${-direction * 24}px)` },
269
+ ],
270
+ { duration: 140, easing: "ease-out" },
271
+ );
272
+ animations.push({ animation, layer });
273
+ animation.finished.then(
274
+ () => layer.remove(),
275
+ () => layer.remove(),
276
+ );
277
+ }
278
+ if (!element.hidden) {
279
+ const animation = element.animate(
280
+ [
281
+ { opacity: 0.25, transform: `translateX(${direction * 28}px)` },
282
+ { opacity: 1, transform: "translateX(0)" },
283
+ ],
284
+ { duration: 240, easing: "cubic-bezier(.16,1,.3,1)" },
285
+ );
286
+ animations.push({ animation });
287
+ animation.finished.catch(() => {});
288
+ }
289
+ });
290
+ } catch {
291
+ stopAnimations();
292
+ layers.forEach((layer) => layer?.remove());
293
+ }
294
+ };
295
+ const navigate = async (route, { view, pop = false, position = index + 1 } = {}) => {
296
+ historyViews.set(index, { path: current.path, view: getState() });
297
+ if (pop && historyViews.get(position)?.path === route.path)
298
+ view = historyViews.get(position).view;
299
+ const ticket = ++request;
300
+ pending?.abort();
301
+ const controller = new AbortController();
302
+ pending = controller;
303
+ if (!pop) {
304
+ const entry = history.state?.dirwellCrosswave;
305
+ position =
306
+ (entry?.owner === owner && Number.isInteger(entry.index) ? entry.index : index) + 1;
307
+ recent.delete(current.path);
308
+ recent.set(current.path, getState());
309
+ if (recent.size > 32) recent.delete(recent.keys().next().value);
310
+ if (view === undefined) view = recent.get(route.path);
311
+ try {
312
+ if (ownsCurrentEntry()) history.replaceState(state(), "");
313
+ } catch {
314
+ location.assign(route.url);
315
+ return;
316
+ }
317
+ }
318
+ const direction = pop
319
+ ? Math.sign(position - index) || 1
320
+ : current.path && (current.path.startsWith(`${route.path}/`) || route.path === "")
321
+ ? -1
322
+ : 1;
323
+ main.setAttribute("aria-busy", "true");
324
+ const delay = setTimeout(() => {
325
+ if (ticket === request)
326
+ show("Loading folder…", [{ label: "Cancel", run: () => controller.abort() }]);
327
+ }, 120);
328
+ try {
329
+ const loaded = await load(route, controller.signal);
330
+ if (ticket !== request || controller.signal.aborted) return;
331
+ exchange(loaded, view, direction, !pop, position);
332
+ notice.hidden = true;
333
+ } catch (error) {
334
+ if (ticket !== request) return;
335
+ if (pop || !ownsCurrentEntry()) {
336
+ // The browser has already changed its address: reload that exact destination on failure.
337
+ if (local) location.replace(route.url);
338
+ else location.reload();
339
+ return;
340
+ }
341
+ if (error.name === "AbortError") {
342
+ notice.hidden = true;
343
+ return;
344
+ }
345
+ show("Could not open this folder. Your current files are still available.", [
346
+ { label: "Retry", run: () => navigate(route, { view }) },
347
+ { label: "Open normally", href: route.url },
348
+ ]);
349
+ } finally {
350
+ clearTimeout(delay);
351
+ if (ticket === request) {
352
+ main.removeAttribute("aria-busy");
353
+ pending = null;
354
+ }
355
+ }
356
+ };
357
+ document.addEventListener("click", (event) => {
358
+ if (event.target.closest?.(".cw-skip")) {
359
+ event.preventDefault();
360
+ const panel = document.querySelector("#cw-panel");
361
+ panel.tabIndex = -1;
362
+ panel.focus({ preventScroll: true });
363
+ return;
364
+ }
365
+ const link = event.target.closest?.("a[data-cw-navigation][data-cw-page]");
366
+ if (
367
+ !link ||
368
+ event.defaultPrevented ||
369
+ event.button !== 0 ||
370
+ event.metaKey ||
371
+ event.ctrlKey ||
372
+ event.shiftKey ||
373
+ event.altKey ||
374
+ link.target ||
375
+ link.hasAttribute("download")
376
+ )
377
+ return;
378
+ const url = new URL(link.href),
379
+ asset = new URL(link.dataset.cwPage);
380
+ if (!allowed(url) || !allowed(asset) || url.hash) return;
381
+ event.preventDefault();
382
+ if (link.dataset.cwPath === current.path) {
383
+ pending?.abort();
384
+ notice.hidden = true;
385
+ return;
386
+ }
387
+ navigate({ url: url.href, asset: asset.href, path: link.dataset.cwPath });
388
+ });
389
+ document.addEventListener("keydown", (event) => {
390
+ if (
391
+ event.key === "Escape" &&
392
+ !event.isComposing &&
393
+ !event.altKey &&
394
+ !event.ctrlKey &&
395
+ !event.metaKey &&
396
+ !event.target.matches?.("input,textarea,select,[contenteditable]")
397
+ ) {
398
+ pending?.abort();
399
+ notice.hidden = true;
400
+ }
401
+ });
402
+ addEventListener("popstate", (event) => {
403
+ const entry = event.state?.dirwellCrosswave;
404
+ if (entry?.owner !== owner || !allowed(new URL(entry.route.url))) {
405
+ location.reload();
406
+ return;
407
+ }
408
+ navigate(entry.route, { view: entry.view, pop: true, position: entry.index });
409
+ });
410
+ addEventListener("pagehide", () => {
411
+ ++request;
412
+ pending?.abort();
413
+ main.removeAttribute("aria-busy");
414
+ notice.hidden = true;
415
+ stopAnimations();
416
+ });
417
+ reduced.addEventListener("change", () => {
418
+ if (reduced.matches) stopAnimations();
419
+ });
420
+ if (local && initialUrl.href !== location.href) {
421
+ const hash = new URLSearchParams(location.hash.slice(1));
422
+ if (hash.has("cw") && hash.has("cw-data") && hash.has("cw-path")) {
423
+ const url = new URL(hash.get("cw"), rootUrl),
424
+ asset = new URL(hash.get("cw-data"), rootUrl);
425
+ if (allowed(url) && allowed(asset))
426
+ navigate(
427
+ { url: url.href, asset: asset.href, path: hash.get("cw-path") },
428
+ {
429
+ pop: true,
430
+ position: index,
431
+ view: savedEntry?.owner === owner ? savedEntry.view : undefined,
432
+ },
433
+ );
434
+ }
435
+ }
436
+ return {
437
+ cancel() {
438
+ if (!pending) return false;
439
+ pending.abort();
440
+ notice.hidden = true;
441
+ return true;
442
+ },
443
+ };
444
+ }
445
+
446
+ document.addEventListener("DOMContentLoaded", () => {
447
+ const main = document.querySelector("[data-cw-root]");
448
+ if (!main) return;
449
+ document.body.dataset.cwEnhanced = "true";
450
+ const key = `dirwell:crosswave:${new URL(main.dataset.cwRoot, document.baseURI).pathname}`;
451
+ const read = (name) => {
452
+ try {
453
+ return localStorage.getItem(`${key}:${name}`);
454
+ } catch {
455
+ return null;
456
+ }
457
+ };
458
+ const write = (name, value) => {
459
+ try {
460
+ localStorage.setItem(`${key}:${name}`, value);
461
+ } catch {}
462
+ };
463
+ const colors = {
464
+ azure: [0.17, 0.31, 0.52],
465
+ violet: [0.36, 0.25, 0.49],
466
+ amber: [0.44, 0.3, 0.1],
467
+ rose: [0.44, 0.25, 0.33],
468
+ jade: [0.13, 0.37, 0.32],
469
+ graphite: [0.26, 0.3, 0.38],
470
+ };
471
+ const savedColor = read("color");
472
+ if (Object.hasOwn(colors, savedColor)) root.dataset.cwColor = savedColor;
473
+ let paused = root.dataset.cwMotion === "false" || read("paused") === "true";
474
+ let rows = [...document.querySelectorAll("[data-cw-entry]")];
475
+ const tabs = [...document.querySelectorAll("[data-cw-category]")];
476
+ const search = document.querySelector("#cw-search");
477
+ const clear = document.querySelector("[data-cw-clear]");
478
+ const empty = document.querySelector("[data-cw-empty]");
479
+ const status = document.querySelector("[data-cw-status]");
480
+ const detail = document.querySelector(".cw-detail");
481
+ const icons = new Map(tabs.map((tab) => [tab.dataset.cwCategory, tab.querySelector("svg")]));
482
+ const linkIcon = rows.find((row) => row.dataset.icon === "link")?.querySelector("svg");
483
+ if (linkIcon) icons.set("link", linkIcon);
484
+ let category = "all",
485
+ selected = null,
486
+ composing = false,
487
+ searchTimer;
488
+ const visible = () => rows.filter((row) => !row.hidden);
489
+ const select = (row, focus = false, reveal = true) => {
490
+ if (selected) selected.classList.remove("cw-selected");
491
+ selected = row;
492
+ if (!row) {
493
+ detail.hidden = true;
494
+ return;
495
+ }
496
+ row.classList.add("cw-selected");
497
+ const values = {
498
+ name: "name",
499
+ path: "path",
500
+ size: "size",
501
+ modified: "modified",
502
+ status: "status",
503
+ };
504
+ for (const [field, data] of Object.entries(values))
505
+ document.querySelector(`[data-cw-detail-${field}]`).textContent = row.dataset[data];
506
+ const host = document.querySelector("[data-cw-detail-icon]");
507
+ host.replaceChildren();
508
+ const icon = icons.get(row.dataset.icon);
509
+ if (icon) host.append(icon.cloneNode(true));
510
+ const link = row.querySelector("a");
511
+ const open = document.querySelector("[data-cw-open]");
512
+ open.hidden = !link;
513
+ if (link) {
514
+ open.href = link.href;
515
+ open.textContent = row.dataset.status.includes("declared target")
516
+ ? "Show declared target"
517
+ : row.dataset.category === "folder"
518
+ ? "Open folder"
519
+ : "Open file";
520
+ if (row.dataset.exits === "true") {
521
+ open.target = "_blank";
522
+ open.rel = "noopener";
523
+ open.removeAttribute("data-cw-navigation");
524
+ delete open.dataset.cwPage;
525
+ delete open.dataset.cwPath;
526
+ } else {
527
+ open.removeAttribute("target");
528
+ open.removeAttribute("rel");
529
+ open.setAttribute("data-cw-navigation", "");
530
+ open.dataset.cwPage = link.dataset.cwPage;
531
+ open.dataset.cwPath = link.dataset.cwPath;
532
+ }
533
+ }
534
+ detail.hidden = false;
535
+ if (focus) {
536
+ link?.focus({ preventScroll: true });
537
+ if (reveal)
538
+ row.scrollIntoView({
539
+ block: "nearest",
540
+ behavior: reduced.matches ? "instant" : "smooth",
541
+ });
542
+ }
543
+ };
544
+ const applyFilter = () => {
545
+ const query = search.value.trim().toLocaleLowerCase();
546
+ let count = 0;
547
+ for (const row of rows) {
548
+ row.hidden =
549
+ (category !== "all" && row.dataset.category !== category) ||
550
+ !`${row.dataset.name} ${row.dataset.path}`.toLocaleLowerCase().includes(query);
551
+ if (!row.hidden) count++;
552
+ }
553
+ empty.hidden = count !== 0;
554
+ empty.textContent = !rows.length
555
+ ? "This folder is empty."
556
+ : query
557
+ ? `No files match “${search.value.trim()}”.`
558
+ : "No files in this category.";
559
+ document.querySelector("[data-cw-count]").textContent =
560
+ `${count} ${count === 1 ? "item" : "items"}`;
561
+ status.textContent = `${count} ${count === 1 ? "item" : "items"} shown`;
562
+ clear.hidden = !search.value;
563
+ if (!selected || selected.hidden) select(visible()[0] ?? null);
564
+ };
565
+ const setCategory = (next, focus = false, animate = true) => {
566
+ category = next;
567
+ for (const tab of tabs) {
568
+ const active = tab.dataset.cwCategory === next;
569
+ tab.classList.toggle("cw-active", active);
570
+ tab.setAttribute("aria-selected", String(active));
571
+ tab.tabIndex = active ? 0 : -1;
572
+ if (active) {
573
+ document.querySelector("[data-cw-heading]").textContent =
574
+ tab.querySelector("span").textContent;
575
+ document.querySelector("#cw-panel").setAttribute("role", "tabpanel");
576
+ document.querySelector("#cw-panel").setAttribute("aria-labelledby", tab.id);
577
+ if (focus) tab.focus({ preventScroll: true });
578
+ const rail = document.querySelector(".cw-rail");
579
+ rail.scrollTo({
580
+ left: Math.max(0, tab.offsetLeft - tabs[0].offsetLeft),
581
+ behavior: reduced.matches ? "instant" : "smooth",
582
+ });
583
+ }
584
+ }
585
+ selected = null;
586
+ rows.forEach((row) => row.classList.remove("cw-selected"));
587
+ applyFilter();
588
+ if (animate) {
589
+ main.classList.remove("cw-category-changing");
590
+ void main.offsetWidth;
591
+ main.classList.add("cw-category-changing");
592
+ }
593
+ };
594
+ const moveCategory = (direction) => {
595
+ const index = tabs.findIndex((tab) => tab.dataset.cwCategory === category);
596
+ setCategory(tabs[(index + direction + tabs.length) % tabs.length].dataset.cwCategory, true);
597
+ };
598
+ const moveRow = (direction) => {
599
+ const list = visible().filter((row) => row.querySelector("a"));
600
+ if (!list.length) return;
601
+ let index = list.indexOf(selected);
602
+ index = index < 0 ? 0 : Math.max(0, Math.min(list.length - 1, index + direction));
603
+ select(list[index], true);
604
+ };
605
+ for (const tab of tabs) {
606
+ tab.addEventListener("click", () => setCategory(tab.dataset.cwCategory));
607
+ const count =
608
+ tab.dataset.cwCategory === "all"
609
+ ? rows.length
610
+ : rows.filter((row) => row.dataset.category === tab.dataset.cwCategory).length;
611
+ tab.querySelector("small").textContent = String(count);
612
+ }
613
+ main.addEventListener("pointerover", (event) => {
614
+ const row = event.target.closest?.("[data-cw-entry]");
615
+ if (row && event.pointerType !== "touch") select(row);
616
+ });
617
+ main.addEventListener("focusin", (event) => {
618
+ const row = event.target.closest?.("[data-cw-entry]");
619
+ if (row) select(row);
620
+ });
621
+ search.addEventListener("compositionstart", () => {
622
+ composing = true;
623
+ clearTimeout(searchTimer);
624
+ });
625
+ search.addEventListener("compositionend", () => {
626
+ composing = false;
627
+ applyFilter();
628
+ });
629
+ search.addEventListener("input", () => {
630
+ if (!composing) {
631
+ clearTimeout(searchTimer);
632
+ searchTimer = setTimeout(applyFilter, 80);
633
+ }
634
+ });
635
+ clear.addEventListener("click", () => {
636
+ search.value = "";
637
+ applyFilter();
638
+ search.focus();
639
+ });
640
+ const activate = () => {
641
+ const link = selected?.querySelector("a");
642
+ if (!link) return;
643
+ if (link.target !== "_blank") {
644
+ link.click();
645
+ return;
646
+ }
647
+ // Controller polling does not necessarily grant a browser user gesture.
648
+ // Detach the blank tab before loading source content, preserving opener isolation.
649
+ const tab = window.open("about:blank", "_blank");
650
+ if (tab) {
651
+ tab.opener = null;
652
+ tab.location.replace(link.href);
653
+ } else {
654
+ link.focus({ preventScroll: true });
655
+ status.classList.remove("cw-sr");
656
+ status.classList.add("cw-notice");
657
+ status.textContent =
658
+ "Your browser blocked the new tab. Press Enter or click the file to open it.";
659
+ }
660
+ };
661
+ let navigation;
662
+ const back = () => {
663
+ if (!navigation?.cancel()) document.querySelector("[data-cw-parent]")?.click();
664
+ };
665
+ const editing = () =>
666
+ document.activeElement?.matches("input,textarea,select,[contenteditable=true]");
667
+ document.addEventListener("keydown", (event) => {
668
+ if (
669
+ event.isComposing ||
670
+ event.keyCode === 229 ||
671
+ composing ||
672
+ event.altKey ||
673
+ event.ctrlKey ||
674
+ event.metaKey
675
+ )
676
+ return;
677
+ if (editing()) {
678
+ if (event.key === "Escape" && document.activeElement === search) {
679
+ search.value = "";
680
+ applyFilter();
681
+ search.blur();
682
+ selected?.querySelector("a")?.focus();
683
+ }
684
+ return;
685
+ }
686
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
687
+ event.preventDefault();
688
+ moveCategory(event.key === "ArrowLeft" ? -1 : 1);
689
+ } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
690
+ event.preventDefault();
691
+ moveRow(event.key === "ArrowUp" ? -1 : 1);
692
+ } else if (event.key === "Home" || event.key === "End") {
693
+ event.preventDefault();
694
+ if (document.activeElement?.matches("[role=tab]")) {
695
+ setCategory((event.key === "Home" ? tabs[0] : tabs.at(-1)).dataset.cwCategory, true);
696
+ } else {
697
+ const list = visible().filter((row) => row.querySelector("a"));
698
+ select(event.key === "Home" ? list[0] : list.at(-1), true);
699
+ }
700
+ } else if (event.key === "Backspace") {
701
+ event.preventDefault();
702
+ back();
703
+ } else if (event.key === "/") {
704
+ event.preventDefault();
705
+ search.focus();
706
+ } else if (event.key === "Enter" && document.activeElement?.matches("[role=tab]")) {
707
+ event.preventDefault();
708
+ selected?.querySelector("a")?.focus();
709
+ }
710
+ });
711
+ for (const selector of [
712
+ ".cw-rail",
713
+ ".cw-search",
714
+ ".cw-clock",
715
+ ".cw-key-help",
716
+ ".cw-appearance",
717
+ ])
718
+ document.querySelector(selector).hidden = false;
719
+ const clock = document.querySelector(".cw-clock");
720
+ const updateClock = () => {
721
+ const now = new Date();
722
+ clock.dateTime = now.toISOString();
723
+ clock.textContent = new Intl.DateTimeFormat(undefined, {
724
+ month: "numeric",
725
+ day: "numeric",
726
+ hour: "2-digit",
727
+ minute: "2-digit",
728
+ }).format(now);
729
+ };
730
+ updateClock();
731
+ let clockTimer = setInterval(updateClock, 30000);
732
+ const colorSelect = document.querySelector("#cw-color");
733
+ colorSelect.value = root.dataset.cwColor;
734
+ colorSelect.addEventListener("change", () => {
735
+ root.dataset.cwColor = colorSelect.value;
736
+ write("color", colorSelect.value);
737
+ wave?.draw(0);
738
+ });
739
+ const motionButton = document.querySelector("button[data-cw-motion]");
740
+ const updateMotion = () => {
741
+ motionButton.setAttribute("aria-pressed", String(paused));
742
+ motionButton.querySelector("span").textContent = reduced.matches
743
+ ? "Reduced motion"
744
+ : paused
745
+ ? "Resume waves"
746
+ : "Pause waves";
747
+ motionButton.disabled =
748
+ reduced.matches || root.dataset.cwMotion === "false" || !wave?.available;
749
+ if (!wave?.available) motionButton.querySelector("span").textContent = "Static background";
750
+ wave?.sync();
751
+ };
752
+ motionButton.addEventListener("click", () => {
753
+ paused = !paused;
754
+ write("paused", String(paused));
755
+ updateMotion();
756
+ });
757
+ let wave = createWave(
758
+ document.querySelector("#cw-wave"),
759
+ colors,
760
+ root,
761
+ () => !paused && !reduced.matches && !document.hidden,
762
+ );
763
+ document.querySelector("#cw-wave").addEventListener("cw-wave-fallback", updateMotion);
764
+ reduced.addEventListener("change", updateMotion);
765
+ updateMotion();
766
+ setCategory("all");
767
+ const scroll = document.querySelector(".cw-list-scroll");
768
+ const fitList = () => {
769
+ const footer = document.querySelector(".cw-bottom");
770
+ const available = Math.max(
771
+ 80,
772
+ innerHeight -
773
+ scroll.getBoundingClientRect().top -
774
+ footer.getBoundingClientRect().height -
775
+ 18,
776
+ );
777
+ scroll.style.maxHeight = `${Math.floor(available)}px`;
778
+ scroll.style.minHeight = `${Math.min(160, Math.floor(available))}px`;
779
+ };
780
+ const layoutObserver = new ResizeObserver(fitList);
781
+ for (const element of [
782
+ document.querySelector(".cw-top"),
783
+ document.querySelector(".cw-list-head"),
784
+ document.querySelector(".cw-rail"),
785
+ document.querySelector(".cw-bottom"),
786
+ ])
787
+ layoutObserver.observe(element);
788
+ addEventListener("resize", fitList);
789
+ fitList();
790
+
791
+ navigation = createCrosswaveNavigator({
792
+ main,
793
+ reduced,
794
+ getState: () => ({
795
+ category,
796
+ query: search.value,
797
+ selected: selected?.dataset.path,
798
+ scroll: scroll.scrollTop,
799
+ }),
800
+ commit: (doc, view) => {
801
+ clearTimeout(searchTimer);
802
+ composing = false;
803
+ document.title = doc.title;
804
+ document
805
+ .querySelector(".cw-location")
806
+ .replaceChildren(...doc.querySelector(".cw-location").childNodes);
807
+ document.querySelector(".cw-stage > h1").textContent =
808
+ doc.querySelector(".cw-stage > h1").textContent;
809
+ document
810
+ .querySelector(".cw-directory")
811
+ .firstElementChild.replaceWith(doc.querySelector(".cw-directory").firstElementChild);
812
+ document
813
+ .querySelector(".cw-files")
814
+ .replaceChildren(...doc.querySelector(".cw-files").childNodes);
815
+ main.dataset.cwPath = doc.querySelector("[data-cw-root]").dataset.cwPath;
816
+ rows = [...main.querySelectorAll("[data-cw-entry]")];
817
+ const nextLinkIcon = rows.find((row) => row.dataset.icon === "link")?.querySelector("svg");
818
+ if (nextLinkIcon) icons.set("link", nextLinkIcon);
819
+ for (const tab of tabs)
820
+ tab.querySelector("small").textContent = String(
821
+ tab.dataset.cwCategory === "all"
822
+ ? rows.length
823
+ : rows.filter((row) => row.dataset.category === tab.dataset.cwCategory).length,
824
+ );
825
+ search.value = view?.query ?? "";
826
+ setCategory(
827
+ tabs.some((tab) => tab.dataset.cwCategory === view?.category) ? view.category : "all",
828
+ false,
829
+ false,
830
+ );
831
+ main.classList.remove("cw-category-changing");
832
+ const row = rows.find((row) => !row.hidden && row.dataset.path === view?.selected);
833
+ select(row ?? visible()[0] ?? null, true, false);
834
+ if (!selected?.querySelector("a")) {
835
+ document.querySelector("#cw-panel").tabIndex = -1;
836
+ document.querySelector("#cw-panel").focus({ preventScroll: true });
837
+ }
838
+ scroll.scrollTop = view?.scroll ?? 0;
839
+ status.className = "cw-sr";
840
+ document.querySelector(".cw-skip").href = `${location.href.split("#")[0]}#cw-panel`;
841
+ // Persistent ResizeObservers update geometry after the browser lays out the new content.
842
+ },
843
+ });
844
+
845
+ // VueUse's useGamepad demonstrates event-driven connection and rAF polling.
846
+ // Read fresh standard-mapping snapshots; controls here are original and framework-free.
847
+ let padFrame = 0,
848
+ padHeld = new Map(),
849
+ stopped = false,
850
+ padIdentity = null;
851
+ const indicator = document.querySelector(".cw-controller");
852
+ const pollPad = (time) => {
853
+ if (stopped || document.hidden) return;
854
+ let pad;
855
+ try {
856
+ pad = Array.from(navigator.getGamepads?.() ?? []).find(
857
+ (p) => p?.connected && p.mapping === "standard",
858
+ );
859
+ } catch {
860
+ pad = null;
861
+ }
862
+ indicator.hidden = !pad;
863
+ if (!pad) {
864
+ padHeld.clear();
865
+ padIdentity = null;
866
+ padFrame = 0;
867
+ return;
868
+ }
869
+ const controllerLabel = document.querySelector("[data-cw-controller-label]");
870
+ if (controllerLabel.textContent !== "Controller · Confirm / Back")
871
+ controllerLabel.textContent = "Controller · Confirm / Back";
872
+ const pressed = (i) => Boolean(pad.buttons[i]?.pressed || pad.buttons[i]?.value > 0.5);
873
+ const active = {
874
+ left: pressed(14) || (pad.axes[0] ?? 0) < -0.4,
875
+ right: pressed(15) || (pad.axes[0] ?? 0) > 0.4,
876
+ up: pressed(12) || (pad.axes[1] ?? 0) < -0.4,
877
+ down: pressed(13) || (pad.axes[1] ?? 0) > 0.4,
878
+ confirm: pressed(0),
879
+ back: pressed(1),
880
+ search: pressed(9),
881
+ };
882
+ const identity = `${pad.index}:${pad.id}`;
883
+ if (padIdentity !== identity) {
884
+ padIdentity = identity;
885
+ padHeld.clear();
886
+ // A held confirm must not open another item after document navigation.
887
+ for (const [action, on] of Object.entries(active))
888
+ if (on) padHeld.set(action, { next: Infinity });
889
+ }
890
+ for (const [action, on] of Object.entries(active)) {
891
+ if (!on) {
892
+ padHeld.delete(action);
893
+ continue;
894
+ }
895
+ const hold = padHeld.get(action);
896
+ const repeat = ["left", "right", "up", "down"].includes(action);
897
+ if (hold && (!repeat || time < hold.next)) continue;
898
+ padHeld.set(action, { next: time + (hold ? 140 : 420) });
899
+ if (!document.hasFocus() || composing) continue;
900
+ if (editing()) {
901
+ if (action === "back") {
902
+ if (document.activeElement === search) {
903
+ search.value = "";
904
+ applyFilter();
905
+ }
906
+ document.activeElement?.blur();
907
+ selected?.querySelector("a")?.focus();
908
+ }
909
+ continue;
910
+ }
911
+ if (action === "left" || action === "right") moveCategory(action === "left" ? -1 : 1);
912
+ else if (action === "up" || action === "down") moveRow(action === "up" ? -1 : 1);
913
+ else if (action === "confirm") activate();
914
+ else if (action === "back") back();
915
+ else if (action === "search") search.focus();
916
+ }
917
+ padFrame = requestAnimationFrame(pollPad);
918
+ };
919
+ const startPad = () => {
920
+ if (root.dataset.cwGamepad !== "false" && !document.hidden && !padFrame) {
921
+ stopped = false;
922
+ padFrame = requestAnimationFrame(pollPad);
923
+ }
924
+ };
925
+ addEventListener("gamepadconnected", startPad);
926
+ addEventListener("gamepaddisconnected", () => {
927
+ padHeld.clear();
928
+ indicator.hidden = true;
929
+ startPad();
930
+ });
931
+ startPad();
932
+ document.addEventListener("visibilitychange", () => {
933
+ wave?.sync();
934
+ if (document.hidden) {
935
+ cancelAnimationFrame(padFrame);
936
+ padFrame = 0;
937
+ padHeld.clear();
938
+ padIdentity = null;
939
+ clearInterval(clockTimer);
940
+ } else {
941
+ clearInterval(clockTimer);
942
+ updateClock();
943
+ clockTimer = setInterval(updateClock, 30000);
944
+ startPad();
945
+ }
946
+ });
947
+ addEventListener("pagehide", () => {
948
+ stopped = true;
949
+ cancelAnimationFrame(padFrame);
950
+ padFrame = 0;
951
+ padHeld.clear();
952
+ padIdentity = null;
953
+ clearInterval(clockTimer);
954
+ wave?.stop();
955
+ });
956
+ addEventListener("pageshow", (event) => {
957
+ if (event.persisted) {
958
+ stopped = false;
959
+ clearInterval(clockTimer);
960
+ updateClock();
961
+ clockTimer = setInterval(updateClock, 30000);
962
+ wave?.sync();
963
+ startPad();
964
+ }
965
+ });
966
+ });
967
+
968
+ function createWave(canvas, colors, root, moving) {
969
+ let gl;
970
+ try {
971
+ gl = canvas.getContext("webgl", {
972
+ alpha: false,
973
+ antialias: false,
974
+ powerPreference: "low-power",
975
+ });
976
+ } catch {
977
+ return null;
978
+ }
979
+ if (!gl) return null;
980
+ const vertex = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
981
+ const fragment = `precision highp float;uniform vec2 resolution;uniform float time;uniform vec3 tint;
982
+ void main(){vec2 uv=gl_FragCoord.xy/resolution;float x=uv.x;float y=uv.y;float t=time;
983
+ vec3 ground=mix(tint*.22,tint,clamp(y*.85+x*.1,0.,1.));ground+=vec3(.035)*pow(max(0.,1.-distance(uv,vec2(.2,.9))),3.);
984
+ float light=0.;for(int i=0;i<3;i++){float k=float(i);float center=.40+.07*sin(x*4.2+t*.17+k*.8)+.035*cos(x*7.1-t*.11+k*1.4)+k*.024;
985
+ float breadth=.012+.037*(.5+.5*sin(x*2.7+t*.09+k));float d=y-center;float sheet=exp(-pow(d/breadth,2.));float edge=exp(-abs(d-breadth*.65)*430.);
986
+ float fold=exp(-abs(d+breadth*.35)*170.);float taper=pow(sin(clamp(x,0.,1.)*3.14159),.6);light+=(sheet*.07+edge*.23+fold*.09)*taper;}
987
+ ground+=mix(vec3(.6,.8,1.),vec3(1.),.75)*min(light,.12);gl_FragColor=vec4(ground,1.);}`;
988
+ const shader = (type, source) => {
989
+ const s = gl.createShader(type);
990
+ if (!s) return null;
991
+ gl.shaderSource(s, source);
992
+ gl.compileShader(s);
993
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
994
+ gl.deleteShader(s);
995
+ return null;
996
+ }
997
+ return s;
998
+ };
999
+ const vs = shader(gl.VERTEX_SHADER, vertex),
1000
+ fs = shader(gl.FRAGMENT_SHADER, fragment);
1001
+ if (!vs || !fs) {
1002
+ if (vs) gl.deleteShader(vs);
1003
+ if (fs) gl.deleteShader(fs);
1004
+ return null;
1005
+ }
1006
+ const program = gl.createProgram();
1007
+ if (!program) return null;
1008
+ gl.attachShader(program, vs);
1009
+ gl.attachShader(program, fs);
1010
+ gl.linkProgram(program);
1011
+ gl.deleteShader(vs);
1012
+ gl.deleteShader(fs);
1013
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1014
+ gl.deleteProgram(program);
1015
+ return null;
1016
+ }
1017
+ const buffer = gl.createBuffer();
1018
+ if (!buffer) {
1019
+ gl.deleteProgram(program);
1020
+ return null;
1021
+ }
1022
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
1023
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
1024
+ gl.useProgram(program);
1025
+ const p = gl.getAttribLocation(program, "p");
1026
+ gl.enableVertexAttribArray(p);
1027
+ gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
1028
+ const r = gl.getUniformLocation(program, "resolution"),
1029
+ t = gl.getUniformLocation(program, "time"),
1030
+ c = gl.getUniformLocation(program, "tint");
1031
+ let frame = 0,
1032
+ last = 0,
1033
+ lost = false,
1034
+ frozenTime = 17;
1035
+ const draw = (now) => {
1036
+ if (lost) return;
1037
+ const ratio = Math.min(devicePixelRatio || 1, 1.5, 1600 / innerWidth, 1000 / innerHeight),
1038
+ width = Math.round(innerWidth * ratio),
1039
+ height = Math.round(innerHeight * ratio);
1040
+ if (canvas.width !== width || canvas.height !== height) {
1041
+ canvas.width = width;
1042
+ canvas.height = height;
1043
+ gl.viewport(0, 0, width, height);
1044
+ }
1045
+ if (moving()) frozenTime = (Date.now() % 86400000) / 1000;
1046
+ gl.uniform2f(r, width, height);
1047
+ gl.uniform1f(t, frozenTime);
1048
+ gl.uniform3fv(c, colors[root.dataset.cwColor] ?? colors.azure);
1049
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
1050
+ canvas.parentElement.dataset.ready = "true";
1051
+ last = now;
1052
+ };
1053
+ const tick = (now) => {
1054
+ frame = 0;
1055
+ if (!moving() || lost) return;
1056
+ if (now - last >= 33) draw(now);
1057
+ frame = requestAnimationFrame(tick);
1058
+ };
1059
+ const stop = () => {
1060
+ cancelAnimationFrame(frame);
1061
+ frame = 0;
1062
+ };
1063
+ const sync = () => {
1064
+ stop();
1065
+ if (lost) return;
1066
+ draw(0);
1067
+ if (moving()) frame = requestAnimationFrame(tick);
1068
+ };
1069
+ canvas.addEventListener("webglcontextlost", (event) => {
1070
+ event.preventDefault();
1071
+ lost = true;
1072
+ stop();
1073
+ delete canvas.parentElement.dataset.ready;
1074
+ canvas.dispatchEvent(new Event("cw-wave-fallback"));
1075
+ });
1076
+ canvas.addEventListener("webglcontextrestored", () => {
1077
+ delete canvas.parentElement.dataset.ready;
1078
+ lost = true;
1079
+ });
1080
+ addEventListener("resize", () => draw(0));
1081
+ return {
1082
+ draw,
1083
+ sync,
1084
+ stop,
1085
+ get available() {
1086
+ return !lost;
1087
+ },
1088
+ };
1089
+ }
1090
+ })();