Compare commits
4 Commits
79f64e3f7c
...
ecd444b53b
| Author | SHA1 | Date | |
|---|---|---|---|
| ecd444b53b | |||
| fafb498d6f | |||
| e06191db32 | |||
| ccd40103a2 |
@@ -49,7 +49,7 @@ test.describe("Task 4 — Photosensitivity notice", () => {
|
||||
});
|
||||
|
||||
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
|
||||
test("Speed is a 0–4x forward slider; the loop follows it (default 1x)", async ({ page }) => {
|
||||
test("Speed is a 0–2x forward slider; the loop follows it (default 1x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
@@ -57,17 +57,17 @@ test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveAttribute("min", "0");
|
||||
await expect(slider).toHaveAttribute("max", "4");
|
||||
await expect(slider).toHaveAttribute("max", "2"); // capped at 2x — base clips aren't all-intra, so >2x stutters
|
||||
});
|
||||
|
||||
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
|
||||
test("forward speed sets the loop video's playbackRate (up to 2x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator("#play-speed").fill("4");
|
||||
await page.locator("#play-speed").fill("2");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
|
||||
.toBe(4);
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test("0x freezes the loop video", async ({ page }) => {
|
||||
|
||||
@@ -108,6 +108,20 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test("reduce motion: selecting a new altitude still swaps to a fresh clip (instant cut)", async ({ page }) => {
|
||||
await boot(page);
|
||||
// #scale-name reads "<scale> · <clip> (i/n)"; extract the clip id.
|
||||
const clipId = (s: string) => (s.split("·")[1] || "").split("(")[0].trim();
|
||||
await page.locator('label[for="reduce-motion"]').click(); // engage reduce motion
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
const before = (await page.locator("#scale-name").textContent())!;
|
||||
await wheelOnStage(page, 60); // descend one altitude (instant cut)
|
||||
await page.waitForFunction(() => /orbit/.test(document.querySelector("#scale-name")?.textContent || ""), null, { timeout: 20000 });
|
||||
const after = (await page.locator("#scale-name").textContent())!;
|
||||
expect(after).toContain("orbit");
|
||||
expect(clipId(after)).not.toBe(clipId(before)); // the clip actually changed (not stuck on the old scale's clip)
|
||||
});
|
||||
|
||||
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
|
||||
@@ -1015,6 +1015,12 @@ function setPos(next) {
|
||||
const { lo, frac } = HEFScrub.segmentOf(pos);
|
||||
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
|
||||
busy = false;
|
||||
// If we JUMPED straight here (a reduced-motion instant cut skips the segment that
|
||||
// would re-roll the destination), activeClipId still points at the previous
|
||||
// altitude's clip. Re-roll from this altitude's pool so the video actually changes.
|
||||
if (!poolIds(ring.scales[ringIndex]).includes(activeClipId)) {
|
||||
activeClipId = pickPoolClip(ringIndex, activeClipId);
|
||||
}
|
||||
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
|
||||
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
|
||||
// Land the loop on the frame the morph last showed for THIS travel direction:
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
|
||||
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
|
||||
"knobs.legend": { en: "Experience knobs (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expérience", ja: "体験つまみ" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
|
||||
|
||||
+12
-13
@@ -22,7 +22,7 @@
|
||||
<select id="welcome-lang" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
|
||||
<input type="range" id="welcome-play-speed" min="0" max="4" step="any" value="1"
|
||||
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
|
||||
<span id="welcome-play-speed-val">1.00×</span>
|
||||
</label>
|
||||
@@ -77,14 +77,14 @@
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
|
||||
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
|
||||
<input type="range" id="play-speed" min="0" max="2" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="play-speed-val" />
|
||||
<span id="play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<datalist id="play-speed-ticks">
|
||||
<option value="0"></option><option value="0.5"></option><option value="1"></option>
|
||||
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
|
||||
<option value="3"></option><option value="3.5"></option><option value="4"></option>
|
||||
<option value="0"></option><option value="0.25"></option><option value="0.5"></option>
|
||||
<option value="0.75"></option><option value="1"></option><option value="1.25"></option>
|
||||
<option value="1.5"></option><option value="1.75"></option><option value="2"></option>
|
||||
</datalist>
|
||||
<label class="dev-switch" for="reduce-motion">
|
||||
<input type="checkbox" id="reduce-motion" />
|
||||
@@ -96,6 +96,13 @@
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="knobs.legend">Experience knobs</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
@@ -104,14 +111,6 @@
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
|
||||
|
||||
+13
-8
@@ -69,14 +69,19 @@ def _write_redirects(out: Path, seg: str) -> None:
|
||||
|
||||
|
||||
def _write_headers(out: Path, seg: str) -> None:
|
||||
# The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is
|
||||
# invisible to returning visitors for hours (Pages' default is max-age=14400).
|
||||
# `no-cache` = store but revalidate every load (cheap 304 via etag). The media is
|
||||
# on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax.
|
||||
(out / "_headers").write_text(
|
||||
f"/{seg}/*\n"
|
||||
" Cache-Control: no-cache\n"
|
||||
)
|
||||
# The app shell must NOT be browser-cached — otherwise a deploy is invisible to
|
||||
# returning visitors for hours (Pages' default is max-age=14400). The HTML entry
|
||||
# points get `no-store` (never stored → a guaranteed-fresh index every visit):
|
||||
# `no-cache` (revalidate) was not enough — Safari served a STALE index from its
|
||||
# disk/bfcache, which then loaded the OLD versioned JS and broke the page. A fresh
|
||||
# index is the linchpin: it carries the content-hashed asset URLs (see
|
||||
# _version_assets), so once the HTML is fresh the JS/CSS are too. The rest of the
|
||||
# app path stays `no-cache`. Media is on R2 (immutable + content-hash), unaffected.
|
||||
# Cloudflare Pages `_headers` syntax; later rules win for the same header.
|
||||
html_pages = ("", "index.html", "credits.html", "about.html") # "" = the /seg/ root
|
||||
blocks = [f"/{seg}/*\n Cache-Control: no-cache\n"]
|
||||
blocks += [f"/{seg}/{p}\n Cache-Control: no-store\n" for p in html_pages]
|
||||
(out / "_headers").write_text("".join(blocks))
|
||||
|
||||
|
||||
def _version_assets(app_dir: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user