@a2f0/skyline 0.1.7 → 0.2.1

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,115 @@
1
+ // The viewer's code and styles, relative to the assets.
2
+ const VIEWER = "skyline-viewer.js";
3
+ const STYLESHEETS = ["study.css", "viewer.css"];
4
+ /** The validated, slash-terminated directory the assets are served from. */
5
+ export function assetsBase(document, assetsUrl) {
6
+ const base = new URL(assetsUrl, document.baseURI);
7
+ if (base.protocol !== "http:" && base.protocol !== "https:")
8
+ throw new TypeError("Skyline assets must be served over HTTP or HTTPS.");
9
+ if (base.search || base.hash)
10
+ throw new TypeError("Skyline assetsUrl must be a directory URL without a query or fragment.");
11
+ if (!base.pathname.endsWith("/"))
12
+ base.pathname += "/";
13
+ return base;
14
+ }
15
+ /**
16
+ * Imports the viewer module from the assets. Hosts bundle this file, but the module is
17
+ * served with the assets, so the comments ask webpack, Turbopack and Vite to leave the
18
+ * import to the browser; Rollup, esbuild and Bun leave an import of a variable alone.
19
+ */
20
+ function importViewer(url) {
21
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ /* turbopackIgnore: true */ url);
22
+ }
23
+ /** Resolves once `link` has loaded its stylesheet. */
24
+ function loaded(link) {
25
+ return new Promise((resolve, reject) => {
26
+ link.addEventListener("load", () => resolve(), { once: true });
27
+ link.addEventListener("error", () => reject(new Error(`Couldn't load ${link.href}.`)), { once: true });
28
+ });
29
+ }
30
+ // Set on the element itself, where it outranks the host page's rules: `all: initial` keeps
31
+ // the host's rules for its elements (a `div` or `*` rule) off it, so nothing they set is
32
+ // inherited into the viewer, `direction` (which `all` leaves) keeps a right-to-left page from
33
+ // mirroring the viewer's layout, and the rest sizes it to fill the container.
34
+ const HOST_STYLE = "all:initial;direction:ltr;display:block;position:relative;isolation:isolate;width:100%;height:100%;overflow:hidden";
35
+ // The failure message's own look, since the viewer's stylesheets may be what failed.
36
+ const FAILURE_STYLE = "box-sizing:border-box;height:100%;margin:0;padding:24px;background:#000;color:#aaa;font:12px/1.5 ui-monospace,monospace";
37
+ /** Shows why the viewer couldn't start, in place of the viewer. */
38
+ function showFailure(root, error) {
39
+ const message = root.ownerDocument.createElement("p");
40
+ message.style.cssText = FAILURE_STYLE;
41
+ message.setAttribute("role", "alert");
42
+ message.textContent = `The Chicago skyline couldn't start: ${error instanceof Error ? error.message : String(error)}`;
43
+ root.replaceChildren(...[...root.querySelectorAll("link")].filter((link) => link.sheet), message);
44
+ }
45
+ /**
46
+ * Mounts the viewer in a new element appended to `container`, and returns at once: the
47
+ * element is in place, and `ready` settles once the 3D skyline shows or can't start.
48
+ */
49
+ export function mountViewer(container, { assetsUrl, title, ...options }) {
50
+ const document = container.ownerDocument;
51
+ const assets = assetsBase(document, assetsUrl);
52
+ const element = document.createElement("div");
53
+ element.style.cssText = HOST_STYLE;
54
+ element.setAttribute("role", "region");
55
+ element.setAttribute("aria-label", title);
56
+ element.setAttribute("aria-busy", "true");
57
+ const root = element.attachShadow({ mode: "open" });
58
+ const stylesheets = STYLESHEETS.map((name) => {
59
+ const link = document.createElement("link");
60
+ link.rel = "stylesheet";
61
+ link.href = new URL(name, assets).href;
62
+ return link;
63
+ });
64
+ root.append(...stylesheets);
65
+ container.append(element);
66
+ // A host's engine that fails to load is the viewer's to report, once its scene asks for it
67
+ // (three-engine.ts); until then, or if the instance never gets that far, its rejection is
68
+ // handled here rather than left unhandled.
69
+ if (options.three)
70
+ Promise.resolve(options.three).catch(() => { });
71
+ const controller = new AbortController();
72
+ let destroyed = false, mounted = false;
73
+ const context = { ...options, assets, signal: controller.signal };
74
+ const ready = new Promise((resolve, reject) => {
75
+ Promise.all([importViewer(new URL(VIEWER, assets).href), ...stylesheets.map(loaded)])
76
+ .then(async ([viewer]) => {
77
+ if (destroyed)
78
+ return;
79
+ mounted = true;
80
+ await viewer.mount(root, context);
81
+ })
82
+ .then(() => {
83
+ if (destroyed)
84
+ return;
85
+ element.removeAttribute("aria-busy");
86
+ resolve();
87
+ }, (error) => {
88
+ if (destroyed)
89
+ return;
90
+ element.removeAttribute("aria-busy");
91
+ // A viewer that mounted says why its scene can't start, and keeps its drawing;
92
+ // one that never loaded is replaced by the reason.
93
+ if (!mounted) {
94
+ controller.abort(error);
95
+ showFailure(root, error);
96
+ }
97
+ console.error("The Chicago skyline couldn't start:", error);
98
+ reject(error);
99
+ });
100
+ });
101
+ // The element and the console report a failure, so a host that ignores `ready` sees no
102
+ // unhandled rejection.
103
+ ready.catch(() => { });
104
+ return {
105
+ element,
106
+ ready,
107
+ destroy() {
108
+ if (destroyed)
109
+ return;
110
+ destroyed = true;
111
+ controller.abort(new DOMException("The Chicago skyline was destroyed.", "AbortError"));
112
+ element.remove();
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,321 @@
1
+ import { loadMarkup } from "./markup.js";
2
+ import { provideThree } from "./three-engine.js";
3
+ // What each of the site toolbar's mode buttons shows, says and announces. The viewer opens
4
+ // on the 3D skyline; each toggle shows its mode and, pressed again, returns to it.
5
+ const home = { label: "3d skyline", title: "Return to the 3D skyline", status: "3D skyline displayed" };
6
+ const toggles = {
7
+ original: { id: "toggle-skyline", label: "show original", title: "Compare with the original skyline SVG", status: "Original skyline displayed" },
8
+ webgl: { id: "toggle-webgl", label: "show webgl", title: "Preview the WebGL skyline prototype", status: "WebGL skyline prototype displayed" },
9
+ enhanced: { id: "toggle-enhanced", label: "show enhanced", title: "Show the enhanced interactive skyline drawing", status: "Enhanced interactive skyline displayed" },
10
+ };
11
+ // The two prototypes the site's toolbar shows in place are pages of their own, as the
12
+ // studies are: the hover-scripted drawing and the WebGL prototype. Only the site's own
13
+ // index.html shows them, in frames; a host's viewer never does.
14
+ const prototypes = {
15
+ enhanced: { src: "skyline-animated.svg", title: "Animated Chicago skyline at night" },
16
+ webgl: { src: "skyline-webgl.html", title: "WebGL Chicago skyline prototype" },
17
+ };
18
+ const SCENE_FAILURE = "The 3D preview could not load. Check that WebGL 2 is enabled, then reload. You can still compare the SVG or return to the skyline.";
19
+ function element(document, tag, properties = {}, attributes = {}) {
20
+ const created = Object.assign(document.createElement(tag), properties);
21
+ for (const [name, value] of Object.entries(attributes))
22
+ created.setAttribute(name, value);
23
+ return created;
24
+ }
25
+ export function mount(root, { assets, signal, controls, attribution, navigation, page, three }) {
26
+ const document = root.ownerDocument;
27
+ const host = root.host;
28
+ const listening = { signal };
29
+ // The host's engine, if it shares one, before the scene's code first loads.
30
+ if (three)
31
+ provideThree(three);
32
+ // The scene's code and markup load together, while the stars and the controls show.
33
+ const sceneCode = import("./skyline-3d.js");
34
+ sceneCode.catch(() => { });
35
+ const sceneMarkup = loadMarkup(new URL("skyline-3d.html", assets), signal);
36
+ sceneMarkup.catch(() => { });
37
+ const viewer = element(document, "div", { className: "skyline-viewer" });
38
+ if (!navigation)
39
+ viewer.dataset["embedded"] = "";
40
+ const stars = element(document, "div", { className: "stars", id: "stars" }, { "aria-hidden": "true" });
41
+ const scene = element(document, "div", { className: "skyline-3d", id: "skyline-3d-scene" });
42
+ const drawing = element(document, "img", { className: "scene drawing", id: "drawing", alt: "Original Chicago skyline", hidden: true });
43
+ const originalReturn = element(document, "button", { type: "button", className: "original-return", id: "return-skyline-3d", title: home.title, hidden: true, textContent: home.label });
44
+ const embeddedOriginal = element(document, "button", { type: "button", className: "original-return", id: "embedded-original", title: toggles.original.title, hidden: true, textContent: toggles.original.label });
45
+ const status = element(document, "p", { className: "visually-hidden", id: "status" }, { "aria-live": "polite" });
46
+ viewer.append(stars, scene, drawing, originalReturn, embeddedOriginal, status);
47
+ root.append(viewer);
48
+ let mode = "3d";
49
+ // Keep the drawing reachable until the first 3D frame succeeds, even if the scene's
50
+ // markup or code never loads, and again if its graphics context is lost.
51
+ let sceneUnavailable = true;
52
+ const frames = {};
53
+ const toolbar = navigation ? createToolbar() : null;
54
+ function updateView() {
55
+ scene.hidden = mode !== "3d";
56
+ if (mode === "original" && !drawing.getAttribute("src"))
57
+ drawing.src = new URL("skyline-original-fit.svg", assets).href;
58
+ drawing.hidden = mode !== "original";
59
+ if (mode === "enhanced" || mode === "webgl")
60
+ frames[mode] ??= createPrototype(mode);
61
+ for (const [name, frame] of Object.entries(frames))
62
+ frame.hidden = name !== mode;
63
+ originalReturn.hidden = mode !== "original";
64
+ embeddedOriginal.hidden = navigation || mode !== "3d" || !sceneUnavailable;
65
+ if (toolbar) {
66
+ for (const [name, toggle] of Object.entries(toggles)) {
67
+ const button = toolbar.querySelector(`#${toggle.id}`), showing = name === mode;
68
+ button.textContent = showing ? home.label : toggle.label;
69
+ button.setAttribute("aria-pressed", String(showing));
70
+ button.title = showing ? home.title : toggle.title;
71
+ }
72
+ }
73
+ }
74
+ function showMode(next) {
75
+ mode = next;
76
+ updateView();
77
+ status.textContent = next === "3d" ? home.status : toggles[next].status;
78
+ }
79
+ // The scene's show original shortcut and the embedded button both open the drawing, and
80
+ // focus its way back.
81
+ function showOriginal() {
82
+ if (mode !== "3d")
83
+ return;
84
+ showMode("original");
85
+ originalReturn.focus({ preventScroll: true });
86
+ }
87
+ embeddedOriginal.addEventListener("click", showOriginal, listening);
88
+ // Returning focuses the scene's own way to the drawing again: the shortcut where the bar
89
+ // is open, the star where it is folded, or, while the scene can't take focus, a visible
90
+ // way back to the drawing.
91
+ originalReturn.addEventListener("click", () => {
92
+ showMode("3d");
93
+ const star = root.querySelector("#menu-toggle");
94
+ const focus = star?.getAttribute("aria-expanded") === "true" ? root.querySelector("#show-original") : star;
95
+ const target = !sceneUnavailable && focus?.matches(":enabled") ? focus : null;
96
+ target?.focus({ preventScroll: true });
97
+ if (!target || root.activeElement !== target) {
98
+ (embeddedOriginal.hidden ? toolbar?.querySelector(`#${toggles.original.id}`) : embeddedOriginal)?.focus({ preventScroll: true });
99
+ }
100
+ }, listening);
101
+ updateView();
102
+ // The stars come from stars.svg, the sky's own drawing, without its script: the viewer
103
+ // places them and picks the twinkling ones itself.
104
+ loadStars();
105
+ // Fullscreen shows the viewer's element alone; F toggles it from anywhere in the viewer,
106
+ // or, on the site's own page, from anywhere on the page. The element's own root says
107
+ // whether it is fullscreen: in a host's shadow root, the document names that root's host.
108
+ const fullscreenNow = () => host.getRootNode().fullscreenElement === host;
109
+ async function toggleFullscreen() {
110
+ try {
111
+ if (fullscreenNow())
112
+ await document.exitFullscreen();
113
+ else
114
+ await host.requestFullscreen();
115
+ }
116
+ catch {
117
+ status.textContent = "Fullscreen is not available in this browser";
118
+ }
119
+ }
120
+ function fullscreenShortcut(event) {
121
+ const key = event;
122
+ if (key.defaultPrevented || key.key?.toLowerCase() !== "f" || key.metaKey || key.ctrlKey || key.altKey)
123
+ return;
124
+ key.preventDefault();
125
+ void toggleFullscreen();
126
+ }
127
+ (page ? document : root).addEventListener("keydown", fullscreenShortcut, listening);
128
+ let fullscreen = false;
129
+ document.addEventListener("fullscreenchange", () => {
130
+ const now = fullscreenNow();
131
+ if (now === fullscreen)
132
+ return;
133
+ fullscreen = now;
134
+ const button = toolbar?.querySelector("#fullscreen");
135
+ if (button) {
136
+ button.textContent = now ? "exit fullscreen" : "fullscreen";
137
+ button.setAttribute("aria-pressed", String(now));
138
+ }
139
+ status.textContent = now ? "Fullscreen enabled" : "Fullscreen exited";
140
+ }, listening);
141
+ return startScene();
142
+ async function startScene() {
143
+ let markup;
144
+ try {
145
+ markup = await sceneMarkup;
146
+ }
147
+ catch (error) {
148
+ if (signal.aborted)
149
+ return new Promise(() => { });
150
+ const message = element(document, "p", { className: "loading", textContent: SCENE_FAILURE }, { role: "status" });
151
+ scene.append(message);
152
+ throw error;
153
+ }
154
+ if (signal.aborted)
155
+ return new Promise(() => { });
156
+ // The scene's markup, set as its options ask before its first frame: the bar open, with
157
+ // no fold, and the credit left out, as skyline-3d.html's own script does for its address.
158
+ for (const child of markup.body.children)
159
+ scene.append(document.importNode(child, true));
160
+ if (controls === "open") {
161
+ const toggle = scene.querySelector("#menu-toggle");
162
+ toggle.setAttribute("aria-expanded", "true");
163
+ toggle.title = "Hide the controls";
164
+ scene.querySelector(".control-bar").classList.add("open");
165
+ }
166
+ if (!attribution)
167
+ scene.querySelector(".attribution").hidden = true;
168
+ if (toolbar)
169
+ insetScene(toolbar);
170
+ // The scene's own lifetime: the viewer's, or shorter, if it fails to start.
171
+ const sceneLife = new AbortController();
172
+ signal.addEventListener("abort", () => sceneLife.abort(signal.reason), { once: true });
173
+ try {
174
+ const { startSkyline3d } = await sceneCode;
175
+ if (signal.aborted)
176
+ return new Promise(() => { });
177
+ await new Promise((resolve) => {
178
+ startSkyline3d({
179
+ root,
180
+ frame: scene,
181
+ assets,
182
+ signal: sceneLife.signal,
183
+ onShowOriginal: showOriginal,
184
+ onReady() {
185
+ sceneUnavailable = false;
186
+ updateView();
187
+ resolve();
188
+ },
189
+ onUnavailable() {
190
+ sceneUnavailable = true;
191
+ updateView();
192
+ },
193
+ });
194
+ });
195
+ }
196
+ catch (error) {
197
+ if (signal.aborted)
198
+ return new Promise(() => { });
199
+ sceneLife.abort(error);
200
+ // As the scene's own page says it: its controls stay, disabled, and the drawing one
201
+ // press away.
202
+ scene.querySelector("#loading").textContent = SCENE_FAILURE;
203
+ scene.querySelectorAll("button").forEach((button) => { button.disabled = true; });
204
+ throw error;
205
+ }
206
+ }
207
+ async function loadStars() {
208
+ try {
209
+ const response = await fetch(new URL("stars.svg", assets), { signal });
210
+ if (!response.ok)
211
+ throw new Error(`Couldn't load stars.svg (${response.status}).`);
212
+ const sky = new DOMParser().parseFromString(await response.text(), "image/svg+xml").documentElement;
213
+ if (signal.aborted || sky.nodeName !== "svg")
214
+ return;
215
+ // Its title, description and element names describe the standalone file; here the sky
216
+ // is decoration, and names would meet the viewer's own.
217
+ sky.querySelectorAll("script, title, desc").forEach((node) => node.remove());
218
+ for (const node of [sky, ...sky.querySelectorAll("[id]")])
219
+ node.removeAttribute("id");
220
+ sky.removeAttribute("role");
221
+ sky.removeAttribute("aria-labelledby");
222
+ const svg = document.importNode(sky, true);
223
+ twinkle(svg);
224
+ stars.append(svg);
225
+ }
226
+ catch (error) {
227
+ // The sky is decoration: the viewer works on its black without it.
228
+ if (!signal.aborted)
229
+ console.warn("The skyline's stars could not load:", error);
230
+ }
231
+ }
232
+ // Places the 96 stars at random and picks about a tenth to twinkle, each on its own slow
233
+ // cycle; a cycle ends at a fixed brightness, so the next joins it smoothly.
234
+ function twinkle(svg) {
235
+ const atmosphere = svg.querySelector(".atmosphere");
236
+ const all = [...atmosphere.querySelectorAll("circle")];
237
+ const randomize = (star) => {
238
+ star.style.setProperty("--twinkle-dim", String(0.42 + Math.random() * 0.05));
239
+ star.style.setProperty("--twinkle-peak", String(0.54 + Math.random() * 0.06));
240
+ star.style.setProperty("--twinkle-afterglow", String(0.47 + Math.random() * 0.06));
241
+ };
242
+ for (const star of all) {
243
+ star.setAttribute("cx", `${1 + Math.random() * 98}%`);
244
+ star.setAttribute("cy", `${2 + Math.random() * 96}%`);
245
+ }
246
+ const count = Math.round(all.length * 0.1);
247
+ for (let i = 0; i < count; i += 1) {
248
+ const selected = i + Math.floor(Math.random() * (all.length - i));
249
+ [all[i], all[selected]] = [all[selected], all[i]];
250
+ const star = all[i], duration = (60 + Math.random() * 40) / 1.5;
251
+ star.style.setProperty("--twinkle-duration", `${duration}s`);
252
+ star.style.setProperty("--twinkle-delay", `${-Math.random() * duration}s`);
253
+ randomize(star);
254
+ star.classList.add("twinkle");
255
+ }
256
+ atmosphere.addEventListener("animationiteration", (event) => {
257
+ if (event.animationName === "star-twinkle")
258
+ randomize(event.target);
259
+ }, listening);
260
+ }
261
+ // The site's toolbar, along the viewer's top.
262
+ function createToolbar() {
263
+ const nav = element(document, "nav", { className: "controls" }, { "aria-label": "Skyline controls" });
264
+ const button = (id, text, attributes = {}) => element(document, "button", { type: "button", id, textContent: text }, { "aria-pressed": "false", ...attributes });
265
+ const fullscreenButton = button("fullscreen", "fullscreen");
266
+ fullscreenButton.hidden = !document.fullscreenEnabled;
267
+ fullscreenButton.addEventListener("click", () => void toggleFullscreen(), listening);
268
+ nav.append(fullscreenButton);
269
+ for (const [name, toggle] of Object.entries(toggles)) {
270
+ const toggleButton = button(toggle.id, toggle.label);
271
+ toggleButton.addEventListener("click", () => showMode(mode === name ? "3d" : name), listening);
272
+ nav.append(toggleButton);
273
+ }
274
+ nav.append(element(document, "a", { href: new URL("building-study.html", assets).href, textContent: "3d building" }), element(document, "a", { href: new URL("skyline-study.html", assets).href, textContent: "skyline study" }));
275
+ const debugButton = button("debug-motion", "debug motion", { "aria-describedby": "stars-motion-status" });
276
+ const motionStatus = element(document, "p", { className: "motion-status", id: "stars-motion-status" }, { role: "status" });
277
+ nav.append(debugButton, motionStatus);
278
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
279
+ let debugMotion = false;
280
+ const updateMotionStatus = () => {
281
+ const preference = reducedMotion.matches ? "reduced" : "allowed";
282
+ const animation = debugMotion ? (reducedMotion.matches ? "debug override active" : "debug animation") : (reducedMotion.matches ? "stars paused" : "stars twinkling");
283
+ motionStatus.textContent = `System motion: ${preference} · ${animation}`;
284
+ debugButton.textContent = debugMotion ? "stop debug" : "debug motion";
285
+ debugButton.setAttribute("aria-pressed", String(debugMotion));
286
+ debugButton.title = debugMotion
287
+ ? "Return to the system motion setting"
288
+ : "Preview slightly stronger brightness changes at the same gentle pace, even when reduced motion is enabled";
289
+ };
290
+ debugButton.addEventListener("click", () => {
291
+ debugMotion = !debugMotion;
292
+ stars.classList.toggle("debug-motion", debugMotion);
293
+ updateMotionStatus();
294
+ }, listening);
295
+ reducedMotion.addEventListener("change", updateMotionStatus, listening);
296
+ updateMotionStatus();
297
+ viewer.append(nav);
298
+ return nav;
299
+ }
300
+ // The toolbar lies over the scene's top, which floats a building's detail below it.
301
+ function insetScene(nav) {
302
+ const inset = () => scene.style.setProperty("--viewer-controls", `${Math.max(0, Math.ceil(nav.getBoundingClientRect().bottom - scene.getBoundingClientRect().top))}px`);
303
+ const resizing = new ResizeObserver(inset);
304
+ resizing.observe(nav);
305
+ resizing.observe(scene);
306
+ signal.addEventListener("abort", () => resizing.disconnect(), { once: true });
307
+ }
308
+ // A prototype's page, in a frame of its own, shown in place the first time it is chosen.
309
+ // Keys pressed inside it go to its own window, so F listens there too.
310
+ function createPrototype(name) {
311
+ const frame = element(document, "iframe", { className: "scene", id: `${name}-scene`, title: prototypes[name].title, src: new URL(prototypes[name].src, assets).href });
312
+ frame.addEventListener("load", () => {
313
+ try {
314
+ frame.contentWindow?.addEventListener("keydown", fullscreenShortcut, listening);
315
+ }
316
+ catch { /* A frame from another origin keeps its keys. */ }
317
+ }, listening);
318
+ viewer.insertBefore(frame, originalReturn);
319
+ return frame;
320
+ }
321
+ }
package/site/stars.svg CHANGED
@@ -5,11 +5,13 @@
5
5
  style="background:#000000;pointer-events:none">
6
6
  <title id="stars-title">Twinkling night sky</title>
7
7
  <desc id="stars-description">96 randomly positioned grayscale stars spread across the full viewport. Ten randomly selected stars slowly twinkle with independent timing and changing brightness patterns while the rest stay steady. Reduced motion pauses twinkling; the debug-motion fragment enables stronger brightness pulses on the same ten stars.</desc>
8
+ <!-- The skyline viewer renders this sky in its shadow root without the script below, which
9
+ it ports (skyline-viewer.ts), and without element names; it selects by class. -->
8
10
  <style><![CDATA[
9
- #atmosphere circle {
11
+ .atmosphere circle {
10
12
  opacity: 0.5;
11
13
  }
12
- #atmosphere .twinkle {
14
+ .atmosphere .twinkle {
13
15
  animation: star-twinkle var(--twinkle-duration, 53.333s) ease-in-out var(--twinkle-delay, 0s) infinite;
14
16
  }
15
17
 
@@ -25,7 +27,7 @@
25
27
  }
26
28
 
27
29
  @media (prefers-reduced-motion: reduce) {
28
- #atmosphere .twinkle { animation: none; }
30
+ .atmosphere .twinkle { animation: none; }
29
31
  }
30
32
 
31
33
  /* An explicit debug preview may override the system motion preference. */
@@ -33,7 +35,7 @@
33
35
  animation: star-debug var(--twinkle-duration, 53.333s) ease-in-out var(--twinkle-delay, 0s) infinite;
34
36
  }
35
37
  ]]></style>
36
- <g id="atmosphere">
38
+ <g id="atmosphere" class="atmosphere">
37
39
  <g id="debug-motion">
38
40
  <g fill="#ebebeb" class="stars-a">
39
41
  <circle cx="22.65%" cy="16.86%" r="0.85" />
@@ -140,7 +142,7 @@
140
142
  </g>
141
143
  </g>
142
144
  <script><![CDATA[
143
- const stars = [...document.querySelectorAll("#atmosphere circle")];
145
+ const stars = [...document.querySelectorAll(".atmosphere circle")];
144
146
 
145
147
  function randomizeTwinkle(star) {
146
148
  star.style.setProperty("--twinkle-dim", 0.42 + Math.random() * 0.05);
@@ -166,7 +168,7 @@
166
168
  star.classList.add("twinkle");
167
169
  }
168
170
 
169
- document.querySelector("#atmosphere").addEventListener("animationiteration", (event) => {
171
+ document.querySelector(".atmosphere").addEventListener("animationiteration", (event) => {
170
172
  if (event.animationName === "star-twinkle") {
171
173
  // The cycle ends at a fixed brightness so the next pattern joins smoothly.
172
174
  randomizeTwinkle(event.target);
@@ -4,8 +4,6 @@
4
4
  function showStudyError(message) {
5
5
  document.querySelector("#loading").textContent = message;
6
6
  document.querySelectorAll("button").forEach((button) => { button.disabled = true; });
7
- if (window.parent !== window)
8
- window.parent.postMessage({ type: "skyline:unavailable" }, location.origin);
9
7
  }
10
8
  if (location.protocol === "file:") {
11
9
  const page = location.pathname.split("/").pop();