@escape-game-over/atlas 0.1.1 → 0.1.2

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,342 @@
1
+ /**
2
+ * A carousel's arithmetic and its input handling — not its markup.
3
+ *
4
+ * In `astro/` because it touches the DOM and timers, which the core is
5
+ * type-checked without — this and `consent.ts` are the folder allowed them.
6
+ *
7
+ * **It draws nothing.** No transform, no classes, no dots, no arrows, no
8
+ * `aria`. It owns which slide is current and calls `onChange` when that
9
+ * changes; a project does the one line that moves the track, and every design
10
+ * decision stays where the design is. That is the whole split, and it is why
11
+ * one function can serve a full-bleed hero and a three-up logo strip.
12
+ *
13
+ * What it does own is the handful of things that are identical in every
14
+ * carousel and quietly wrong in most: the negative modulo going backwards, a
15
+ * lock so a fast swipe cannot start a second move mid-transition, autoplay that
16
+ * restarts on interaction rather than fighting it, and a swipe threshold that
17
+ * does not steal a vertical scroll.
18
+ *
19
+ * ```ts
20
+ * const slider = carousel({
21
+ * length: slides.length,
22
+ * autoplayMs: 6000,
23
+ * onChange: (index) => {
24
+ * track.style.transform = `translateX(-${index * 100}%)`;
25
+ * },
26
+ * });
27
+ * slider.attach(viewport);
28
+ * ```
29
+ *
30
+ * The element handed to `attach` wants `touch-action: pan-y` in CSS. Without
31
+ * it a browser may claim the horizontal gesture before this sees it; with it,
32
+ * vertical scrolling still works over the carousel, which is what a reader on a
33
+ * phone expects.
34
+ */
35
+
36
+ export interface CarouselOptions {
37
+ /** How many slides there are. Fewer than two and everything is inert. */
38
+ readonly length: number;
39
+ /**
40
+ * Milliseconds between automatic advances. Omitted, nothing advances on its
41
+ * own.
42
+ *
43
+ * The timer restarts on every manual move rather than continuing, so a
44
+ * reader who just pressed *next* gets a full interval to look at what they
45
+ * asked for instead of the remainder of the last one.
46
+ */
47
+ readonly autoplayMs?: number;
48
+ /** Pixels of horizontal travel before a drag counts as a swipe. */
49
+ readonly swipeThreshold?: number;
50
+ /**
51
+ * How long a move is protected from the next one, in milliseconds.
52
+ *
53
+ * Should match the CSS transition. Too short and a fast swipe queues a
54
+ * second move over an unfinished one; too long and the carousel feels
55
+ * unresponsive.
56
+ */
57
+ readonly lockMs?: number;
58
+ /**
59
+ * Called with the new index, and only when it actually changed.
60
+ *
61
+ * Never called on creation: the markup already renders whichever slide it
62
+ * renders, and calling would make a project write the first position twice.
63
+ */
64
+ readonly onChange: (index: number) => void;
65
+ }
66
+
67
+ export interface Carousel {
68
+ /** The slide showing now. */
69
+ readonly index: number;
70
+ next(): void;
71
+ prev(): void;
72
+ goTo(index: number): void;
73
+ /**
74
+ * Wires one element up, and returns the undo.
75
+ *
76
+ * Starts input *and* autoplay, and the returned function stops both — one
77
+ * lifecycle rather than two. Nothing rotates before `attach`, because a
78
+ * carousel nobody can reach has no business advancing, and nothing rotates
79
+ * after detach, because writing a transform to a track that has left the
80
+ * document is a leak with no visible symptom.
81
+ *
82
+ * One element per carousel. Buttons and dots are wired by the caller to
83
+ * `next`, `prev` and `goTo` — this is only for the surface a gesture lands
84
+ * on, and there is one of those.
85
+ *
86
+ * `index` is not touched by either, which is the point of the split: the
87
+ * slide a reader left it on survives a detach and a re-attach.
88
+ */
89
+ attach(element: HTMLElement): () => void;
90
+ /**
91
+ * Holds autoplay until the matching `resume`.
92
+ *
93
+ * `attach` already pauses on hover, on focus within, and while the tab is
94
+ * hidden — WCAG 2.2.2 asks for a way to stop anything auto-updating for
95
+ * more than five seconds, and this rotates every six. This is for the rest:
96
+ * a play/pause button, a modal opening over the slider.
97
+ */
98
+ pause(): void;
99
+ /** Releases a `pause`. Does nothing if something else is still holding. */
100
+ resume(): void;
101
+ }
102
+
103
+ export function carousel(options: CarouselOptions): Carousel {
104
+ const {
105
+ length,
106
+ autoplayMs,
107
+ swipeThreshold = 10,
108
+ lockMs = 500,
109
+ onChange,
110
+ } = options;
111
+
112
+ /**
113
+ * Whether there is anywhere to go.
114
+ *
115
+ * One slide is a picture, not a carousel: nothing advances, nothing is
116
+ * scheduled, and a swipe does nothing rather than wrapping to itself.
117
+ */
118
+ const canMove = length > 1;
119
+
120
+ let index = 0;
121
+ let locked = false;
122
+ let lockTimer: ReturnType<typeof setTimeout> | undefined;
123
+ let autoplayTimer: ReturnType<typeof setInterval> | undefined;
124
+
125
+ /**
126
+ * Whether anything is wired up.
127
+ *
128
+ * Gates autoplay: a carousel that was built but never attached, or has been
129
+ * detached, has no business scheduling a timer that writes transforms to
130
+ * markup nobody is connected to.
131
+ */
132
+ let attached = false;
133
+
134
+ /**
135
+ * Everything currently holding autoplay still. Empty means it may run.
136
+ *
137
+ * A set of reasons rather than a flag, because the reasons are independent
138
+ * and overlap: leave the tab while hovering, come back with the pointer
139
+ * somewhere else, and `pointerleave` never fires — with one boolean,
140
+ * whichever source wrote last wins and autoplay is stuck off or wrongly on.
141
+ * Each source only ever adds and removes its own key, so no source can
142
+ * release another's hold.
143
+ */
144
+ const holds = new Set<string>();
145
+
146
+ const hold = (reason: string): void => {
147
+ holds.add(reason);
148
+ clearInterval(autoplayTimer);
149
+ };
150
+
151
+ const release = (reason: string): void => {
152
+ holds.delete(reason);
153
+ restartAutoplay();
154
+ };
155
+
156
+ /**
157
+ * Claims the carousel for one move, or says it is already busy.
158
+ *
159
+ * Everything that moves goes through this, autoplay included — a tick that
160
+ * landed mid-transition without it would let the next swipe start a second
161
+ * move over an unfinished one, which is the exact case the lock exists for.
162
+ */
163
+ const claim = (): boolean => {
164
+ if (locked) return false;
165
+ locked = true;
166
+ clearTimeout(lockTimer);
167
+ lockTimer = setTimeout(() => {
168
+ locked = false;
169
+ }, lockMs);
170
+ return true;
171
+ };
172
+
173
+ const restartAutoplay = (): void => {
174
+ clearInterval(autoplayTimer);
175
+ // Nothing to rotate through, nobody watching, or no autoplay asked
176
+ // for — in each case, nothing to schedule.
177
+ if (autoplayMs === undefined || !canMove || !attached) return;
178
+ if (holds.size > 0) return;
179
+ autoplayTimer = setInterval(() => {
180
+ // No `restartAutoplay` here: the interval already paces itself, and
181
+ // resetting it from inside its own tick would only churn timers.
182
+ if (claim()) move(index + 1);
183
+ }, autoplayMs);
184
+ };
185
+
186
+ /** The only place `index` changes. `at` may be out of range or negative. */
187
+ const move = (at: number): void => {
188
+ if (!canMove) return;
189
+ // `+ length` before the modulo: in JavaScript `-1 % 5` is `-1`, not
190
+ // `4`, so going backwards from the first slide without it lands on
191
+ // nothing.
192
+ const next = ((at % length) + length) % length;
193
+ if (next === index) return;
194
+
195
+ index = next;
196
+ onChange(index);
197
+ };
198
+
199
+ /** A move a reader asked for: refuses during one, and resets autoplay. */
200
+ const request = (at: number): void => {
201
+ if (!canMove || !claim()) return;
202
+ move(at);
203
+ restartAutoplay();
204
+ };
205
+
206
+ // Nothing is scheduled here: autoplay begins on the first `attach` and ends
207
+ // with the last detach, so a carousel that was built but never wired up
208
+ // does not sit rotating against markup nobody has connected to it.
209
+
210
+ return {
211
+ get index() {
212
+ return index;
213
+ },
214
+ next: () => request(index + 1),
215
+ prev: () => request(index - 1),
216
+ goTo: (at: number) => request(at),
217
+ pause: () => hold("manual"),
218
+ resume: () => release("manual"),
219
+
220
+ attach(element: HTMLElement): () => void {
221
+ let startX: number | null = null;
222
+ let startY = 0;
223
+ let lastX = 0;
224
+
225
+ const down = (event: PointerEvent): void => {
226
+ startX = event.clientX;
227
+ startY = event.clientY;
228
+ lastX = event.clientX;
229
+ // Capture, so a drag that leaves the element still finishes
230
+ // here rather than being lost to whatever it passed over.
231
+ element.setPointerCapture(event.pointerId);
232
+ };
233
+
234
+ const pointermove = (event: PointerEvent): void => {
235
+ if (startX === null) return;
236
+ lastX = event.clientX;
237
+ // Traveling more vertically than horizontally means they are
238
+ // scrolling the page, not the carousel. Abandon rather than
239
+ // compete: a carousel that eats a scroll is worse than one that
240
+ // misses a swipe.
241
+ if (
242
+ Math.abs(event.clientY - startY) >
243
+ Math.abs(event.clientX - startX)
244
+ ) {
245
+ startX = null;
246
+ }
247
+ };
248
+
249
+ const up = (): void => {
250
+ if (startX === null) return;
251
+ const traveled = startX - lastX;
252
+ startX = null;
253
+
254
+ if (traveled > swipeThreshold) request(index + 1);
255
+ else if (traveled < -swipeThreshold) request(index - 1);
256
+ };
257
+
258
+ const cancel = (): void => {
259
+ startX = null;
260
+ };
261
+
262
+ const wheel = (event: WheelEvent): void => {
263
+ // Horizontal intent only — a track-pad reports both axes, and a
264
+ // mouse wheel reports `deltaY` alone, which is the page's.
265
+ if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
266
+ if (Math.abs(event.deltaX) <= swipeThreshold) return;
267
+
268
+ event.preventDefault();
269
+ request(event.deltaX > 0 ? index + 1 : index - 1);
270
+ };
271
+
272
+ // One controller for the lot, rather than a `removeEventListener`
273
+ // mirroring each `addEventListener`. Those pairs have to match on
274
+ // both the function reference and the options, and the failure is
275
+ // silent: a listener added later without its twin simply survives
276
+ // teardown. Here the undo cannot drift from what it undoes.
277
+ const listeners = new AbortController();
278
+ const { signal } = listeners;
279
+
280
+ element.addEventListener("pointerdown", down, { signal });
281
+ element.addEventListener("pointermove", pointermove, { signal });
282
+ element.addEventListener("pointerup", up, { signal });
283
+ element.addEventListener("pointercancel", cancel, { signal });
284
+ // `passive: false` is load-bearing: a wheel listener is passive by
285
+ // default, where `preventDefault` silently does nothing and the
286
+ // page scrolls sideways underneath the carousel.
287
+ element.addEventListener("wheel", wheel, {
288
+ passive: false,
289
+ signal,
290
+ });
291
+
292
+ // Pointer and keyboard, kept apart: a reader hovering a slide and a
293
+ // reader tabbing through its links are both looking at it, but
294
+ // either can end while the other continues.
295
+ element.addEventListener("pointerenter", () => hold("hover"), {
296
+ signal,
297
+ });
298
+ element.addEventListener("pointerleave", () => release("hover"), {
299
+ signal,
300
+ });
301
+ element.addEventListener("focusin", () => hold("focus"), {
302
+ signal,
303
+ });
304
+ element.addEventListener("focusout", () => release("focus"), {
305
+ signal,
306
+ });
307
+
308
+ // On the document, not the element: a hidden tab is not a fact
309
+ // about the carousel. Registered with the same signal, so it goes
310
+ // when the carousel does rather than outliving it.
311
+ document.addEventListener(
312
+ "visibilitychange",
313
+ () => {
314
+ if (document.hidden) hold("hidden");
315
+ else release("hidden");
316
+ },
317
+ { signal }
318
+ );
319
+
320
+ attached = true;
321
+ // A tab that was already hidden when this ran gets no event, so the
322
+ // initial state is read rather than waited for.
323
+ if (document.hidden) holds.add("hidden");
324
+ restartAutoplay();
325
+
326
+ return () => {
327
+ listeners.abort();
328
+ attached = false;
329
+ clearInterval(autoplayTimer);
330
+
331
+ // The automatic holds belong to this attachment: an element
332
+ // detached while hovered would otherwise keep "hover" forever,
333
+ // and no `pointerleave` is ever coming for it — autoplay would
334
+ // never resume on re-attach. A manual pause is a deliberate
335
+ // choice and survives.
336
+ holds.delete("hover");
337
+ holds.delete("focus");
338
+ holds.delete("hidden");
339
+ };
340
+ },
341
+ };
342
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A development-only panel that failures announce themselves in.
3
+ *
4
+ * The problem it solves is narrow and real: a client component that fails to
5
+ * wire itself looks exactly like one that had nothing to do. A `console.error`
6
+ * is correct and invisible — nobody has devtools open while reading a page, and
7
+ * least of all the person who broke it ten seconds ago. This puts the same
8
+ * message where it cannot be missed.
9
+ *
10
+ * **Every call site must be behind `import.meta.env.DEV`.** The check belongs
11
+ * to the caller rather than to this module, so the whole thing — panel, styles
12
+ * and message strings — is dead code in a production build and gets dropped
13
+ * instead of shipped and never called.
14
+ *
15
+ * ```ts
16
+ * if (import.meta.env.DEV) reportDevError(this.localName, error);
17
+ * ```
18
+ *
19
+ * Written as the bare expression, without an optional chain: Vite substitutes
20
+ * `import.meta.env.DEV` with a literal, so the branch collapses and everything
21
+ * below is dropped. `import.meta.env?.DEV` is replaced as `import.meta.env` —
22
+ * an object literal whose property access a minifier has to fold rather than
23
+ * simply delete.
24
+ *
25
+ * Not a custom element, deliberately. It would need registering, it would
26
+ * collide with a project that registered the same name, and nothing ever
27
+ * extends it — the panel is an artefact of a broken page, not a part of the
28
+ * page's design.
29
+ */
30
+
31
+ const PANEL_ID = "atlas-dev-log";
32
+
33
+ /** Everything already reported, so a repeated failure counts rather than spams. */
34
+ const seen = new Map<string, HTMLElement>();
35
+
36
+ /** The message a line shows, for anything that might be thrown. */
37
+ function describe(error: unknown): string {
38
+ if (error instanceof Error) return error.message;
39
+ if (typeof error === "object" && error !== null) {
40
+ // A thrown plain object stringifies to "[object Object]", which is the
41
+ // same non-message this panel exists to replace.
42
+ try {
43
+ return JSON.stringify(error);
44
+ } catch {
45
+ return Object.prototype.toString.call(error);
46
+ }
47
+ }
48
+ return String(error);
49
+ }
50
+
51
+ function panel(): HTMLElement {
52
+ const existing = document.getElementById(PANEL_ID);
53
+ if (existing) return existing;
54
+
55
+ // Either nothing has failed yet, or the document this lived in was swapped
56
+ // out from under it — a client router replacing `<body>`. Either way every
57
+ // line in `seen` is detached, and counting into one would report nothing.
58
+ seen.clear();
59
+
60
+ const created = document.createElement("div");
61
+ created.id = PANEL_ID;
62
+ // Inline, because a project's stylesheet is not this module's to depend on
63
+ // — and a panel that needed setting up would not be there the one time it
64
+ // mattered. `z-index` at the maximum: whatever is broken, this outranks it.
65
+ created.style.cssText = [
66
+ "position:fixed",
67
+ "inset:auto 0 0 0",
68
+ "z-index:2147483647",
69
+ "max-height:40vh",
70
+ "overflow:auto",
71
+ "background:#7f1d1d",
72
+ "color:#fff",
73
+ "font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
74
+ ].join(";");
75
+
76
+ // Dismissable, because a panel pinned over the bottom 40% of the viewport
77
+ // will eventually be covering the component being debugged.
78
+ const close = document.createElement("button");
79
+ close.type = "button";
80
+ close.textContent = "✕";
81
+ close.setAttribute("aria-label", "Dismiss");
82
+ close.style.cssText =
83
+ "position:sticky;top:0;float:right;border:0;background:transparent;color:#fff;cursor:pointer;font:inherit;padding:.4rem .6rem";
84
+ close.addEventListener("click", () => {
85
+ created.remove();
86
+ seen.clear();
87
+ });
88
+ created.append(close);
89
+
90
+ document.body.append(created);
91
+ return created;
92
+ }
93
+
94
+ /**
95
+ * Reports a failure to the console and to the panel.
96
+ *
97
+ * `source` names what broke — an element's `localName`, a module's name —
98
+ * because the message alone rarely says which of six carousels it came from.
99
+ *
100
+ * Identical reports collapse into one line with a count. Six broken instances
101
+ * of the same component are one bug, and six identical lines would bury the
102
+ * second bug underneath them.
103
+ */
104
+ export function reportDevError(source: string, error: unknown): void {
105
+ const text = `${source} — ${describe(error)}`;
106
+
107
+ console.error(text, error);
108
+
109
+ // `isConnected`, not merely present: after a body swap the remembered line
110
+ // is detached, and incrementing its count would report into a node nobody
111
+ // can see. Falling through rebuilds it in the new document — the count
112
+ // restarts, which is right, since it is a different page.
113
+ const already = seen.get(text);
114
+ if (already?.isConnected) {
115
+ const count = Number(already.dataset.count ?? "1") + 1;
116
+ already.dataset.count = String(count);
117
+ already.textContent = `${text} ×${count}`;
118
+ return;
119
+ }
120
+
121
+ const line = document.createElement("p");
122
+ line.style.cssText =
123
+ "margin:0;padding:.4rem .6rem;border-top:1px solid rgba(255,255,255,.25)";
124
+ line.textContent = text;
125
+
126
+ seen.set(text, line);
127
+ panel().append(line);
128
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Scoped element lookups, without the ceremony.
3
+ *
4
+ * `querySelector` returns `Element`, so anything that wants to set `hidden`,
5
+ * read `dataset` or call `focus` has to say `<HTMLElement>` at every call — long
6
+ * enough that the line wraps, on lookups that are otherwise trivial. And
7
+ * `querySelectorAll` returns a `NodeList`, which needs spreading before it will
8
+ * `map` or `entries`. Both are noise, and both hide the one thing worth reading:
9
+ * what is being looked for.
10
+ *
11
+ * ```ts
12
+ * const { one, all } = within(root);
13
+ * const track = one("[data-carousel-track]");
14
+ * const dots = all<HTMLButtonElement>("[data-carousel-dot]");
15
+ * ```
16
+ *
17
+ * The root is bound once, which is the point: a client script that looks
18
+ * everything up inside the element it was given cannot reach a second copy of
19
+ * itself elsewhere on the page. `document` is a valid root, and should appear
20
+ * exactly once per script — to find the roots.
21
+ *
22
+ * **A selector with a combinator still matches against the whole document.**
23
+ * `within(form).one("form p")` can match a `<p>` whose ancestor `<form>` is not
24
+ * the one bound here; only the final filter is scoped. Simple selectors — an
25
+ * attribute, a class — are unaffected, which is what these are for. Use
26
+ * `:scope` if a combinator is ever genuinely needed.
27
+ */
28
+
29
+ /**
30
+ * Constrained to `Element` but defaulting to `HTMLElement`.
31
+ *
32
+ * The default is what almost every lookup wants — `hidden`, `dataset` and
33
+ * `focus` all live on `HTMLElement`, and having to name it at each call is the
34
+ * noise this exists to remove. The wider constraint is for the rest: inline
35
+ * `<svg>` and `<use>` are `SVGElement`, which is an `Element` and not an
36
+ * `HTMLElement`, so a narrower bound would refuse a perfectly ordinary lookup.
37
+ */
38
+ export interface Within {
39
+ /** The first match inside the root, or `null`. */
40
+ one<T extends Element = HTMLElement>(selector: string): T | null;
41
+ /** Every match inside the root, as an array rather than a `NodeList`. */
42
+ all<T extends Element = HTMLElement>(selector: string): T[];
43
+ }
44
+
45
+ export function within(root: ParentNode): Within {
46
+ return {
47
+ one: <T extends Element = HTMLElement>(selector: string) =>
48
+ root.querySelector<T>(selector),
49
+ all: <T extends Element = HTMLElement>(selector: string) => [
50
+ ...root.querySelectorAll<T>(selector),
51
+ ],
52
+ };
53
+ }