Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6ffc74a1 | |||
| 380c2d13e7 | |||
| ff06783173 | |||
| 87d605db39 | |||
| 75961ea18e | |||
| b83758fbca | |||
| 61bd4a7943 | |||
| 79105a3ef9 | |||
| 2d54023f94 | |||
| bf1013be74 | |||
| f1ce23c4fe | |||
| 0db602ebd0 | |||
| fa3bbef0d6 | |||
| 717bf5b08b | |||
| 64e8f658f6 | |||
| 6cf8fffc51 | |||
| 1f98b7bd8c | |||
| 6e2e65202e | |||
| fe77677d4c | |||
| 760fdc7550 | |||
| a895e7139b | |||
| e62382c5e5 | |||
| 041fcdeb7d | |||
| 31956bfdfc | |||
| 5a08d1dd35 | |||
| 768fee5ccd | |||
| 432fbd7703 | |||
| 4e4a5c256d | |||
| 8cfceffb76 | |||
| 5f8b8f14e6 | |||
| 59c1c3a895 | |||
| 582183db2f | |||
| c13ce36787 | |||
| 21830069ad | |||
| 50366c056e |
@@ -0,0 +1,885 @@
|
||||
# Accessibility Pass + About Page Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the simulator usable away from the attended kiosk — reduced-motion, seizure safety, keyboard/screen-reader access, AA contrast — and add an English-first `about.html`.
|
||||
|
||||
**Architecture:** All client-side in `simulator/static/`. One new pure helper (`flash.js`, UMD + node tests). One new page (`about.html`) mirroring `credits.html`. Behavior changes live in `app.js`; chrome/contrast in `style.css` and `index.html`. The Python static build (`tools/build_static.py`) copies the new static files.
|
||||
|
||||
**Tech Stack:** Vanilla JS (no framework, UMD modules like `i18n.js`/`credits.js`), CSS, Playwright e2e (`simulator/e2e/`), `node --test` for pure helpers, FastAPI dev server for e2e fixtures.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No engine/Python behavior changes — only `tools/build_static.py`'s copy list is touched.
|
||||
- New JS modules follow the existing **UMD** pattern (browser global + `module.exports`), like `i18n.js` and `credits.js`.
|
||||
- About page is **English-first**; i18n keys may be EN-only and fall back via `pickUiString` (returns `v.en`).
|
||||
- Target **WCAG 2.1 AA** (4.5:1 text / 3:1 large) plus WCAG 2.3.1 (≤3 flashes/sec) and 2.3.3 (no autonomous animation under reduced-motion).
|
||||
- Persist user toggles in `localStorage`, mirroring `DEV_KEY = "hef.devMode"` (`app.js:918`).
|
||||
- Reduced-motion toggle key: `RM_KEY = "hef.reduceMotion"`. Warning-gate dismissal key: `WARN_KEY = "hef.motionWarnDismissed"`.
|
||||
- `<html lang>` switching already exists (`applyUiStrings`, `app.js:1325`) — do NOT re-implement.
|
||||
- E2E in this env: start uvicorn by hand with the venv `python` (only `python3` exists on PATH) and run Playwright with `reuseExistingServer`. `loop-recovery.spec.ts` is known-red on a clean baseline (boots without video).
|
||||
- Direction convention: descend = `+1` (cosmos→abyss), matching wheel-down (`app.js:703`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Output-panel layout — language picker below Audio, globe inline-left
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html:37-51` (Output fieldset)
|
||||
- Modify: `simulator/static/style.css` (add `.lang-pick` rule)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the `#lang-select` element ends up after `#audio` in DOM order; `.lang-pick` is a flex row.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Create `simulator/e2e/tests/a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("language picker sits below the Audio control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const audio = page.locator("#audio");
|
||||
const lang = page.locator("#lang-select");
|
||||
await expect(audio).toBeVisible();
|
||||
await expect(lang).toBeVisible();
|
||||
const aBox = await audio.boundingBox();
|
||||
const lBox = await lang.boundingBox();
|
||||
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang is rendered lower than audio
|
||||
});
|
||||
|
||||
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const pick = page.locator(".lang-pick");
|
||||
const select = page.locator("#lang-select");
|
||||
const pBox = await pick.boundingBox();
|
||||
const sBox = await select.boundingBox();
|
||||
// select starts to the right of the label's left edge, and shares its row (height ~ one line)
|
||||
expect(sBox!.x).toBeGreaterThan(pBox!.x);
|
||||
expect(pBox!.height).toBeLessThan(40);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify the first test fails**
|
||||
|
||||
Start the dev server (separate shell, from `simulator/`):
|
||||
`../.venv/bin/python -m uvicorn server.app:app --port 8000` (adjust to the project's venv/module path).
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "below the Audio"`
|
||||
Expected: FAIL (lang currently renders above audio).
|
||||
|
||||
- [ ] **Step 3: Move the language picker in `index.html`**
|
||||
|
||||
In the Output `<fieldset>`, delete the `<label class="lang-pick">…</label>` block from its current spot (above the Video toggle) and re-insert it as the LAST child of the fieldset, after the Audio `<label class="audio-level">`. Result order: legend → Video toggle → Audio level → language picker.
|
||||
|
||||
- [ ] **Step 4: Add the `.lang-pick` flex rule in `style.css`**
|
||||
|
||||
Add near the other panel rules:
|
||||
|
||||
```css
|
||||
/* Globe + language select on one row (the select no longer goes full-width here). */
|
||||
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.4rem 0; }
|
||||
.lang-pick select { flex: 1; width: auto; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run both layout tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "language picker|globe"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/style.css simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): move language picker below Audio, globe inline-left"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Low-vision contrast + global focus rings + visually-hidden utility
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/style.css` (color bumps, `:focus-visible`, `.visually-hidden`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a `.visually-hidden` utility class used by Task 4 (gate) and Task 7 (aria-live).
|
||||
|
||||
- [ ] **Step 1: Bump failing text colors to AA**
|
||||
|
||||
In `style.css`, change these declarations (values chosen to clear 4.5:1 on the dark backgrounds; verify with a contrast check in Step 3):
|
||||
|
||||
```css
|
||||
/* was #789 — too low on #111 */
|
||||
.hint { color: #9fb3c8; }
|
||||
/* dial labels/captions were #789ac0 / #4d6184 on #0d1320 */
|
||||
.dial-label { fill: #b8cfe6; }
|
||||
.dial-caption { fill: #8fa6c4; }
|
||||
```
|
||||
|
||||
(Keep every other property on those selectors unchanged — edit only the color/fill.)
|
||||
|
||||
- [ ] **Step 2: Add focus-visible + visually-hidden utilities**
|
||||
|
||||
```css
|
||||
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
|
||||
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
|
||||
#dial:focus-visible { outline-offset: 4px; }
|
||||
/* Screen-reader-only text (announcements, labels) — present in a11y tree, off-screen. */
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify contrast**
|
||||
|
||||
Manually confirm with any WCAG contrast tool that `#9fb3c8` on `#111`, `#b8cfe6` on `#0d1320`, and `#8fa6c4` on `#0d1320` each meet ≥4.5:1 (≥3:1 acceptable for the dial caption if it reads as large). Adjust lighter if any fails.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/style.css
|
||||
git commit -m "feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `flash.js` — pure flash-clamp helper + node tests
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/flash.js`
|
||||
- Test: `simulator/static/flash.test.js` (new, `node --test`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `HEFFlash.minSafeDurationMs(count)` → minimum total ms for `count` luminance transitions to stay ≤3/sec; `HEFFlash.clampDurationMs(requestedMs, count)` → `max(requestedMs, minSafeDurationMs(count))`. Consumed by Task 8 (audit).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `simulator/static/flash.test.js`:
|
||||
|
||||
```js
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const F = require("./flash.js");
|
||||
|
||||
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
|
||||
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
|
||||
assert.strictEqual(F.minSafeDurationMs(6), 2000);
|
||||
assert.strictEqual(F.minSafeDurationMs(0), 0);
|
||||
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
|
||||
});
|
||||
|
||||
test("clampDurationMs: stretches only when too fast", () => {
|
||||
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
|
||||
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast → clamped up
|
||||
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: FAIL ("Cannot find module './flash.js'").
|
||||
|
||||
- [ ] **Step 3: Implement `flash.js`**
|
||||
|
||||
```js
|
||||
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
|
||||
// (luminance transitions) per second. Given a transition COUNT, returns the
|
||||
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
|
||||
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFFlash = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const MAX_PER_SEC = 3;
|
||||
function minSafeDurationMs(count) {
|
||||
const n = Math.max(0, Number(count) || 0);
|
||||
return (n / MAX_PER_SEC) * 1000;
|
||||
}
|
||||
function clampDurationMs(requestedMs, count) {
|
||||
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
|
||||
}
|
||||
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify pass**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/flash.js simulator/static/flash.test.js
|
||||
git commit -m "feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Photosensitivity warning gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (gate markup inside `.screen`)
|
||||
- Modify: `simulator/static/style.css` (gate styles)
|
||||
- Modify: `simulator/static/app.js` (gate logic around `run-sim` reveal, `app.js:1297`)
|
||||
- Modify: `simulator/static/i18n.js` (gate strings)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `WARN_KEY` localStorage flag; `maybeShowMotionWarning()` called before `run-sim` is revealed.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("motion warning gate shows once, then is remembered", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gate = page.locator("#motion-warning");
|
||||
await expect(gate).toBeVisible();
|
||||
await page.locator("#motion-warning-continue").click();
|
||||
await expect(gate).toBeHidden();
|
||||
// Reload: dismissal persisted, gate stays hidden, run-sim is the entry point.
|
||||
await page.reload();
|
||||
await expect(page.locator("#motion-warning")).toBeHidden();
|
||||
await expect(page.locator("#run-sim")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
|
||||
Expected: FAIL (no `#motion-warning`).
|
||||
|
||||
- [ ] **Step 3: Add gate markup in `index.html`**
|
||||
|
||||
Inside `<div class="screen">`, after the `#run-sim` button:
|
||||
|
||||
```html
|
||||
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
|
||||
<div class="mw-card">
|
||||
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
|
||||
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add gate styles in `style.css`**
|
||||
|
||||
```css
|
||||
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
|
||||
align-items: center; justify-content: center; padding: 1rem;
|
||||
background: rgba(2, 4, 10, 0.92); }
|
||||
.motion-warning.hidden { display: none; }
|
||||
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
|
||||
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
|
||||
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #b9c8e0; }
|
||||
.mw-card .run-sim { position: static; transform: none; }
|
||||
.mw-card .run-sim:hover { transform: scale(1.04); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add i18n strings in `i18n.js`**
|
||||
|
||||
Add to `UI_STRINGS` (EN required; other langs may be added later, fallback is EN):
|
||||
|
||||
```js
|
||||
"warn.title": { en: "Heads up — motion & flashing" },
|
||||
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
|
||||
"warn.continue": { en: "Continue" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Wire gate logic in `app.js`**
|
||||
|
||||
Add near `DEV_KEY` (`app.js:918`):
|
||||
|
||||
```js
|
||||
const WARN_KEY = "hef.motionWarnDismissed";
|
||||
function motionWarnDismissed() {
|
||||
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
|
||||
}
|
||||
function maybeShowMotionWarning() {
|
||||
const gate = $("motion-warning");
|
||||
if (!gate) return;
|
||||
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
|
||||
gate.classList.remove("hidden");
|
||||
$("motion-warning-continue").addEventListener("click", () => {
|
||||
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
|
||||
gate.classList.add("hidden");
|
||||
$("motion-warning-continue").focus({ preventScroll: true });
|
||||
$("run-sim").focus({ preventScroll: true });
|
||||
}, { once: true });
|
||||
}
|
||||
```
|
||||
|
||||
In `main()`, immediately after `$("run-sim").classList.remove("hidden");` (`app.js:1297`), add:
|
||||
|
||||
```js
|
||||
maybeShowMotionWarning(); // photosensitivity notice on first visit (over the stage)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the gate test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
|
||||
Expected: PASS. (If `localStorage` carries across tests, the test clears it via `page.goto` fresh context — Playwright uses a fresh context per test by default.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/style.css simulator/static/app.js simulator/static/i18n.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): one-time photosensitivity warning gate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Reduced-motion freeze-to-stills + toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (toggle in Output fieldset)
|
||||
- Modify: `simulator/static/i18n.js` (toggle label)
|
||||
- Modify: `simulator/static/app.js` (`reduceMotion` state, freeze logic, `autoScrub` instant path)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `reduceMotion` boolean; `isReduced()` accessor used by `autoScrub` (Task 5) and dial keyboard (Task 6); `applyReduceMotion()` pauses/resumes playback.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Dismiss the gate and start the experience so video is playing.
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
await page.locator("#run-sim").click();
|
||||
await page.waitForTimeout(400);
|
||||
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await page.locator("#reduce-motion").check();
|
||||
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
|
||||
Expected: FAIL (no `#reduce-motion`).
|
||||
|
||||
- [ ] **Step 3: Add the toggle markup in `index.html`**
|
||||
|
||||
In the Output fieldset, after the Audio level label (and before the language picker added in Task 1), add a switch mirroring the existing `.dev-switch`:
|
||||
|
||||
```html
|
||||
<label class="dev-switch" for="reduce-motion">
|
||||
<input type="checkbox" id="reduce-motion" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the i18n label in `i18n.js`**
|
||||
|
||||
```js
|
||||
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `reduceMotion` state + freeze logic in `app.js`**
|
||||
|
||||
Near `DEV_KEY`/`WARN_KEY`:
|
||||
|
||||
```js
|
||||
const RM_KEY = "hef.reduceMotion";
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
function initReduceMotion() {
|
||||
const box = $("reduce-motion");
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
|
||||
// Default to the OS setting when the user hasn't chosen yet.
|
||||
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
reduceMotion = stored === null ? !!prefers : stored === "1";
|
||||
if (box) {
|
||||
box.checked = reduceMotion;
|
||||
box.addEventListener("change", () => {
|
||||
reduceMotion = box.checked;
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
applyReduceMotion();
|
||||
});
|
||||
}
|
||||
applyReduceMotion();
|
||||
}
|
||||
function applyReduceMotion() {
|
||||
if (reduceMotion) {
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
} else if (videoEverOn && $("visual").checked) {
|
||||
playLoop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Make `autoScrub` instant under reduced motion**
|
||||
|
||||
At the top of `autoScrub` (`app.js:678`), after the `if (!ring …) return;` guard, add:
|
||||
|
||||
```js
|
||||
if (isReduced()) { // no autonomous tween — jump straight to the target
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
setPos(targetPos);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Keep playback paused when reduced-motion is on after landing**
|
||||
|
||||
In `beginExperience()` (`app.js:1250`) and wherever `playLoop()` is called on a settle, guard the play with `if (!isReduced()) playLoop();` — specifically wrap the settle-time `playLoop()` at `app.js:850` and the `beginExperience` start so turning the experience on while reduced does not animate. (Inspect each `playLoop()` call site; guard the autonomous ones, leave the explicit toggle-off resume in `applyReduceMotion`.)
|
||||
|
||||
- [ ] **Step 8: Call `initReduceMotion()` in `main()`**
|
||||
|
||||
After `initLanguage();` (`app.js:1266`) add:
|
||||
|
||||
```js
|
||||
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the reduced-motion test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/i18n.js simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): reduced-motion freeze-to-stills with OS-default toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Keyboard + ARIA for the Altitude dial
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html:56` (dial svg attrs)
|
||||
- Modify: `simulator/static/app.js` (keydown handler, aria-value sync, label buttons)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `autoScrub`, `jumpToScale`, `ring`, `ringIndex`, `dialStep` (all in `app.js`).
|
||||
- Produces: `setDialAria()` updates `aria-valuenow/valuetext`; called wherever the needle settles.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("altitude dial is keyboard-operable", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const dial = page.locator("#dial");
|
||||
await expect(dial).toHaveAttribute("role", "slider");
|
||||
await dial.focus();
|
||||
const before = await page.locator("#scale-name").textContent();
|
||||
await dial.press("ArrowDown"); // descend one altitude
|
||||
await expect.poll(async () => page.locator("#scale-name").textContent()).not.toBe(before);
|
||||
await dial.press("Home"); // jump to cosmos (top)
|
||||
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
|
||||
Expected: FAIL (dial has no `role`/key handling).
|
||||
|
||||
- [ ] **Step 3: Add ARIA attrs to the dial in `index.html`**
|
||||
|
||||
Change line 56 to:
|
||||
|
||||
```html
|
||||
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
|
||||
aria-label="Altitude — turn to change scale"
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `setDialAria()` and call it on settle**
|
||||
|
||||
In `app.js`, add:
|
||||
|
||||
```js
|
||||
// Reflect the committed altitude into the dial's slider semantics for AT.
|
||||
function setDialAria() {
|
||||
if (!dial || !ring) return;
|
||||
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
|
||||
dial.setAttribute("aria-valuenow", String(ringIndex));
|
||||
const s = ring.scales[ringIndex];
|
||||
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
|
||||
}
|
||||
```
|
||||
|
||||
Call `setDialAria()` at the settle point in `setPos` where `setNeedle(ringIndex * dialStep())` runs on frac 0 (`app.js:852`), and once in `buildDial()` after the dial is drawn.
|
||||
|
||||
- [ ] **Step 5: Add the keydown handler**
|
||||
|
||||
```js
|
||||
function onDialKey(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break;
|
||||
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break;
|
||||
case "Home": jumpToScale(0); break;
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
}
|
||||
```
|
||||
|
||||
Wire it in `main()` next to the other dial listeners (`app.js:1289`):
|
||||
|
||||
```js
|
||||
dial.addEventListener("keydown", onDialKey);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Make the dial labels keyboard-activatable**
|
||||
|
||||
In `buildDial()` (where each `.dial-label` is created, ~`app.js:749`), add to the label's attributes: `role: "button"`, `tabindex: "0"`, and an `aria-label` of the scale name. Then in `main()` add a delegated keydown on the dial that activates a focused label:
|
||||
|
||||
```js
|
||||
dial.addEventListener("keydown", (e) => {
|
||||
const t = e.target;
|
||||
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
jumpToScale(+t.getAttribute("data-index"));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the keyboard test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): keyboard + ARIA slider semantics for the Altitude dial"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Screen-reader narration (aria-live) + hide decorative SVG
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (aria-live region; `aria-hidden` on decorative layers)
|
||||
- Modify: `simulator/static/app.js` (announce on settle, throttled)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `activeClipId`, the clip's resolved strings, `ring`, `ringIndex`.
|
||||
- Produces: `announce(text)`; `decorative SVGs are aria-hidden`.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("an aria-live region narrates the current scale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const live = page.locator("#sr-status");
|
||||
await expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("Home");
|
||||
await expect.poll(async () => (await live.textContent())?.toLowerCase()).toContain("cosmos");
|
||||
});
|
||||
|
||||
test("decorative overlay SVGs are hidden from AT", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Add the live region + aria-hidden in `index.html`**
|
||||
|
||||
Add `aria-hidden="true"` to `#overlay`, `#affect`, `#tint`, `#paint`. Add inside `.panel` (top, after the opening tag) a live region:
|
||||
|
||||
```html
|
||||
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `announce()` + call on settle in `app.js`**
|
||||
|
||||
```js
|
||||
let lastAnnounced = "";
|
||||
function announce(text) {
|
||||
const el = $("sr-status");
|
||||
if (!el || !text || text === lastAnnounced) return;
|
||||
lastAnnounced = text;
|
||||
el.textContent = text;
|
||||
}
|
||||
// Build the spoken summary: scale + top factual (left) label for the active clip.
|
||||
function announceState() {
|
||||
if (!ring) return;
|
||||
const s = ring.scales[ringIndex];
|
||||
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
|
||||
const clip = activeClip && activeClip(); // use existing accessor for the locked clip
|
||||
let label = "";
|
||||
if (clip) {
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
label = firstFactualLabel(strings) || "";
|
||||
}
|
||||
announce(label ? `${scaleName}. ${label}` : scaleName);
|
||||
}
|
||||
```
|
||||
|
||||
If no `activeClip()` accessor exists, read the locked clip via the existing `activeClipId` lookup used in `renderScaleReadout` (mirror that code). `firstFactualLabel` returns the first non-empty left/`LABELS`-style string from `strings`; if the shape makes this awkward, announce just `scaleName` (degrade gracefully — the scale name is the load-bearing part).
|
||||
|
||||
Call `announceState()` at the same settle point as `setDialAria()` (frac 0 in `setPos`) and at the end of `setLanguage()` so a language switch re-announces.
|
||||
|
||||
- [ ] **Step 5: Run the narration tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): aria-live narration of scale/label; hide decorative SVGs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Apply the flash audit to real transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` and/or `simulator/static/style.css` (clamp the audited transitions)
|
||||
- Modify: `simulator/static/index.html` (load `flash.js` before `app.js`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFFlash.clampDurationMs` (Task 3).
|
||||
|
||||
- [ ] **Step 1: Load `flash.js` in `index.html`**
|
||||
|
||||
Add before `app.js` (after `i18n.js`, `index.html:117`):
|
||||
|
||||
```html
|
||||
<script src="flash.js"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Audit + enumerate the rapid-luminance transitions**
|
||||
|
||||
Inspect the three sources and record findings as a comment in `app.js` above the change:
|
||||
1. Fast-spin blended dial pass — `autoScrub` total `ms = perAltMs * |dist|`; the per-altitude floor is 1200ms (well under 3/sec for one step), so a multi-step spin is already ≥3 transitions over ≥N×1.2s = safe. Confirm and note.
|
||||
2. `#black` cover fade — `style.css` `transition: opacity 200ms`; a single fade is one transition, not a repeated flash — safe.
|
||||
3. Audio-coupled crossfade — visual? If it drives an opacity swap, check its duration.
|
||||
|
||||
For any source that CAN repeat faster than 3/sec (e.g. a rapid wheel/keyboard repeat firing `autoScrub` back-to-back), clamp using `HEFFlash`:
|
||||
|
||||
```js
|
||||
// Guard against a fast key/wheel repeat producing >3 luminance swings/sec.
|
||||
const safeMs = HEFFlash.clampDurationMs(PER_ALTITUDE_MS, 1);
|
||||
```
|
||||
|
||||
If the audit finds NO breach (the likely outcome given the 1200ms floor), record that conclusion in the comment and make no timing change beyond loading `flash.js` for the helper's availability — do not invent a clamp that isn't needed (YAGNI). The deliverable of this task is the documented audit + `flash.js` wired in.
|
||||
|
||||
- [ ] **Step 3: Run existing e2e to confirm no regression**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts`
|
||||
Expected: PASS (12 tests, per project memory).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js
|
||||
git commit -m "feat(a11y): wire flash-clamp helper; document 3-flash/sec audit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: about.html + header link + static-build copy
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/about.html`
|
||||
- Modify: `simulator/static/index.html:18` (header link)
|
||||
- Modify: `simulator/static/i18n.js` (about link label)
|
||||
- Modify: `tools/build_static.py:38` (add `about.html`, `flash.js` to `PUBLIC_ASSETS`)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `.credits-page` / `.credits-wrap` / `.back-link` styles.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("about page loads and links back to the experience", async ({ page }) => {
|
||||
await page.goto("/about.html");
|
||||
await expect(page.locator("h1")).toContainText(/about/i);
|
||||
await expect(page.getByText(/imperfect/i)).toBeVisible();
|
||||
await page.locator(".back-link").click();
|
||||
await expect(page).toHaveURL(/index\.html|\/$/);
|
||||
});
|
||||
|
||||
test("header links to the about page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('a[href="about.html"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
|
||||
Expected: FAIL (no about.html / link).
|
||||
|
||||
- [ ] **Step 3: Create `about.html`**
|
||||
|
||||
Mirror `credits.html` structure exactly (body `class="credits-page"`, `main.credits-wrap`, back-link, `config.js`):
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>About — Human Experience Filter</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>About this work</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>For everyone</h2>
|
||||
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
|
||||
representative of — the full range of human experience. That is why it works
|
||||
with a keyboard, with a screen reader, with reduced motion, and away from any
|
||||
single screen or kiosk: an experience about being human should be open to as
|
||||
many humans as possible.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Places technology let us see</h2>
|
||||
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
|
||||
of the Earth, the wider cosmos — are vantage points no unaided human could
|
||||
ever witness. We can share them only because our tools evolved to reach them:
|
||||
from <strong>underwater exploration</strong>, to <strong>drones and aerial
|
||||
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
|
||||
a machine went first so that a person could feel what it is like to be there.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Built with LLMs</h2>
|
||||
<p>This piece was itself built using large language models — an extension of
|
||||
that same long arc of tools that widen what a single human can reach. The
|
||||
software, the words, and much of the craft were shaped in collaboration with
|
||||
a machine.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Honest about its limits</h2>
|
||||
<p>This implementation is imperfect. It is also more than I could have made
|
||||
alone: better with these tools assisting than by my hand as a single human.
|
||||
That tension is part of the point — technology does not replace the person
|
||||
behind the work; it extends their reach, flaws and all.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the header link in `index.html`**
|
||||
|
||||
After the existing credits link (`index.html:18`), add:
|
||||
|
||||
```html
|
||||
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the i18n label in `i18n.js`**
|
||||
|
||||
```js
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the new files to the static build**
|
||||
|
||||
In `tools/build_static.py:38`, extend `PUBLIC_ASSETS`:
|
||||
|
||||
```python
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
|
||||
"credits.html", "credits.js", "about.html", "flash.js"]
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the about-page tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Verify the static build includes the new files**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test static-build.spec.ts` (or run `python3 tools/build_static.py` and confirm `dist/.../about.html` + `flash.js` exist).
|
||||
Expected: PASS / files present.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/about.html simulator/static/index.html simulator/static/i18n.js tools/build_static.py simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(content): about.html — intent, provenance, honest limits; static-build copy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Full-suite green + finish
|
||||
|
||||
**Files:** none (verification).
|
||||
|
||||
- [ ] **Step 1: Run the full e2e a11y spec**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Run the node helper tests**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run the existing suites that should stay green**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts i18n.spec.ts static-build.spec.ts`
|
||||
Expected: PASS. (`loop-recovery.spec.ts` is known-red on a clean baseline here — do not block on it, but confirm it is no MORE broken.)
|
||||
Run: `python3 -m pytest` from repo root for the Python suite.
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Final review + branch finish**
|
||||
|
||||
Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT auto-merge to main without the operator's go (per session posture).
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** A1 reduced-motion → Task 5; A2 gate → Task 4, audit → Tasks 3+8; B keyboard/focus → Tasks 2+6; C contrast → Task 2; D SR narration → Task 7; E about.html → Task 9; F layout → Task 1. `<html lang>` correctly omitted (already done). All covered.
|
||||
|
||||
**Placeholder scan:** Code shown for every code step. Task 7's `firstFactualLabel`/`activeClip` note explicitly degrades to scale-name-only if the clip-strings shape is awkward — that is a defined fallback, not a placeholder. Task 8 explicitly allows a "no breach found → document and stop" outcome (YAGNI), which is a real deliverable.
|
||||
|
||||
**Type consistency:** `isReduced()`, `autoRaf`, `setPos`, `autoScrub`, `jumpToScale`, `setDialAria`, `announce`, `HEFFlash.clampDurationMs`, `WARN_KEY`/`RM_KEY` used consistently across tasks. `reduce-motion` element id matches between HTML (Task 5 Step 3) and JS (Task 5 Step 5) and test.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Accessibility pass + About page — Solution Design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Branch:** `feat/accessibility-pass` (off `design/cloudflare-static-publish`)
|
||||
**Status:** Draft — pending operator review
|
||||
|
||||
## Problem
|
||||
|
||||
The simulator was built for an attended **kiosk**: a known display, a person
|
||||
nearby, no assistive technology in the loop. The Cloudflare static publish
|
||||
(`design/cloudflare-static-publish`) puts the same experience on a public URL
|
||||
(`benstull.art`) where none of those assumptions hold. That raises two bars at
|
||||
once:
|
||||
|
||||
- **Legal/usability** — a public site is expected to meet WCAG 2.1 AA; today the
|
||||
primary navigation control (the Altitude dial) is pointer-only, several text
|
||||
colors fail contrast, and there is no screen-reader path into a piece whose
|
||||
*meaning* is already textual.
|
||||
- **Physical safety** — the piece is continuous motion (video morphs, auto-scrub,
|
||||
crossfades, a "trippy at max" WebGL shader, black-cover fades) with no
|
||||
`prefers-reduced-motion` support and no photosensitivity safeguard. Unattended,
|
||||
that is a vestibular and seizure risk.
|
||||
|
||||
This work makes the *experience* (not just the chrome) usable away from the
|
||||
kiosk, and adds an **About** page explaining what the piece is for.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Honor reduced-motion and add seizure safety (the public-web-specific layer).
|
||||
2. Make every control keyboard- and screen-reader-operable.
|
||||
3. Fix low-vision contrast.
|
||||
4. Add `about.html` — the project's intent and provenance.
|
||||
5. A small Output-panel layout fix (language picker placement).
|
||||
|
||||
**Non-goals:** no engine/Python changes; no new languages (about page is
|
||||
English-first, i18n-keyed for later); no RTL; no redesign of the visual art.
|
||||
The `<html lang>` switch is **already implemented** (`applyUiStrings`,
|
||||
`app.js:1325`) and is out of scope.
|
||||
|
||||
## Target
|
||||
|
||||
WCAG 2.1 **AA** as the baseline, plus a motion/seizure layer (WCAG 2.3.1 flash
|
||||
threshold + 2.3.3 animation-from-interactions) on top, since those are the risks
|
||||
that genuinely change when the piece leaves the kiosk.
|
||||
|
||||
## Design
|
||||
|
||||
All changes are client-side, in `simulator/static/`. Seven units.
|
||||
|
||||
### A1. Reduced-motion: freeze-to-stills
|
||||
|
||||
A single `reduceMotion` state, default-on when
|
||||
`window.matchMedia('(prefers-reduced-motion: reduce)').matches`, plus a visible
|
||||
toggle in the Output fieldset (so a visitor whose OS setting disagrees can
|
||||
override either way). Persisted in `localStorage` (mirrors the existing `devMode`
|
||||
pattern, `app.js:934`).
|
||||
|
||||
When `reduceMotion` is **on**:
|
||||
|
||||
- **Video holds a frame.** Pause `#vid` / `#vid-loop` (do not call `playLoop()` /
|
||||
the `.play()` paths at `app.js:260,288`). The Kuwahara paint loop keeps running
|
||||
but composites a *static* frame, so knob changes (mood grade, dream, labels)
|
||||
still re-render — the image responds, it just doesn't animate on its own.
|
||||
- **Transitions are instant, not animated.** `autoScrub()` (`app.js:678`) jumps
|
||||
`pos` straight to the target (one assignment + a single settle render) instead
|
||||
of driving the rAF tween. Dial drag still scrubs live under the finger (that is
|
||||
a direct-manipulation gesture, not autonomous motion — allowed under 2.3.3),
|
||||
but on release it settles without a spin.
|
||||
- **Auto-scrub speed coupling is disabled** (the constant per-altitude auto-spin).
|
||||
|
||||
The toggle flips state live (no reload): turning it off resumes `playLoop()`;
|
||||
turning it on pauses and holds.
|
||||
|
||||
### A2. Photosensitivity: warning gate + flash audit
|
||||
|
||||
- **Warning gate.** A one-time interstitial over the stage, shown with the
|
||||
existing `#run-sim` flow before the experience begins: a short "contains motion
|
||||
and flashing effects" notice with a **Continue** action. Dismissal is
|
||||
remembered in `localStorage` so it shows once per visitor, not every load. It
|
||||
reuses the run-sim z-layer (above the black cover) and does not block the rest
|
||||
of the page (controls remain reachable).
|
||||
- **Flash audit.** Review the three motion sources that can produce rapid
|
||||
luminance swings — the fast-spin blended dial pass, the `#black` cover fades
|
||||
(`app.js`/`style.css`), and audio-coupled crossfades — and clamp any that can
|
||||
exceed **3 transitions/second** (WCAG 2.3.1). The clamp math (min transition
|
||||
duration given a luminance delta) is a **pure function** in a small module so it
|
||||
is unit-testable; the audit findings and any clamps are recorded in the
|
||||
implementation plan.
|
||||
|
||||
### B. Keyboard + focus
|
||||
|
||||
- **Dial as a real slider.** The `#dial` SVG gets `role="slider"`, `tabindex="0"`,
|
||||
and live `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
|
||||
(the human scale name, e.g. "reef"). A `keydown` handler maps:
|
||||
- `ArrowDown` / `ArrowRight` → descend one altitude (`+1`, matching wheel-down).
|
||||
- `ArrowUp` / `ArrowLeft` → ascend one altitude (`-1`).
|
||||
- `Home` → cosmos (index 0); `End` → the deepest scale.
|
||||
- Each step calls the existing `autoScrub` / `jumpToScale` path (so reduced-motion
|
||||
instant-jump is inherited for free).
|
||||
- **Dial labels become buttons.** The tap-to-jump `.dial-label` nodes get
|
||||
`role="button"` + `tabindex="0"` + Enter/Space activation, reusing `jumpToScale`.
|
||||
- **Visible focus everywhere.** A global `:focus-visible` outline rule (today only
|
||||
`.dev-switch` has one).
|
||||
|
||||
### C. Low-vision contrast
|
||||
|
||||
Bump the failing dark-on-dark text to meet AA (4.5:1 for body, 3:1 for large):
|
||||
the panel `.hint` (`#789`), `.dial-caption` (`#4d6184`), `.dial-label` (`#789ac0`),
|
||||
and any others a contrast check flags on the `#111` / `#0d1320` backgrounds.
|
||||
Verify the 280px panel reflows and text scales to 200% without clipping.
|
||||
|
||||
### D. Screen-reader basics
|
||||
|
||||
- Ensure every control has an accessible name (sliders via their `<label>`; the
|
||||
dial via `aria-valuetext`; the new toggle labeled).
|
||||
- **Narrate the alteration.** A visually-hidden `aria-live="polite"` region
|
||||
announces the current scale and the active clip's top factual (left-brain)
|
||||
label as the altitude/knobs change — turning the visual alteration into words.
|
||||
Updates are throttled/debounced so a drag doesn't flood the queue.
|
||||
- The decorative HUD/affect SVG layers get `aria-hidden="true"`.
|
||||
|
||||
### E. about.html
|
||||
|
||||
A standalone page mirroring `credits.html` exactly (same `credits-page` /
|
||||
`credits-wrap` styles, a `← Back to the experience` link, `config.js` loaded).
|
||||
English prose, structured so it can be i18n-keyed later. Linked from the header
|
||||
beside the existing Credits link (`index.html:18`). Narrative beats:
|
||||
|
||||
- **For everyone.** The piece aims to be accessible to, and representative of,
|
||||
the full range of human experience — which is *why* this accessibility work
|
||||
exists.
|
||||
- **Vantage points technology gave us.** The scales — deep-sea, coast, sky,
|
||||
drone/aerial, orbit, cosmos — are places no unaided human could witness. We can
|
||||
share them only because technology evolved to reach them: **underwater
|
||||
exploration → drones → space exploration.**
|
||||
- **Built with LLMs.** The work itself was built using large language models — an
|
||||
extension of that same arc of tools extending human reach.
|
||||
- **Honest about its limits.** This implementation is **imperfect** — but it is
|
||||
more than the author could make alone; better *with* LLMs assisting than by a
|
||||
single human hand. The imperfection is part of the point: tools extend us, they
|
||||
don't replace the human behind them.
|
||||
|
||||
### F. Layout fix (Output fieldset)
|
||||
|
||||
Move the language picker **below** the Audio control (currently it sits above
|
||||
Video/Audio). Put the globe `🌐` inline to the **left** of the `<select>` via a
|
||||
flex row — today `.lang-pick` has no CSS, so the full-width `select` rule
|
||||
(`style.css` `select { width: 100% }`) pushes the globe onto its own line above.
|
||||
Add a `.lang-pick { display:flex; align-items:center; gap }` rule and let the
|
||||
select flex to fill the rest.
|
||||
|
||||
## Components / files
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `index.html` | Reduced-motion toggle; warning-gate markup; aria-live region; dial a11y attrs; move lang picker below Audio; about-page header link |
|
||||
| `style.css` | `.lang-pick` flex; contrast bumps; `:focus-visible`; visually-hidden util; warning-gate styles |
|
||||
| `app.js` | `reduceMotion` state + freeze logic; dial keyboard handler + aria-value sync; label buttons; aria-live narration; gate dismissal |
|
||||
| `flash.js` *(new, pure)* | Flash-clamp helper (min duration given luminance delta), UMD like `i18n.js`/`credits.js` |
|
||||
| `i18n.js` | New UI keys for the toggle, warning gate, about link (EN populated; other langs may fall back) |
|
||||
| `about.html` *(new)* | The page above |
|
||||
| `tools/build_static.py` | Ensure `about.html` + `flash.js` are copied into `dist/` (verify the static build includes new static files) |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Node unit tests** for the pure flash-clamp helper (`flash.js`) — boundary
|
||||
cases around the 3/sec threshold.
|
||||
- **Playwright e2e** (extends the existing suite): reduced-motion toggle pauses
|
||||
video and makes a dial step instant; dial is keyboard-focusable and
|
||||
Arrow/Home/End change the scale; the warning gate appears and dismisses once;
|
||||
`about.html` loads and its back-link returns to `index.html`; the language
|
||||
picker renders below Audio with the globe inline.
|
||||
- **Manual/automated contrast check** on the bumped colors.
|
||||
- Existing suites stay green: the current Playwright specs, node `--test`, and
|
||||
`pytest`.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Freeze-to-stills + WebGL.** Need to confirm the paint loop still composites a
|
||||
paused-video frame (some browsers stop delivering frames to WebGL from a paused
|
||||
`<video>`); fallback is to draw the last frame once to the canvas and stop the
|
||||
loop. Resolved during implementation.
|
||||
- **Flash audit is the fuzziest unit** — the clamp is mechanical, but deciding
|
||||
which existing transitions actually breach 3/sec needs measurement; the plan
|
||||
will enumerate them explicitly rather than hand-wave.
|
||||
- E2E in this environment starts uvicorn via `python` (only `python3` exists) —
|
||||
start the server by hand and use `reuseExistingServer`, per project memory.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session 0027.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-29T22-04 (PST)
|
||||
> End: 2026-06-30T06-47 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Add left and right brain annotations (including localization) for any that are missing.
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: operator direct instruction (standalone leaf content-fill task, §4.3 R2b).
|
||||
|
||||
**Goal:** Author the missing **left-brain** factual `LABELS` (tiered general→scientific→+fact
|
||||
labels) for the rotating-pool clips that lack them, plus their **es/fr/ja** translations,
|
||||
then rebuild + merge the manifest. Right-brain `affect` is auto-generated per scale for
|
||||
every clip via `_affect_for_clip` — nothing missing there.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- `main` at `5beee55` locally but **diverged from `origin/main`**: 11 merged-but-unpushed
|
||||
commits (the entire i18n localization feature + a video-loop safety-net fix) stranded
|
||||
locally; `origin/main` still at `eff43bd` (PR #29). Plus 3 files with an uncommitted
|
||||
"boots-silent / no auto-start" change. Two stale `--INPROGRESS` placeholders (0015, 0017).
|
||||
- Annotation reality (from a read-only survey of `build_pool_manifest.py` + catalogs):
|
||||
right-brain `affect` present on all 41 clips (auto-per-scale); left-brain `LABELS`
|
||||
present on only 13 of 41. 28 pool clips had `"annotations": []`.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Scoped the task** — explored the annotation/i18n system; established that the gap was
|
||||
left-brain only (right-brain is auto-generated), 28 clips, + their es/fr/ja strings.
|
||||
2. **Claimed session 0027** (peek showed the two stale placeholders; noted + proceeded).
|
||||
3. **Surfaced the unclean baseline**; operator chose *"Push stranded, also commit dirty."*
|
||||
- Stashed the 3 dirty files, merged `origin/main`'s claim commit, **pushed the 11
|
||||
stranded commits** (`020219f`).
|
||||
- Committed the boots-silent change on `fix/boot-silent-no-autostart`, verified
|
||||
(299 pytest pass), **merged + pushed** (`7bd6c9a`).
|
||||
4. **Annotation work** on `feat/left-brain-annotations`:
|
||||
- Added 28 `LABELS` entries (static tiered labels) to `build_pool_manifest.py`
|
||||
(Round-5 block); rebuilt manifest → all 41 clips annotated; extracted en catalog.
|
||||
- Dispatched **3 parallel translator subagents** (es/fr/ja) for the 67 new keys,
|
||||
matching existing catalog voice; fixed 2 ja `measure.*` values to the `約`/localized
|
||||
convention; merged fragments into catalogs; merged all 3 langs into the manifest.
|
||||
- Verified: 41 clips × en/es/fr/ja full key + tier-length parity; 299 pytest pass
|
||||
(3 pre-existing `cv2` env failures only); 37 i18n/manifest tests green; spot-checked
|
||||
translations. **Merged + pushed** (`934f60c`), deleted both branches.
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` == `origin/main` @ `934f60c`, clean tree, branches removed.
|
||||
- 41/41 clips carry left+right annotations with full en/es/fr/ja parity.
|
||||
- §9: local tests green. No PPE/E2E machinery exists for this app yet (pipeline is policy,
|
||||
not automated — §10.6); prod promotion stays operator-gated. Visual review deferred to
|
||||
operator (no Chrome on box).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- **Review by eye** the 28 newly-labelled clips across all 4 languages in the running sim.
|
||||
- The labels are **static**; the abyss/reef *creature* clips would benefit from a later
|
||||
**tracked-label** upgrade via author mode (couldn't auto-file as a tracker issue — the
|
||||
capture token returned 401 in this env; full context is in the session-0027 memory note).
|
||||
- A few species IDs were inferred from clip META, not the frame (`reef_redsea`→anthias,
|
||||
`reef_flowergarden`→jack/scad+sergeant-major) — worth confirming at review.
|
||||
- Two stale `--INPROGRESS` sessions (0015, 0017) remain unfinalized — likely the crashed
|
||||
sessions that stranded the 11 commits; left untouched for the operator.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Review the new left-brain annotations (all 28 newly-labelled pool clips, across en/es/fr/ja) by eye in the running simulator; correct any species/box that reads wrong, and decide whether the abyss/reef creature clips should get tracked labels via author mode.
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Static labels, not tracked.** Authored all 28 clips' left-brain labels as fixed-box
|
||||
`static_label`s rather than motion-tracked labels. Alternative: tracked labels (as the
|
||||
reef/abyss showcase clips use) so the box follows drifting creatures. Chose static because
|
||||
the footage wasn't eyeballed — fabricating track coordinates would claim precision I don't
|
||||
have. The abyss/reef creature clips are the ones that would most benefit from a later
|
||||
tracked-label upgrade via author mode.
|
||||
- **Some species IDs inferred from clip metadata, not the footage.** `reef_redsea`→anthias,
|
||||
`reef_flowergarden`→jack/scad + sergeant-major — read from the clip META title/source.
|
||||
High-confidence from provenance, but worth an eyeball check.
|
||||
- **`O-type` kept verbatim in es/fr/ja** (per the translation brief) rather than the more
|
||||
natural `tipo O` / `type O`. Minor; flagged by the es translator.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Session 0027.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-29T22-04 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0027 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Add left and right brain annotations (including localization) for any that are missing.
|
||||
|
||||
Resolved scope: right-brain `affect` is auto-generated per scale for every clip (nothing missing). The gap is left-brain `LABELS` (factual/scientific tiered labels) on 28 rotating-pool clips that have no entry in simulator/build_pool_manifest.py, plus their es/fr/ja translations in tools/i18n/catalogs. Author them following the existing static_label pattern, rebuild the manifest, merge translations, run tests.
|
||||
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,125 @@
|
||||
# Session 0028.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T06-53 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: d9da7994-e036-4b7c-b2d4-d2675ac4e5a1
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **FINALIZED.**
|
||||
> Worktree: `worktree-session-0028` (isolated; concurrent session in flight). Torn down at finalize.
|
||||
> Outcome: D3 reverse-landing frame jump FIXED, app-side. PR #30 → main (`582183d`).
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal next` — operator then directed: "There's a concurrent session — do this one out
|
||||
of a worktree in a different dir." Session 0028 runs isolated in worktree
|
||||
`worktree-session-0028` while a concurrent session owns the simulator startup ("Run
|
||||
simulation" button: `index.html`/`style.css`/`i18n.js`).
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf bug — D3 reverse-landing frame jump (no tracker issue; the s0027 capture
|
||||
> token returned 401. Residual flagged in the altitude-morph-and-scrub memory note +
|
||||
> scrub-driven-transitions design.)
|
||||
|
||||
**Goal:** kill the ~3-second frame jump when ASCENDING the altitude dial and landing on
|
||||
the higher-altitude clip.
|
||||
|
||||
**Root cause (verified by reading the bake + player):** each baked morph
|
||||
`transitions/{h}__{l}.mp4` spans `src(h)@0s` (frac 0) → `dst(l)@~LOOP_TAIL_S` (frac 1),
|
||||
and the scrub uses this single forward file in BOTH directions. The steady loop element
|
||||
always seeks to `LOOP_TAIL_S` (3.0s) on landing. So:
|
||||
- DESCEND (dir>0) lands on `dst` at frac 1 → morph ends `dst@3` ≈ loop@3 → seamless ✓
|
||||
(the s0026 "loop-from-tail" fix).
|
||||
- ASCEND (dir<0) lands on `src` at frac 0 → morph shows `src@0`, but loop seeks `src@3`
|
||||
→ ~3s jump ✗ (D3).
|
||||
|
||||
**Fix (app-side, no media re-bake — beats re-baking 154 LFS morphs):** make the loop
|
||||
landing frame DIRECTION-AWARE. On a reverse landing seek the loop to 0 (= the morph's
|
||||
frac-0 frame) and wrap `[0,dur]` (the base is already a seamless crossfade-loop); forward
|
||||
keeps `[LOOP_TAIL_S,dur]`. Pure decision extracted to `scrub.js loopLandFrame(dir,
|
||||
loopTailS)` + node tests; wired through `loadLoop`/settle/wrap handlers in `app.js`.
|
||||
|
||||
**Tasks:**
|
||||
1. TDD `HEFScrub.loopLandFrame(dir, loopTailS)` in `scrub.js` (+ `scrub.test.js`).
|
||||
2. Wire `app.js`: `loadLoop(clip, landFrame)`, directional settle seek, `dataset.loopStart`
|
||||
in the `timeupdate`/`ended` wrap handlers.
|
||||
3. Add a reverse-landing e2e (mirror of the existing forward "loops from tail" test).
|
||||
4. Verify: node unit tests + pytest + e2e (best-effort) all green.
|
||||
5. Branch → PR → merge (yolo). PPE/E2E machinery not built for this app (§10.6) — local
|
||||
green is the bar; prod promotion stays operator-gated.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
|
||||
- **Fix shape: app-side direction-aware loop landing, NOT a 154-morph re-bake.** The
|
||||
s0026 memory note guessed the D3 fix would be "per-clip loop files." Reading the bake +
|
||||
player showed the root cause is simpler: the morph's frac-0 frame is `src@0` but the
|
||||
loop always seeked the tail (`LOOP_TAIL_S`). Fixed entirely in `app.js`/`scrub.js` by
|
||||
landing the loop on the morph's actual frac-0 frame (0) when ascending. This avoids
|
||||
re-baking 154 LFS morphs (no media churn, a tiny reviewable diff, the base clips are
|
||||
already seamless crossfade-loops so `[0,dur]` wraps cleanly). Alternative considered:
|
||||
re-bake morphs to start at `src@LOOP_TAIL_S` — heavier, no benefit.
|
||||
- **Pre-existing e2e failure left as-is:** `loop-recovery.spec.ts` fails in this
|
||||
environment on the CLEAN baseline too (verified by stashing my changes). It boots
|
||||
without enabling video, so the loop's `loopTail` is never armed and the `ended` guard
|
||||
returns early. Not introduced here; my change preserves that guard exactly. Worth a
|
||||
separate look (the test likely needs to `enableVideo` first), but out of scope for D3.
|
||||
- **Could not verify by eye.** The fix is logic- and test-verified (node + the new
|
||||
reverse-landing e2e asserting the loop anchors at the head, not the tail); the actual
|
||||
visual seamlessness of an ascending landing still wants an operator eyeball in the
|
||||
running sim.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Claimed session **0028** (race-free, via git). Two stale `--INPROGRESS` placeholders
|
||||
(0015, 0017) noted as orphaned, left untouched.
|
||||
2. Baseline survey surfaced uncommitted `run-sim` button edits + main behind by 2
|
||||
(session commits). Operator confirmed a **concurrent session** owns that startup work
|
||||
and directed isolation → created worktree `worktree-session-0028` off `origin/main`,
|
||||
leaving the concurrent session's edits in the canonical clone untouched.
|
||||
3. Orientation: in-repo `docs/ROADMAP.md` is stale (formal frontier = deferred hardware);
|
||||
live frontier is in memory. Stored `/goal next` (0027's by-eye annotation review) is
|
||||
operator-eyes work I can't complete; the run-sim button is the concurrent session's.
|
||||
Asked the operator to pick 0028's disjoint item → **D3 reverse-landing fix**.
|
||||
4. Read the bake (`build_pool_manifest.transition_cmd`) + player (`app.js` loop/morph) to
|
||||
pin the root cause (morph spans `src@0`→`dst@tail`; loop always seeks the tail).
|
||||
5. TDD: `HEFScrub.loopLandFrame` (red → green), then wired `app.js`
|
||||
(`loadLoop(clip,landFrame)`, `dataset.loopStart`, directional settle, wrap handlers).
|
||||
6. Added a reverse-landing e2e. Verified: node 16/16, **altitude-lock e2e 12/12** (forward
|
||||
no-regression + new reverse test), pytest 299 pass. Confirmed `loop-recovery.spec.ts`
|
||||
fails on the clean baseline too (pre-existing, not mine) by stashing.
|
||||
7. Committed → PR #30 → merged to `main` (`582183d`) → deleted branch. Updated memory.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **On `main` (`582183d`):** D3 fix — `simulator/static/scrub.js` (+`loopLandFrame`),
|
||||
`simulator/static/app.js` (direction-aware loop landing), `simulator/unit/scrub.test.js`,
|
||||
`simulator/e2e/tests/altitude-lock.spec.ts` (reverse-landing test).
|
||||
- **Tests:** node 16/16; altitude-lock e2e 12/12; pytest 299 pass / 3 pre-existing
|
||||
env-only failures (real-ffprobe/ffmpeg + ML detect; untouched — no Python changed) / 4
|
||||
skipped. `loop-recovery.spec.ts` is a pre-existing baseline failure in this env.
|
||||
- **§9 / deploy:** this whole project is **exempt** from flotilla/PPE/§9 (memory:
|
||||
project-exempt-from-wiggleverse-deploy; public target is static Cloudflare). Local-green
|
||||
is the bar — met. No PPE stage to run.
|
||||
- **No plan artifact archived:** fused leaf fix, plan inline in this transcript; app has
|
||||
**no content repo** (`CONTENT_REMOTE` empty) — nothing to archive.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- **Review by eye** an ASCENDING altitude landing in the running sim — confirm the
|
||||
~3s reverse-landing jump is gone (logic/test-verified only; no Chrome on box here).
|
||||
- Still standing from s0027: by-eye review of the 28 left-brain annotations (4 langs);
|
||||
decide on tracked labels for abyss/reef creature clips.
|
||||
- A concurrent session was shipping the "Run simulation" startup button — reconcile/land
|
||||
that separately (its edits are in the canonical clone, not touched here).
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Review by eye in the running simulator: (1) an ASCENDING altitude landing — confirm the D3 reverse-landing frame jump is gone; (2) the 28 left-brain annotations across en/es/fr/ja, correcting any species/box that reads wrong; then decide whether the abyss/reef creature clips should get tracked labels via author mode.
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session 0029.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T08-09 (PST)
|
||||
> End: 2026-06-30T08-41 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Branch: design/cloudflare-static-publish
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
"We need to make sure credits for Creative Commons licensed videos are compliant.
|
||||
What are some options?" → after presenting options the operator chose **Option A**
|
||||
(manifest-driven static credits/colophon page surfaced to the public) + **Option D1**
|
||||
(declare the altered footage CC BY-SA 4.0). Non-commercial.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `design/cloudflare-static-publish` (static-publish work, pushed). Working
|
||||
tree had uncommitted, unrelated in-flight work: a "Run simulation" start button
|
||||
(6 files) **plus** an `r2-cors.json` reformat (surfaced via stash, 7 total).
|
||||
- Per-clip `license`/`source` already in `clips.json` (baked from `/api/clips`) but
|
||||
only shown in the dev panel + internal review pages — public experience showed none.
|
||||
- A concurrent session was live in worktree `fix+audio-and-tracking-labels` (off main);
|
||||
the operator was also committing directly to `design/cloudflare-static-publish`.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Audited** the license surface: `tools/licensing.py`, `pipeline/provenance.py`,
|
||||
manifest, frontend. Found credits exist in data + review tooling but not the public
|
||||
experience → that's the gap. Presented options A–D; operator chose A + D1.
|
||||
2. **Claimed session 0029** (planning-and-executing, yolo). Two stale `--INPROGRESS`
|
||||
placeholders (0015, 0017) noted as long-dead, left alone.
|
||||
3. **Operator-requested pre-work:** the uncommitted in-flight work. Stashed it,
|
||||
created an isolated worktree (`.claude/worktrees/session-0029` off the branch HEAD,
|
||||
since the static work lives there not on main), applied the stash.
|
||||
- Discovered the branch had advanced under me to `8cc472c` (operator's direct
|
||||
commit). It **intentionally** keeps `r2-cors.json` in S3-array form alongside a
|
||||
new `r2-cors.wrangler.json` (runbook documents both) → **dropped** my conflicting
|
||||
`r2-cors.json` reformat commit; kept + rebased only the Run-simulation button.
|
||||
- Committed the button (`3a45e33`), ff-merged + pushed.
|
||||
4. **Built Option A + D1** (TDD): node test for the pure credits logic first (red) →
|
||||
`credits.js` (UMD: `creditEntries`/`creditsListHtml`, escaped, sorted) → `credits.html`
|
||||
colophon (D1 CC BY-SA declaration + "altered" note; JS-filled video attributions;
|
||||
PD/CC0 audio courtesy block; back link) → header "ⓘ Credits" link (`credits.link`
|
||||
i18n en/es/fr/ja) → `style.css` → `PUBLIC_ASSETS` in `build_static.py` + its test →
|
||||
Playwright e2e (`tests/test_e2e_credits.py`).
|
||||
5. **Verified:** node 28/28, pytest 315 passed / 2 skipped. Real build render: 41/41
|
||||
clips credited, all 7 CC-BY/BY-SA clips show required attribution, 0 missing.
|
||||
Committed (`1c7f2b7`), ff-merged + pushed, removed the worktree, deleted the branch.
|
||||
|
||||
## End state
|
||||
|
||||
- `design/cloudflare-static-publish` @ `1c7f2b7`, clean, pushed (origin in sync).
|
||||
- Public site now attribution-compliant: credits reachable from the work; altered-work
|
||||
license declared. §9 is **exempt** for this project (static Cloudflare target), so
|
||||
merged+pushed to the design branch is the completion state — no PPE/prod.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Dropped my `r2-cors.json` reformat** rather than merging it — concluded from the
|
||||
operator's `8cc472c` + runbook that the S3-array form is intentional. (Alternative:
|
||||
keep both forms / ask. Chose to honor the documented two-file split.)
|
||||
- **Audio credits as a static courtesy block** in `credits.html` rather than plumbing
|
||||
`audio_license` through the manifest/API. Rationale: audio is all PD/CC0 (no legal
|
||||
attribution duty) and not machine-readable; data-plumbing was out of scope. (Alternative:
|
||||
add audio license fields — deferred, low value for non-commercial PD/CC0.)
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal operator by-eye review of the credits colophon + Run-simulation button on design/cloudflare-static-publish, then the actual Cloudflare deploy (provision R2 human-experience-simulator-media + CORS, run tools.build_static, Pages deploy) per deploy/cloudflare/README.md
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# Session 0030.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T08-10 (PST)
|
||||
> End: 2026-06-30T08-50 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c4cec6c6-26d8-46f3-80ee-4ff4035a4f72
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Date: 2026-06-30
|
||||
> Goal: Add an obvious "Run simulation" start button to the simulator's main
|
||||
> screen (shown after the "Loading Universe…" preload); it turns Video on and
|
||||
> sets Audio to level 2. Turning Video on directly should also start the
|
||||
> experience, snap Audio to 2, and dismiss the button. Move the default
|
||||
> first-Video-on audio coupling from 3/10 → 2/10.
|
||||
> Outcome: Built + verified (303 pytest, 8 pytest-playwright incl. 3 new, 12
|
||||
> Playwright TS incl. 1 updated + 1 new, node unit tests green). Work committed
|
||||
> as 3a45e33 and pushed to design/cloudflare-static-publish — **the commit was
|
||||
> landed by a CONCURRENT session (0029)** that swept up this session's
|
||||
> working-tree changes while doing the CC-credits/Cloudflare-publish work.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Map the splash/loading flow and the Video/Audio toggle wiring (Explore agent
|
||||
+ direct reads of `simulator/static/{index.html,app.js,style.css,i18n.js}`).
|
||||
2. Add a `#run-sim` button inside the stage `.screen`, hidden until media
|
||||
preloads, styled to sit above the black cover (`z-index: 60`).
|
||||
3. Add a `beginExperience()` helper: Video on + audio snapped to a
|
||||
`START_AUDIO_LEVEL = "2"` + button dismissed + soundtrack played in-gesture
|
||||
(Safari unlock). Wire it to both the button click and the first direct
|
||||
Video-on (`!videoEverOn`).
|
||||
4. Reveal the button after `hideLoading()`; change the old 3/10 coupling → 2/10.
|
||||
5. Localize `run.button` in en/es/fr/ja (4-lang parity invariant).
|
||||
6. Update E2E coverage (pytest-playwright + Playwright TS) from the old
|
||||
"couples to 3" assertions to 2; add button-shown / button-starts /
|
||||
direct-video-on tests.
|
||||
7. Run the full suite + confirm a pre-existing failure is not mine.
|
||||
|
||||
## Pre-session state
|
||||
|
||||
- Branch `main` clean at `934f60c` (left-brain annotations merged). Simulator
|
||||
boots SILENT (video off, audio 0) and waited for the operator to flip the
|
||||
unlabelled **Video** toggle as the start gesture — not obvious to a first-time
|
||||
viewer. First-Video-on coupled audio to **3/10**.
|
||||
- No session-init gate was run at the top of this session — it was treated as a
|
||||
small, fully-specified frontend change (see Deferred decisions).
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Explore + direct reads.** Dispatched an `Explore` subagent to map the
|
||||
splash + AV-toggle code; in parallel read `index.html`, `app.js`
|
||||
(`main()`/`hideLoading()`/`beginExperience` region, the audio dial, the
|
||||
`videoEverOn` coupling), `style.css` (`#loading`, `.black z-index:50`,
|
||||
`.hidden`), and `i18n.js` (UI_STRINGS shape, 4 languages). Confirmed the
|
||||
stage `.screen` is `position: relative` and the black cover is `z-index: 50`,
|
||||
so a start button needs `z-index > 50` inside the screen and the side panel
|
||||
(Video toggle) stays reachable.
|
||||
2. **Implement.** Added `#run-sim` to the stage; `.run-sim` CSS (centered pill,
|
||||
`z-index: 60`); `run.button` i18n string (en/es/fr/ja); `beginExperience()`
|
||||
unifying both entry paths; revealed the button after `hideLoading()`;
|
||||
simplified the Video-toggle handler to call `beginExperience()` on first
|
||||
on; changed coupling 3 → 2.
|
||||
3. **Update tests.** Replaced the old `test_video_on_couples_audio_to_three…`
|
||||
(pytest) and the "couples audio to 3/10" Playwright TS test with 2/10
|
||||
variants; added button-shown-after-load, button-starts-video+audio, and
|
||||
direct-Video-on-dismisses-button cases on both harnesses.
|
||||
4. **Verify.** `303 passed, 2 skipped` (non-E2E pytest); `8 passed`
|
||||
pytest-playwright (incl. 3 new); `12 passed` Playwright `altitude-lock.spec`
|
||||
(incl. updated + new). Node unit tests (i18n / index-i18n / scrub) green; the
|
||||
i18n completeness invariant confirms `run.button` has all 4 langs.
|
||||
5. **Isolate a failure.** `loop-recovery.spec.ts` failed; stashed only the
|
||||
source files and re-ran on a clean baseline — it **fails identically without
|
||||
my changes**, so it's pre-existing, not introduced here. (During that stash
|
||||
round-trip a `test-results/.last-run.json` artifact briefly blocked
|
||||
`git stash pop`; recovered by `git checkout`-ing the artifact and popping —
|
||||
no source lost.)
|
||||
6. **Handed the working tree back uncommitted** for an operator by-eye review
|
||||
(the project's usual cadence — no Chrome on the box). Offered to branch + PR.
|
||||
|
||||
## Cut state (end of session)
|
||||
|
||||
| Item | State |
|
||||
| --- | --- |
|
||||
| Run-simulation button + audio-2 change | Committed `3a45e33` on `design/cloudflare-static-publish`, **pushed** |
|
||||
| Committed by | Concurrent **session 0029** (swept up this session's working-tree changes) |
|
||||
| Files | `simulator/static/{index.html,app.js,style.css,i18n.js}`, `simulator/e2e/tests/altitude-lock.spec.ts`, `tests/test_e2e_audio.py` |
|
||||
| This session's working tree | Clean; nothing of mine left to commit/push/merge |
|
||||
| Branch `design/cloudflare-static-publish` | Not merged to `main` — intentional; awaits operator review + Cloudflare deploy (session 0029's frontier) |
|
||||
| Deploy pipeline (§9) | **N/A** — project is exempt (static Cloudflare target; see memory `project-exempt-from-wiggleverse-deploy`) |
|
||||
| Verification | 303 pytest + 8 pytest-playwright + 12 Playwright TS + node units green; `loop-recovery.spec.ts` red **pre-existing** |
|
||||
|
||||
## What lands on the operator's plate
|
||||
|
||||
- **By-eye / by-ear review** of the Run-simulation button in a real browser —
|
||||
especially the **in-gesture audio unlock on real Safari/iOS** (headless tests
|
||||
relax autoplay, so it's wired-but-unconfirmed on a real device).
|
||||
- The button work lives on `design/cloudflare-static-publish` alongside the
|
||||
CC-credits + Cloudflare-publish work; it merges to `main` / ships when that
|
||||
branch does (session 0029's frontier), not separately.
|
||||
- Note the concurrency: this session authored the change; session 0029 committed
|
||||
it. Two transcripts (0029, 0030) touch the same single commit `3a45e33` —
|
||||
expected, not a duplicate of the commit.
|
||||
|
||||
## Prompt the operator can paste into the next session
|
||||
|
||||
```
|
||||
/goal operator eyeball review of the static build (incl. the new Run-simulation button) + execute the real Cloudflare deploy to benstull.art/human-experience-simulator, per the cloudflare-static-publish design on branch design/cloudflare-static-publish
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls; surfaced at finalize._
|
||||
|
||||
1. **No session-init gate at the top.** Treated the task as a small,
|
||||
fully-specified frontend tweak and implemented directly without claiming a
|
||||
transcript first. Consequence: the work landed on a shared checkout and was
|
||||
committed by a concurrent session (0029) rather than carrying its own
|
||||
end-to-end branch→PR. Alternative: claim a session + isolated worktree up
|
||||
front. Flagged because shared-checkout work is what the worktree rule guards
|
||||
against.
|
||||
2. **`beginExperience()` snaps audio to 2 unconditionally** on first Video-on,
|
||||
overriding a viewer who pre-set a higher level before pressing start /
|
||||
flipping Video. Matched the operator's explicit "snap to audio level 2"
|
||||
wording; alternative was to only raise from 0 (the prior 3/10 behavior). Easy
|
||||
to soften to "only if currently 0" if undesired.
|
||||
3. **Real-device audio autoplay unverified.** Headless Chromium ran with
|
||||
`--autoplay-policy=no-user-gesture-required`, masking real Safari/iOS
|
||||
behavior; the in-gesture unlock is wired but needs a by-ear check.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Session 0031.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T09-06 (PST)
|
||||
> End: 2026-06-30T09-11 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Branch: design/cloudflare-static-publish
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Follow-up to session 0029's credits work. Operator's first ask was to rename the
|
||||
credits URL → "about" (one page holding both about + credits) and add an
|
||||
Accessibility table. Mid-clarification the operator redirected: **keep About and
|
||||
Credits separate** (the About page is owned by a parallel session) and simply
|
||||
**"Remove the 'About this work' from the credits page. I'll merge that into the
|
||||
About page on the other session."**
|
||||
|
||||
## What I found before acting
|
||||
|
||||
- The Cloudflare site is now **LIVE** (`benstull.art/human-experience-simulator`);
|
||||
the branch had advanced several commits since s0029 (live deploy, progressive
|
||||
boot, an audio mediaBase fix), and moved again mid-session (`a68d0bc`→`d10ffca`).
|
||||
- A **live concurrent session** owns `feat/accessibility-pass` with an
|
||||
approved-pending design spec (`docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md`):
|
||||
a full WCAG 2.1 AA pass + a **separate `about.html`**. I surfaced the overlap with
|
||||
the operator's original (one-page) ask; the operator resolved it by keeping the
|
||||
pages separate and narrowing my task to the section removal.
|
||||
|
||||
## Arc
|
||||
|
||||
1. Surveyed git + the concurrent worktrees; read the a11y design spec.
|
||||
2. Started to raise a coordination question; operator interrupted with the narrow
|
||||
instruction (remove "About this work" from credits; they move it to About).
|
||||
3. Claimed **session 0031**, isolated worktree off the live branch tip (`d10ffca`)
|
||||
— only `credits.html` + its tests touched, so no overlap with the a11y session's
|
||||
files (`index.html`/`style.css`/`app.js`/`about.html`).
|
||||
4. Removed the `<section class="declaration">` "About this work" block from
|
||||
`credits.html`; rewrote the footage note to be self-contained ("All footage is
|
||||
altered in real time in the experience" — keeps the per-clip modification cue,
|
||||
drops the dangling "as described above").
|
||||
5. Updated tests to the new intent: `credits.test.js` and `tests/test_e2e_credits.py`
|
||||
now assert the page notes footage is altered but the **CC BY-SA 4.0 declaration is
|
||||
NOT** on credits (it moved to About).
|
||||
6. Left the `.declaration` CSS rule in `style.css` (the About page reuses these styles).
|
||||
7. Verified: node 28/28, pytest 315 passed / 2 skipped. Committed `8277417`,
|
||||
ff-merged + pushed, removed the worktree, deleted the branch.
|
||||
|
||||
## End state
|
||||
|
||||
- `design/cloudflare-static-publish` @ `8277417`, clean, pushed.
|
||||
- Credits page = per-clip attributions + "footage altered" note + audio courtesy
|
||||
block. The CC BY-SA work-license declaration (D1) is **no longer on credits** —
|
||||
it now belongs on the About page (operator + `feat/accessibility-pass` own it).
|
||||
- Site is live; this change reaches `benstull.art` only after a **redeploy**.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Kept a brief "footage is altered" note on the credits page** rather than
|
||||
stripping all alteration mention. Rationale: CC BY/BY-SA attribution wants the
|
||||
modification indicated alongside the credit; the full license declaration is what
|
||||
moved to About. (Alternative: remove every alteration mention from credits —
|
||||
judged worse for the per-clip attribution's "indicate changes" cue.)
|
||||
- **Left `.declaration` CSS in `style.css`** though now unused on credits — the
|
||||
About page (other session) will reuse it; removing it would break that page.
|
||||
|
||||
## Compliance note
|
||||
|
||||
D1 (the altered-work CC BY-SA declaration) now depends on the About page actually
|
||||
carrying it. **Confirm it landed on about.html before considering D1 done** — it is
|
||||
currently in neither place on the live site until the About page ships + a redeploy.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal confirm the About page (feat/accessibility-pass) carries the CC BY-SA 4.0 work-license declaration (D1, moved off credits in s0031), then redeploy design/cloudflare-static-publish to benstull.art per deploy/cloudflare/README.md
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# Session 0032.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T09-20 (PST)
|
||||
> End: 2026-06-30T09-24 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Branch: design/cloudflare-static-publish
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
After confirming that the "About this work" section removed in s0031 *was* the D1
|
||||
compliance (the CC BY-SA share-alike declaration, not the per-clip attribution),
|
||||
the operator: "Ok let's add that content back but can we name the section something
|
||||
other than 'About This Work' so it isn't confused with the about page?"
|
||||
|
||||
## Arc
|
||||
|
||||
1. Confirmed for the operator that "About this work" carried D1 specifically — the
|
||||
ShareAlike license declaration for the BY-SA source clips — while the per-clip
|
||||
attribution (the original goal) was untouched and stayed on credits.
|
||||
2. Claimed **session 0032**, isolated worktree off the branch tip (`8277417`).
|
||||
3. Re-added the CC BY-SA 4.0 declaration to `credits.html` under the heading
|
||||
**"License & reuse"** (deliberately not "About this work" — that name is reserved
|
||||
for the separate About page from `feat/accessibility-pass`). Restored the footage
|
||||
note's "altered as described above" now that the section is back above it. Reused
|
||||
the `.declaration` CSS left in place by s0031.
|
||||
4. Reverted the s0031 test changes: `credits.test.js` + `tests/test_e2e_credits.py`
|
||||
now assert the declaration IS present, plus pin the rename (heading ≠ "About this
|
||||
work", contains "License & reuse").
|
||||
5. Verified: node 28/28, pytest 315 passed / 2 skipped (e2e confirms "CC BY-SA 4.0"
|
||||
visible in-browser again). Committed `ee7c65a`, ff-merged + pushed, removed the
|
||||
worktree, deleted the branch.
|
||||
|
||||
## End state
|
||||
|
||||
- `design/cloudflare-static-publish` @ `ee7c65a`, clean, pushed.
|
||||
- Credits page now carries BOTH halves of CC compliance: per-clip CC-BY/BY-SA
|
||||
attribution **and** the CC BY-SA 4.0 derivative declaration (under "License &
|
||||
reuse"). The s0031 gap (live site missing the share-alike declaration) is closed.
|
||||
- Site is live; reaches `benstull.art` only after a **redeploy**.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Section heading "License & reuse"** chosen (operator delegated the name). It pairs
|
||||
with the "Credits & licenses" page title and is unambiguous vs. the About page.
|
||||
(Alternatives weighed: "This work's license", "License & alteration".)
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy design/cloudflare-static-publish to benstull.art (CC-BY/BY-SA credits + "License & reuse" D1 declaration now both on the credits page) per deploy/cloudflare/README.md; the About page lands separately via the feat/accessibility-pass session
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# Session 0033.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T09-38 (PST)
|
||||
> End: 2026-06-30T09-43 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Branch: design/cloudflare-static-publish (canonical); merge performed on main via worktree
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Operator asked "Have you merged to main?" → no (all work was on
|
||||
`design/cloudflare-static-publish`) → operator: "Yes" (merge it to main).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Confirmed none of the credits work (nor the 17-commit static-publish line) was on
|
||||
`main`; surfaced the considerations (live branch, in-flight a11y session, brings all
|
||||
commits). Operator approved the merge.
|
||||
2. Claimed **session 0033**. Surveyed divergence: `main` and the branch had diverged —
|
||||
`main` carried real app work from the parallel labels/affect session (per-clip
|
||||
right-brain feelings `bf1013b`, i18n re-extract `2d54023`, affect/label fix
|
||||
`041fcde`), not just transcript publishes.
|
||||
3. Found only **two** overlapping files (`app.js`, `altitude-lock.spec.ts`).
|
||||
**Test-merged in a throwaway worktree** off `origin/main`: clean auto-merge, no
|
||||
conflicts. Ran the FULL suite on the merged tree — **node 29, pytest 315/2-skip,
|
||||
green** — before trusting the textual merge.
|
||||
4. Performed the real `--no-ff` merge on `main` in a worktree (keeping the canonical
|
||||
checkout on the live deploy branch), pushed `main` (`75961ea`→`87d605d`).
|
||||
5. Verified credits commits + `credits.html` are on `origin/main`, branch now 0 ahead.
|
||||
Removed the worktree; canonical checkout clean on `design/cloudflare-static-publish`.
|
||||
|
||||
## End state
|
||||
|
||||
- `origin/main` @ `87d605d` — now carries the entire Cloudflare static-publish line,
|
||||
including the CC-BY/BY-SA credits page + "License & reuse" CC BY-SA declaration.
|
||||
- `design/cloudflare-static-publish` is fully integrated (0 commits ahead of main).
|
||||
- The live site still serves the pre-merge build; **a redeploy** ships the merged main.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Merged via local `--no-ff` + push rather than a Gitea PR.** `gh` isn't wired (Gitea
|
||||
host) and the operator approved directly in autonomous mode; the work was developed on
|
||||
a branch and the merge produces the same merge-commit a PR would. (Alternative: open a
|
||||
Gitea-API PR for a review record — skipped for speed; can add retroactively if wanted.)
|
||||
- **Did not merge `main` back into `design/cloudflare-static-publish`.** The branch is 0
|
||||
ahead now; future static work can re-branch from main. Left as-is.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy benstull.art from main (now carries the CC-BY/BY-SA credits + "License & reuse" D1 declaration) per deploy/cloudflare/README.md; the About page / WCAG pass lands separately via the feat/accessibility-pass session
|
||||
```
|
||||
@@ -79,5 +79,23 @@
|
||||
},
|
||||
"0027": {
|
||||
"title": ""
|
||||
},
|
||||
"0028": {
|
||||
"title": ""
|
||||
},
|
||||
"0029": {
|
||||
"title": ""
|
||||
},
|
||||
"0030": {
|
||||
"title": ""
|
||||
},
|
||||
"0031": {
|
||||
"title": ""
|
||||
},
|
||||
"0032": {
|
||||
"title": ""
|
||||
},
|
||||
"0033": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,268 @@ AFFECT: dict[str, list[tuple]] = {
|
||||
],
|
||||
}
|
||||
|
||||
# --- Per-CLIP affect override (feelings drawn from THAT specific footage) --------
|
||||
# When a clip is here, these feelings replace the scale's shared register for it —
|
||||
# tailored to what the actual video evokes (mood, motion, light), keeping the
|
||||
# scale's emotional family as a base. Reuses the scale `feel.*` keys where they fit
|
||||
# (their es/fr/ja already exist) and adds new keys (with catalog translations) for
|
||||
# nuances a clip needs. Same tuple shape: (key, [x,y], min_level, [4 tiers]).
|
||||
AFFECT_CLIP: dict[str, list[tuple]] = {
|
||||
"orbit_planetearth": [
|
||||
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
|
||||
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
],
|
||||
"orbit_bluemarble": [
|
||||
("feel.unity", [0.5, 0.44], 1, ["one", "unity", "belonging", "a borderless belonging"]),
|
||||
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.distance", [0.66, 0.58], 3, ["far", "distance", "remoteness", "an exquisite remoteness"]),
|
||||
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
],
|
||||
"orbit_aurora2025": [
|
||||
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.eeriness", [0.66, 0.58], 3, ["strange", "eeriness", "an otherworldly charge", "an electric, otherworldly hush"]),
|
||||
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
],
|
||||
"orbit_citylights": [
|
||||
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradle’s warmth"]),
|
||||
("feel.longing", [0.22, 0.68], 2, ["want", "longing", "yearning", "an aching yearning"]),
|
||||
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.unity", [0.42, 0.82], 4, ["one", "unity", "belonging", "a borderless belonging"]),
|
||||
],
|
||||
"orbit_helene": [
|
||||
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
],
|
||||
"orbit_epic": [
|
||||
("feel.distance", [0.5, 0.44], 1, ["far", "distance", "remoteness", "an exquisite remoteness"]),
|
||||
("feel.insignificance", [0.22, 0.68], 2, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
|
||||
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
],
|
||||
"sky_grca_templesa": [
|
||||
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
],
|
||||
"sky_greenland_landice": [
|
||||
("feel.exhilaration", [0.5, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.purity", [0.66, 0.58], 3, ["pure", "purity", "stillness", "a glacial hush"]),
|
||||
("feel.sublime", [0.42, 0.82], 4, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
],
|
||||
"sky_greenland_suture": [
|
||||
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.solitude", [0.66, 0.58], 3, ["alone", "solitude", "isolation", "a vast frozen solitude"]),
|
||||
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
|
||||
],
|
||||
"sky_jungle_amazon": [
|
||||
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
|
||||
("feel.verdancy", [0.22, 0.68], 2, ["lush", "verdancy", "abundance", "a teeming green abundance"]),
|
||||
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
],
|
||||
"sky_jungle_waterfall": [
|
||||
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
|
||||
("feel.freshness", [0.66, 0.58], 3, ["cool", "freshness", "vitality", "a cool, cascading freshness"]),
|
||||
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
],
|
||||
"sky_coast_cliffspain": [
|
||||
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.exhilaration", [0.22, 0.68], 2, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.turbulence", [0.66, 0.58], 3, ["churn", "turbulence", "ferment", "a violent ferment"]),
|
||||
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
],
|
||||
"sky_mtn_castlecrags": [
|
||||
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
|
||||
("feel.serenity", [0.22, 0.68], 2, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
|
||||
],
|
||||
"sky_mtn_rocky": [
|
||||
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
|
||||
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
],
|
||||
"coast_birdrock": [
|
||||
("feel.melancholy", [0.5, 0.44], 1, ["sad", "melancholy", "longing", "a soft seaward longing"]),
|
||||
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.nostalgia", [0.66, 0.58], 3, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
|
||||
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
],
|
||||
"coast_surfgrass": [
|
||||
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.immersion", [0.22, 0.68], 2, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
("feel.ease", [0.42, 0.82], 4, ["nice", "ease", "calm", "an unhurried calm"]),
|
||||
],
|
||||
"coast_kelp": [
|
||||
("feel.immersion", [0.5, 0.44], 1, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.freedom", [0.42, 0.82], 4, ["free", "freedom", "release", "a boundless release"]),
|
||||
],
|
||||
"coast_otters": [
|
||||
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradle’s warmth"]),
|
||||
("feel.delight", [0.22, 0.68], 2, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
("feel.ease", [0.66, 0.58], 3, ["nice", "ease", "calm", "an unhurried calm"]),
|
||||
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
|
||||
],
|
||||
"coast_kalaloch": [
|
||||
("feel.nostalgia", [0.5, 0.44], 1, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
|
||||
("feel.melancholy", [0.22, 0.68], 2, ["sad", "melancholy", "longing", "a soft seaward longing"]),
|
||||
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
|
||||
],
|
||||
"coast_seals": [
|
||||
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradle’s warmth"]),
|
||||
("feel.repose", [0.22, 0.68], 2, ["rest", "repose", "drowse", "a sun-warmed drowse"]),
|
||||
("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]),
|
||||
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
],
|
||||
"coast_mist": [
|
||||
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.hush", [0.66, 0.58], 3, ["quiet", "hush", "stillness", "a breath-held hush"]),
|
||||
("feel.nostalgia", [0.42, 0.82], 4, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
|
||||
],
|
||||
"reef_lionfish": [
|
||||
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
("feel.poise", [0.22, 0.68], 2, ["still", "poise", "grace", "a hovering, ornate poise"]),
|
||||
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
],
|
||||
"reef_spawning": [
|
||||
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.flow", [0.22, 0.68], 2, ["go", "flow", "streaming", "a sweeping, schooling flow"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
],
|
||||
"reef_hawkfish": [
|
||||
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
|
||||
("feel.curiosity", [0.22, 0.68], 2, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
("feel.delight", [0.66, 0.58], 3, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
],
|
||||
"reef_coralspacific": [
|
||||
("feel.intricacy", [0.5, 0.44], 1, ["fine", "intricacy", "detail", "an intricate, woven density"]),
|
||||
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
],
|
||||
"reef_redsea": [
|
||||
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.radiance", [0.66, 0.58], 3, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
|
||||
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
],
|
||||
"reef_flowergarden": [
|
||||
("feel.delight", [0.5, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.freedom", [0.66, 0.58], 3, ["free", "freedom", "release", "a boundless release"]),
|
||||
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
],
|
||||
"abyss_wow": [
|
||||
("feel.vastness", [0.5, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
|
||||
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
],
|
||||
"abyss_midwaterexp": [
|
||||
("feel.fragility", [0.5, 0.44], 1, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
|
||||
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
|
||||
],
|
||||
"abyss_hiding": [
|
||||
("feel.spectral", [0.5, 0.44], 1, ["ghost", "the spectral", "hauntedness", "a ghost in the water"]),
|
||||
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
],
|
||||
"abyss_bigfin": [
|
||||
("feel.alienness", [0.5, 0.44], 1, ["odd", "alienness", "strangeness", "an alien grace"]),
|
||||
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.unease", [0.66, 0.58], 3, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
|
||||
],
|
||||
"abyss_dandelion": [
|
||||
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
|
||||
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
],
|
||||
"abyss_octopus": [
|
||||
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
|
||||
("feel.tenderness", [0.66, 0.58], 3, ["soft", "tenderness", "warmth", "a cradle’s warmth"]),
|
||||
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
],
|
||||
"abyss_seapig": [
|
||||
("feel.strangeness", [0.5, 0.44], 1, ["weird", "strangeness", "the uncanny", "a fleshy strangeness"]),
|
||||
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
],
|
||||
# cosmos — Webb "Cosmic Cliffs": monumental golden ridges, cathedral-scale.
|
||||
"cosmos": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
],
|
||||
# cosmos_galaxies — drifting through a dark field of galaxies: lonely, remote.
|
||||
"cosmos_galaxies": [
|
||||
("feel.vastness", [0.50, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.distance", [0.22, 0.68], 2, ["far", "distance", "remoteness", "an exquisite remoteness"]),
|
||||
("feel.insignificance", [0.66, 0.58], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
|
||||
],
|
||||
# cosmos_orion — soft rose-lit star nursery: tender, dreamy, warm.
|
||||
"cosmos_orion": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.tenderness", [0.22, 0.68], 2, ["soft", "tenderness", "warmth", "a cradle’s warmth"]),
|
||||
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
|
||||
],
|
||||
# cosmos_tarantula — chaotic, fierce star-forming nebula: turbulent, intense.
|
||||
"cosmos_tarantula": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
|
||||
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
],
|
||||
# cosmos_westerlund — sparkling jewel-box star cluster: dazzling, radiant.
|
||||
"cosmos_westerlund": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
|
||||
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
],
|
||||
# cosmos_southernring — gas shed by a dying star: elegiac, mortal, poignant.
|
||||
"cosmos_southernring": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.mortality", [0.22, 0.68], 2, ["end", "mortality", "impermanence", "a star’s slow dying"]),
|
||||
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
|
||||
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
|
||||
],
|
||||
# cosmos_carina_eso — wide teeming Milky Way star field: panoramic richness.
|
||||
"cosmos_carina_eso": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def static_label(key, salience, tiers, box):
|
||||
"""A fixed-box tiered label (always on-screen, salience-gated by Left)."""
|
||||
@@ -269,6 +531,7 @@ LABELS: dict[str, list[dict]] = {
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_galaxies": [
|
||||
measure("measure.count", "~2T galaxies", [0.06, 0.16, 0.24, 0.08], 2),
|
||||
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
|
||||
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
@@ -291,13 +554,19 @@ LABELS: dict[str, list[dict]] = {
|
||||
],
|
||||
"orbit_bluemarble": [
|
||||
static_label("detected.globe", 4, ["Earth", "the globe", "terrestrial planet", "terrestrial planet · 12,742 km across"], [0.28, 0.18, 0.44, 0.6]),
|
||||
# Rotating full-disk globe: continents drift through frame, so label the
|
||||
# PERSISTENT features (clouds, starfield) with static boxes, not a continent.
|
||||
static_label("detected.cloud_band", 3, ["clouds", "weather systems", "cloud systems", "global cloud systems · clouds cover ~67% of Earth"], [0.34, 0.42, 0.34, 0.32]),
|
||||
static_label("detected.starfield", 1, ["stars", "starfield", "background stars", "background starfield · a rendered backdrop, not to scale"], [0.74, 0.05, 0.22, 0.30]),
|
||||
],
|
||||
# ---------- coast ----------
|
||||
"coast_birdrock": [
|
||||
static_label("detected.seabirds", 2, ["birds", "seabirds", "nesting seabirds", "seabird colony · the rookery that names the rock"], [0.35, 0.18, 0.25, 0.2]),
|
||||
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · wind-driven, ~10 s period"], [0.20, 0.55, 0.6, 0.3]),
|
||||
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.22, 0.3]),
|
||||
],
|
||||
"coast_surfgrass": [
|
||||
static_label("detected.tidepool", 2, ["pool", "tidepool", "intertidal pool", "tidepool · a pocket sea bared at low tide"], [0.15, 0.6, 0.4, 0.25]),
|
||||
static_label("detected.surfgrass", 4, ["grass", "surfgrass", "Phyllospadix", "Phyllospadix · a marine seagrass, not algae"], [0.18, 0.40, 0.5, 0.4]),
|
||||
static_label("detected.coralline", 2, ["pink", "coralline algae", "crustose coralline", "crustose coralline · calcified red algae"], [0.6, 0.55, 0.2, 0.2]),
|
||||
],
|
||||
@@ -336,6 +605,7 @@ LABELS: dict[str, list[dict]] = {
|
||||
measure("measure.depth", "−22 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
"reef_hawkfish": [
|
||||
static_label("detected.coral", 2, ["coral", "reef coral", "hard coral", "hard coral · the reef this fish grazes into sand"], [0.1, 0.6, 0.55, 0.3]),
|
||||
tracked_label(
|
||||
"detected.parrotfish", 4,
|
||||
["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"],
|
||||
@@ -353,6 +623,7 @@ LABELS: dict[str, list[dict]] = {
|
||||
),
|
||||
],
|
||||
"reef_coralspacific": [
|
||||
static_label("detected.polyp", 1, ["polyps", "coral polyps", "living polyps", "polyps · each a tiny animal, the colony’s builders"], [0.25, 0.4, 0.3, 0.3]),
|
||||
static_label("detected.coral", 4, ["coral", "coral colony", "Pacific scleractinian", "Pacific scleractinian · a symbiosis with algae"], [0.2, 0.4, 0.4, 0.4]),
|
||||
tracked_label(
|
||||
"detected.spotfish", 2,
|
||||
@@ -393,6 +664,7 @@ LABELS: dict[str, list[dict]] = {
|
||||
measure("measure.depth", "−1,600 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_hiding": [
|
||||
static_label("detected.marinesnow", 1, ["specks", "marine snow", "falling detritus", "marine snow · organic debris drifting down from above"], [0.1, 0.1, 0.8, 0.6]),
|
||||
tracked_label(
|
||||
"detected.jelly", 4,
|
||||
["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"],
|
||||
@@ -454,55 +726,68 @@ LABELS: dict[str, list[dict]] = {
|
||||
],
|
||||
# ---------- sky ----------
|
||||
"sky_grca_templesa": [
|
||||
static_label("detected.butte", 2, ["tower", "butte", "rock temple", "rock ‘temple’ · an erosional butte left standing"], [0.4, 0.3, 0.2, 0.35]),
|
||||
static_label("detected.canyon", 4, ["canyon", "gorge", "the Grand Canyon", "Grand Canyon · ~1.8 Gyr of rock, cut by the Colorado"], [0.10, 0.38, 0.8, 0.52]),
|
||||
static_label("detected.strata", 2, ["layers", "rock strata", "sedimentary beds", "strata · stacked epochs of deposition"], [0.15, 0.55, 0.6, 0.25]),
|
||||
],
|
||||
"sky_greenland_landice": [
|
||||
static_label("detected.crevasse", 2, ["cracks", "crevasses", "glacial crevasses", "crevasses · the ice fracturing as it flows"], [0.2, 0.55, 0.5, 0.25]),
|
||||
static_label("detected.icesheet", 4, ["ice", "ice sheet", "the Greenland ice sheet", "ice sheet · up to ~3 km thick, flowing slowly seaward"], [0.08, 0.40, 0.84, 0.5]),
|
||||
static_label("detected.snow", 2, ["snow", "snowfield", "firn", "firn · old snow compacting toward glacial ice"], [0.20, 0.20, 0.6, 0.25]),
|
||||
],
|
||||
"sky_greenland_suture": [
|
||||
static_label("detected.floe", 2, ["plates", "ice floes", "pack-ice floes", "pack-ice floes · drifting plates of frozen sea"], [0.15, 0.2, 0.3, 0.3]),
|
||||
static_label("detected.seaice", 4, ["ice", "sea ice", "drift ice", "drift ice · a frozen ocean skin, cracking and refreezing"], [0.08, 0.10, 0.84, 0.8]),
|
||||
static_label("detected.lead", 2, ["crack", "lead", "open lead", "lead · a fracture of open water between floes"], [0.30, 0.40, 0.4, 0.2]),
|
||||
],
|
||||
"sky_jungle_amazon": [
|
||||
static_label("detected.mist", 1, ["haze", "canopy mist", "transpiration haze", "transpiration haze · the forest exhaling water vapor"], [0.06, 0.1, 0.88, 0.18]),
|
||||
static_label("detected.canopy", 4, ["trees", "forest canopy", "the Amazon canopy", "rainforest canopy · among the densest biodiversity on Earth"], [0.06, 0.30, 0.88, 0.6]),
|
||||
static_label("detected.emergent", 2, ["tree", "tall tree", "emergent tree", "emergent · a giant breaking above the canopy roof"], [0.42, 0.40, 0.16, 0.2]),
|
||||
],
|
||||
"sky_jungle_waterfall": [
|
||||
static_label("detected.spray", 2, ["mist", "spray", "plunge spray", "plunge spray · the river aerosolized on impact"], [0.36, 0.62, 0.28, 0.25]),
|
||||
static_label("detected.waterfall", 4, ["falls", "waterfall", "cataract", "cataract · a river plunging off the canopy’s edge"], [0.34, 0.20, 0.3, 0.7]),
|
||||
static_label("detected.canopy", 2, ["trees", "jungle canopy", "rainforest canopy", "canopy · dense forest crowding the gorge"], [0.05, 0.30, 0.9, 0.5]),
|
||||
],
|
||||
"sky_coast_cliffspain": [
|
||||
static_label("detected.headland", 1, ["point", "headland", "rock promontory", "promontory · a cliffed arm of land into the sea"], [0.1, 0.2, 0.3, 0.4]),
|
||||
static_label("detected.seacliff", 4, ["cliff", "sea cliff", "coastal headland", "sea cliff · Atlantic rock cut back by the surf"], [0.10, 0.25, 0.8, 0.55]),
|
||||
static_label("detected.surf", 2, ["waves", "surf", "breaking swell", "breaking swell · ocean meeting stone"], [0.15, 0.70, 0.7, 0.25]),
|
||||
],
|
||||
"sky_mtn_castlecrags": [
|
||||
static_label("detected.talus", 2, ["scree", "talus", "talus slope", "talus · frost-shattered rock piled below the crags"], [0.3, 0.55, 0.4, 0.25]),
|
||||
static_label("detected.spire", 4, ["rock", "spire", "granite spire", "granite spire · a glacier-carved pluton, exhumed and weathered"], [0.30, 0.20, 0.4, 0.6]),
|
||||
static_label("detected.forest", 2, ["trees", "conifer forest", "montane forest", "montane forest · cloaking the slopes below the crags"], [0.05, 0.60, 0.9, 0.35]),
|
||||
],
|
||||
"sky_mtn_rocky": [
|
||||
static_label("detected.snowfield", 2, ["snow", "snowfield", "alpine snowfield", "alpine snowfield · lingering high-elevation snow"], [0.3, 0.22, 0.3, 0.22]),
|
||||
static_label("detected.summit", 4, ["peak", "summit", "alpine summit", "alpine summit · above tree line, snow-streaked granite"], [0.25, 0.20, 0.5, 0.5]),
|
||||
static_label("detected.tundra", 2, ["meadow", "alpine tundra", "alpine tundra", "alpine tundra · low cushion plants above the trees"], [0.10, 0.62, 0.8, 0.3]),
|
||||
],
|
||||
# ---------- coast ----------
|
||||
"coast_kelp": [
|
||||
static_label("detected.pneumatocyst", 1, ["floats", "gas bladders", "pneumatocysts", "pneumatocysts · gas floats lifting the blades to light"], [0.4, 0.18, 0.2, 0.25]),
|
||||
static_label("detected.kelp", 4, ["kelp", "giant kelp", "Macrocystis pyrifera", "Macrocystis · can grow ~0.5 m a day toward the light"], [0.20, 0.10, 0.6, 0.8]),
|
||||
static_label("detected.frond", 2, ["leaf", "blade", "kelp frond", "frond · gas-filled floats hold it upright"], [0.40, 0.30, 0.2, 0.4]),
|
||||
],
|
||||
"coast_otters": [
|
||||
static_label("detected.fur", 1, ["fur", "dense fur", "the densest fur", "densest fur on Earth · ~1M hairs/in², no blubber"], [0.34, 0.38, 0.3, 0.25]),
|
||||
static_label("detected.otter", 4, ["otter", "sea otter", "Enhydra lutris", "Enhydra lutris · eats ~25% of its weight a day to stay warm"], [0.30, 0.35, 0.4, 0.35]),
|
||||
static_label("detected.water", 2, ["water", "estuary", "tidal slough", "tidal slough · sheltered nursery water"], [0.05, 0.05, 0.9, 0.25]),
|
||||
],
|
||||
"coast_kalaloch": [
|
||||
static_label("detected.sunset", 2, ["glow", "sunset", "golden hour", "golden hour · low sun reddened through more air"], [0.05, 0.05, 0.9, 0.22]),
|
||||
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · long-period Pacific swell"], [0.15, 0.50, 0.7, 0.35]),
|
||||
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.3, 0.3]),
|
||||
],
|
||||
"coast_seals": [
|
||||
static_label("detected.whiskers", 1, ["whiskers", "vibrissae", "sensing whiskers", "vibrissae · whiskers that feel prey in murky water"], [0.3, 0.42, 0.2, 0.15]),
|
||||
static_label("detected.seal", 4, ["seals", "harbor seals", "Phoca vitulina", "Phoca vitulina · hauls out on rock to rest and warm"], [0.20, 0.40, 0.6, 0.35]),
|
||||
static_label("detected.searock", 2, ["rock", "haul-out rock", "intertidal rock", "haul-out · a tide-washed resting ledge"], [0.05, 0.6, 0.9, 0.3]),
|
||||
],
|
||||
"coast_mist": [
|
||||
static_label("detected.swell", 2, ["waves", "swell", "ocean swell", "ocean swell · wind-built waves from distant storms"], [0.1, 0.58, 0.8, 0.25]),
|
||||
static_label("detected.mist", 4, ["mist", "sea mist", "advection fog", "advection fog · warm air cooling over cold upwelling"], [0.05, 0.10, 0.9, 0.5]),
|
||||
static_label("detected.searock", 2, ["rock", "shore rock", "coastal rock", "coastal rock · the standing edge of the land"], [0.20, 0.55, 0.6, 0.35]),
|
||||
],
|
||||
@@ -519,19 +804,43 @@ LABELS: dict[str, list[dict]] = {
|
||||
],
|
||||
# ---------- abyss ----------
|
||||
"abyss_bigfin": [
|
||||
static_label("detected.squid", 4, ["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"], [0.30, 0.20, 0.4, 0.65]),
|
||||
static_label("detected.arms", 2, ["arms", "elbowed arms", "trailing filaments", "elbowed arms · held out, then trailing meters of thread"], [0.35, 0.5, 0.3, 0.4]),
|
||||
tracked_label(
|
||||
"detected.squid", 4,
|
||||
["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"],
|
||||
0.0, 1.0,
|
||||
[(0.0, [0.3, 0.36, 0.28, 0.3]), (0.5, [0.42, 0.22, 0.26, 0.28]), (1.0, [0.55, 0.1, 0.26, 0.3])],
|
||||
),
|
||||
measure("measure.depth", "−2,000 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_dandelion": [
|
||||
static_label("detected.siphonophore", 4, ["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"], [0.32, 0.28, 0.36, 0.4]),
|
||||
static_label("detected.tentacle", 2, ["threads", "tentacles", "feeding tentacles", "feeding tentacles · a drifting net for prey"], [0.3, 0.45, 0.4, 0.3]),
|
||||
tracked_label(
|
||||
"detected.siphonophore", 4,
|
||||
["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"],
|
||||
0.0, 1.0,
|
||||
[(0.0, [0.38, 0.28, 0.26, 0.36]), (0.5, [0.3, 0.2, 0.34, 0.46]), (1.0, [0.18, 0.1, 0.45, 0.7])],
|
||||
),
|
||||
measure("measure.depth", "−2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_octopus": [
|
||||
static_label("detected.octopus", 4, ["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"], [0.25, 0.30, 0.5, 0.45]),
|
||||
static_label("detected.arms", 2, ["arms", "eight arms", "sucker-lined arms", "eight arms · sucker-lined, tasting what they touch"], [0.25, 0.45, 0.5, 0.3]),
|
||||
tracked_label(
|
||||
"detected.octopus", 4,
|
||||
["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"],
|
||||
0.0, 1.0,
|
||||
[(0.0, [0.5, 0.1, 0.35, 0.55]), (0.5, [0.42, 0.16, 0.36, 0.52]), (1.0, [0.05, 0.02, 0.92, 0.95])],
|
||||
),
|
||||
measure("measure.depth", "−2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_seapig": [
|
||||
static_label("detected.seacucumber", 4, ["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"], [0.30, 0.30, 0.4, 0.4]),
|
||||
static_label("detected.veil", 2, ["veil", "oral veil", "swimming veil", "oral veil · sweeps sediment, and flaps to swim"], [0.3, 0.3, 0.35, 0.25]),
|
||||
tracked_label(
|
||||
"detected.seacucumber", 4,
|
||||
["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"],
|
||||
0.0, 1.0,
|
||||
[(0.0, [0.58, 0.38, 0.18, 0.3]), (0.5, [0.46, 0.32, 0.19, 0.32]), (1.0, [0.38, 0.28, 0.2, 0.36])],
|
||||
),
|
||||
measure("measure.depth", "−2,700 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
}
|
||||
@@ -540,10 +849,13 @@ LABELS: dict[str, list[dict]] = {
|
||||
# data above; this maps measurement keys to nothing extra (their value IS the string).
|
||||
|
||||
|
||||
def _affect_for_clip(scale: str) -> tuple[list, dict]:
|
||||
"""Build the affect list + its strings for a scale's pool member."""
|
||||
def _affect_for_clip(scale: str, clip_id: str) -> tuple[list, dict]:
|
||||
"""Build the affect list + its strings for a clip. A per-clip override in
|
||||
AFFECT_CLIP (feelings drawn from THAT footage) wins; otherwise the scale's
|
||||
shared register applies."""
|
||||
source = AFFECT_CLIP.get(clip_id, AFFECT[scale])
|
||||
entries, strings = [], {}
|
||||
for key, at, min_level, tiers in AFFECT[scale]:
|
||||
for key, at, min_level, tiers in source:
|
||||
entries.append({"key": key, "at": at, "min_level": min_level})
|
||||
strings[key] = tiers
|
||||
return entries, strings
|
||||
@@ -562,7 +874,7 @@ def _labels_for_clip(clip_id: str) -> tuple[list, dict]:
|
||||
def _clip_entry(scale: str, clip_id: str) -> dict:
|
||||
title, license_, source = META[clip_id]
|
||||
anns, lab_strings = _labels_for_clip(clip_id)
|
||||
affect, aff_strings = _affect_for_clip(scale)
|
||||
affect, aff_strings = _affect_for_clip(scale, clip_id)
|
||||
return {
|
||||
"id": clip_id,
|
||||
"title": title,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Accessibility pass (feat/accessibility-pass) — non-kiosk public-web a11y.
|
||||
// Each block maps to a task in
|
||||
// docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md.
|
||||
|
||||
test.describe("Task 1 — Output panel layout", () => {
|
||||
test("language picker sits below the Audio control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const audio = page.locator("#audio");
|
||||
const lang = page.locator("#lang-select");
|
||||
await expect(audio).toBeVisible();
|
||||
await expect(lang).toBeVisible();
|
||||
const aBox = await audio.boundingBox();
|
||||
const lBox = await lang.boundingBox();
|
||||
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang renders lower than audio
|
||||
});
|
||||
|
||||
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const pick = page.locator(".lang-pick");
|
||||
const select = page.locator("#lang-select");
|
||||
const pBox = await pick.boundingBox();
|
||||
const sBox = await select.boundingBox();
|
||||
expect(sBox!.x).toBeGreaterThan(pBox!.x); // select right of the label's left edge
|
||||
expect(pBox!.height).toBeLessThan(40); // one row, not stacked
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 4 — Photosensitivity warning gate", () => {
|
||||
test("motion warning gate shows once, then is remembered", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gate = page.locator("#motion-warning");
|
||||
await expect(gate).toBeVisible({ timeout: 30000 }); // shown after media preload + splash fade
|
||||
await page.locator("#motion-warning-continue").click();
|
||||
await expect(gate).toBeHidden();
|
||||
// Reload in the SAME context: dismissal persisted, gate stays hidden.
|
||||
await page.reload();
|
||||
await expect(page.locator("#motion-warning")).toBeHidden();
|
||||
await expect(page.locator("#run-sim")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 5 — Reduced motion", () => {
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await expect(rm).not.toBeChecked(); // default-off under no-preference
|
||||
await page.locator("#run-sim").click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(false); // experience running → video plays
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
|
||||
await expect(rm).toBeChecked();
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(true); // reduced motion → frozen
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 6 — Keyboard + ARIA dial", () => {
|
||||
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const dial = page.locator("#dial");
|
||||
await expect(dial).toHaveAttribute("role", "slider");
|
||||
await expect(dial).toHaveAttribute("tabindex", "0");
|
||||
await dial.focus();
|
||||
const before = await page.locator("#scale-name").textContent();
|
||||
await dial.press("ArrowDown"); // descend one altitude
|
||||
await expect
|
||||
.poll(async () => page.locator("#scale-name").textContent())
|
||||
.not.toBe(before);
|
||||
await dial.press("Home"); // jump to the top scale (cosmos)
|
||||
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", "0");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 7 — Screen-reader narration", () => {
|
||||
test("an aria-live region narrates the current scale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const live = page.locator("#sr-status");
|
||||
await expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("Home");
|
||||
await expect
|
||||
.poll(async () => (await live.textContent())?.toLowerCase())
|
||||
.toContain("cosmos");
|
||||
});
|
||||
|
||||
test("decorative overlay SVGs are hidden from assistive tech", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 9 — About page", () => {
|
||||
test("about page loads, names its themes, and links back", async ({ page }) => {
|
||||
await page.goto("/about.html");
|
||||
await expect(page.locator("h1")).toContainText(/about/i);
|
||||
await expect(page.getByText(/imperfect/i)).toBeVisible(); // honest-about-limits beat
|
||||
await expect(page.getByText(/space exploration/i)).toBeVisible(); // technology arc beat
|
||||
await expect(page.getByText(/large language models/i)).toBeVisible();
|
||||
await page.locator(".back-link").click();
|
||||
await expect(page).toHaveURL(/index\.html|\/$/);
|
||||
});
|
||||
|
||||
test("the header links to the about page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('a[href="about.html"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,10 @@ import { test, expect, Page } from "@playwright/test";
|
||||
// ("<scale> · <clip> (i/n)").
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("hef.devMode", "1");
|
||||
localStorage.setItem("hef.motionWarnDismissed", "1"); // returning visitor: skip the photosensitivity gate
|
||||
});
|
||||
await page.goto("/");
|
||||
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
|
||||
await page.waitForFunction(
|
||||
@@ -176,6 +179,38 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
|
||||
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
|
||||
});
|
||||
|
||||
test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => {
|
||||
// D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its
|
||||
// tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue
|
||||
// from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump.
|
||||
await boot(page);
|
||||
await enableVideo(page); // base loop only loads when video is shown
|
||||
// Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the
|
||||
// ascent is the reverse landing under test.
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await wheelOnStage(page, 60); // descend
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const mid = (await page.locator("#scale-name").textContent())!;
|
||||
expect(mid).toContain("orbit");
|
||||
await wheelOnStage(page, -60); // ascend back
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 });
|
||||
expect(await page.locator("#scale-name").textContent()).toContain("cosmos");
|
||||
// The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to
|
||||
// the ~3s tail the descending case uses.
|
||||
await page.waitForFunction(
|
||||
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; },
|
||||
null,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const v = await page.evaluate(() => {
|
||||
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
|
||||
return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused };
|
||||
});
|
||||
expect(v.loopTail).toBe("1"); // tail-loop machinery armed
|
||||
expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail
|
||||
expect(v.paused).toBe(false); // the loop is actually playing
|
||||
});
|
||||
|
||||
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
|
||||
+3244
-2314
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>About — Human Experience Filter</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>About this work</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>For everyone</h2>
|
||||
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
|
||||
representative of — the full range of human experience. That is why it works
|
||||
with a keyboard, with a screen reader, with reduced motion, and away from any
|
||||
single screen or kiosk: an experience about being human should be open to as
|
||||
many humans as possible.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Places technology let us see</h2>
|
||||
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
|
||||
of the Earth, the wider cosmos — are vantage points no unaided human could
|
||||
ever witness. We can share them only because our tools evolved to reach them:
|
||||
from <strong>underwater exploration</strong>, to <strong>drones and aerial
|
||||
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
|
||||
a machine went first, so that a person could feel what it is like to be there.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Built with LLMs</h2>
|
||||
<p>This piece was itself built using large language models — an extension of
|
||||
that same long arc of tools that widen what a single human can reach. The
|
||||
software, the words, and much of the craft were shaped in collaboration with
|
||||
a machine.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Honest about its limits</h2>
|
||||
<p>This implementation is imperfect. It is also more than I could have made
|
||||
alone: better with these tools assisting than by my hand as a single human.
|
||||
That tension is part of the point — technology does not replace the person
|
||||
behind the work; it extends their reach, flaws and all.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+292
-38
@@ -89,15 +89,30 @@ async function loadData() {
|
||||
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
|
||||
// fallback ring has a pool of one, so it just returns that member. Returns a
|
||||
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
|
||||
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
|
||||
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
|
||||
function _preloadDeps() {
|
||||
return {
|
||||
baseOf: (id) => (clipsById[id] || {}).base_file,
|
||||
morphFile: (a, b) => morphByPair[`${a}→${b}`] || null,
|
||||
isCached: (f) => !!f && !!mediaBlobs[f],
|
||||
};
|
||||
}
|
||||
|
||||
function poolIds(scale) {
|
||||
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
|
||||
}
|
||||
|
||||
async function pickRandomMember() {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (!scale) return null;
|
||||
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
|
||||
// The drag/scroll navigation already resolves picks + morphs client-side via
|
||||
// scrub.js; this removes the last /api/ring/advance dependency so the build is
|
||||
// fully static. The synthesized fallback ring has a pool of one → returns it.
|
||||
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
// Show only a clip whose base is already loaded (eligible). Initially that's the
|
||||
// phase-1 first member; the pool grows as the background preload finishes. Fallback
|
||||
// to the first member (guaranteed loaded by the phase-1 gate).
|
||||
const ids = poolIds(scale);
|
||||
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Land on the current scale: pick a random pool member and force its media to load.
|
||||
@@ -180,6 +195,18 @@ function preloadOrder() {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
|
||||
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
|
||||
async function cacheMany(files, concurrency = 6) {
|
||||
const total = files.length || 1;
|
||||
let done = 0, i = 0;
|
||||
setLoadingProgress(0, total);
|
||||
async function worker() {
|
||||
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
|
||||
}
|
||||
|
||||
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
|
||||
async function cacheClip(file) {
|
||||
if (!file || mediaBlobs[file]) return;
|
||||
@@ -246,23 +273,46 @@ function preloadChip() {
|
||||
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
|
||||
const LOOP_TAIL_S = 3.0;
|
||||
|
||||
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
|
||||
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
|
||||
// we're about to land on, so settle just plays an already-decoded element at the morph's
|
||||
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
|
||||
function loadLoop(clip) {
|
||||
if (!clip || loopVid.dataset.clip === clip.id) return;
|
||||
// The frame the current loop both LANDS on and WRAPS back to — stored per load so the
|
||||
// wrap handlers honor a direction-aware landing (forward=LOOP_TAIL_S, reverse=0).
|
||||
function loopStartFrame() {
|
||||
const s = parseFloat(loopVid.dataset.loopStart);
|
||||
return Number.isFinite(s) ? s : LOOP_TAIL_S;
|
||||
}
|
||||
|
||||
// Load a clip's base into the LOOP element (#vid-loop), seeked to `landFrame` and PAUSED
|
||||
// on that exact frame. `landFrame` is the frame the morph last showed for THIS landing:
|
||||
// LOOP_TAIL_S when descending (lands on the morph's dst-tail), 0 when ascending (lands on
|
||||
// the morph's src-head — fixes the D3 reverse-landing jump). Omitted → LOOP_TAIL_S, the
|
||||
// forward default for non-scrub paths (initial load, knob changes). Idempotent per clip;
|
||||
// an explicit `landFrame` re-seeks the already-loaded clip if the travel direction flipped
|
||||
// the landing phase, but a bare call (ensureClipMedia) never clobbers a phase a scrub set.
|
||||
// Called during a scrub to PRELOAD the clip we're about to land on, so settle just plays
|
||||
// an already-decoded element at the right frame — no reload, no seek, no stall.
|
||||
function loadLoop(clip, landFrame) {
|
||||
if (!clip) return;
|
||||
const seekTo = (t) => { try { loopVid.currentTime = t; } catch (e) { /* not seekable yet */ } };
|
||||
if (loopVid.dataset.clip === clip.id) {
|
||||
if (landFrame != null && loopVid.dataset.loopStart !== String(landFrame)) {
|
||||
loopVid.dataset.loopStart = String(landFrame);
|
||||
if (loopVid.readyState >= 1) seekTo(landFrame);
|
||||
else loopVid.addEventListener("loadedmetadata", () => seekTo(landFrame), { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const lf = landFrame == null ? LOOP_TAIL_S : landFrame;
|
||||
loopVid.dataset.clip = clip.id;
|
||||
loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler
|
||||
loopVid.dataset.loopTail = "1"; // arm the [loopStart, duration] wrap handler
|
||||
loopVid.dataset.loopStart = String(lf);
|
||||
loopVid.src = mediaUrl(clip.base_file);
|
||||
loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead
|
||||
loopVid.loop = false; // native loop restarts at 0; we loop from loopStart
|
||||
loopVid.muted = true;
|
||||
const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
|
||||
if (loopVid.readyState >= 1) seek();
|
||||
else loopVid.addEventListener("loadedmetadata", seek, { once: true });
|
||||
if (loopVid.readyState >= 1) seekTo(lf);
|
||||
else loopVid.addEventListener("loadedmetadata", () => seekTo(lf), { once: true });
|
||||
}
|
||||
|
||||
function playLoop() {
|
||||
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -275,21 +325,22 @@ function ensureClipMedia() {
|
||||
}
|
||||
|
||||
// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
|
||||
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
|
||||
// its landing offset (LOOP_TAIL_S forward, 0 reverse — see loopStartFrame), not a fixed
|
||||
// tail. The morph element (#vid) is driven directly by the scrub.
|
||||
loopVid.addEventListener("timeupdate", () => {
|
||||
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
|
||||
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under
|
||||
// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires
|
||||
// `ended`, and — with native loop off — freezes on its last frame ("not looping").
|
||||
// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely
|
||||
// hits this; loaded real machines do.
|
||||
// `ended` GUARANTEES recovery: seek back to the landing offset and resume. Headless
|
||||
// rarely hits this; loaded real machines do.
|
||||
loopVid.addEventListener("ended", () => {
|
||||
if (loopVid.dataset.loopTail !== "1") return;
|
||||
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
loopVid.play().catch(() => {});
|
||||
});
|
||||
|
||||
@@ -568,6 +619,49 @@ function renderOverlay(level, intensity) {
|
||||
// knob no longer gates them). `intensity` is the layer opacity. Words are placed at authored
|
||||
// scene points (no boxes — feelings are scene-level) and read softer than the
|
||||
// clinical reticles — feeling, not measurement.
|
||||
// --- Keep Feel (affect) words clear of Think (label) chips -------------------
|
||||
// Feelings are scene-level (soft position), so we nudge THEM off the analytical
|
||||
// chip plates rather than move a chip off its subject. Obstacles are computed
|
||||
// from the manifest (not the DOM) so the clearance holds for the WHOLE loop: a
|
||||
// tracked chip's drift is sampled, so the per-frame track re-render never slides
|
||||
// a chip under an already-placed word. Mirrors chip()'s plate geometry.
|
||||
const FS_CHIP = 2.4, PAD_CHIP = 0.6;
|
||||
function chipPlateRect(bx, by, textLen) {
|
||||
const w = textLen * FS_CHIP * 0.6 + PAD_CHIP * 2;
|
||||
const h = FS_CHIP + PAD_CHIP * 1.4;
|
||||
const cy = Math.max(by - h - 0.6, 0.4); // plate sits just above the box top
|
||||
return { x: bx, y: cy, w, h };
|
||||
}
|
||||
function rectsOverlap(a, b, pad) {
|
||||
pad = pad || 0;
|
||||
return a.x < b.x + b.w + pad && a.x + a.w + pad > b.x &&
|
||||
a.y < b.y + b.h + pad && a.y + a.h + pad > b.y;
|
||||
}
|
||||
// Plate rects for every left label visible at `level`; a tracked label's path is
|
||||
// sampled across the loop so its whole drift envelope is treated as occupied.
|
||||
function leftLabelPlates(clip, level) {
|
||||
const out = [];
|
||||
if (!clip || !clip.annotations || level <= 0) return out;
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
for (const a of clip.annotations) {
|
||||
if (level < firstLevel(a)) continue;
|
||||
const measure = a.key.startsWith("measure.");
|
||||
const raw = strings[a.key];
|
||||
const tier = measure ? 1 : clamp(level - firstLevel(a) + 1, 1, tierCount(raw));
|
||||
const label = String(pickTier(raw !== undefined ? raw : a.key, tier) || "");
|
||||
const len = label.length + (measure ? 0 : 5); // detections append " 0.xx"
|
||||
const ts = (a.track && a.track.length) ? [0, 0.2, 0.4, 0.6, 0.8, 1] : [0];
|
||||
for (const tt of ts) {
|
||||
const b = boxAt(a, tt);
|
||||
out.push(chipPlateRect(b[0] * 100, b[1] * 100, len));
|
||||
}
|
||||
}
|
||||
// The global "◉ ANALYSIS · L… · … OBJ" status tag (top-right) shows whenever any
|
||||
// label does — reserve its corner so feelings don't collide with it either.
|
||||
if (out.length) out.push({ x: 64, y: 2, w: 34, h: 6 });
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderAffect(strength, intensity, right) {
|
||||
lastAffect = { strength, intensity, right };
|
||||
const clip = activeClip();
|
||||
@@ -576,14 +670,36 @@ function renderAffect(strength, intensity, right) {
|
||||
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
|
||||
affectLayer.style.opacity = String(intensity);
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
// Chip plates to avoid (left labels visible at the current Left level), plus the
|
||||
// words placed so far so feelings don't stack on each other either.
|
||||
const obstacles = leftLabelPlates(clip, lastOverlay ? lastOverlay.level : 0);
|
||||
const placed = [];
|
||||
for (const f of clip.affect) {
|
||||
if (f.min_level > strength) continue;
|
||||
const [x, y] = f.at.map((n) => n * 100);
|
||||
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
|
||||
const [x, y0] = f.at.map((n) => n * 100);
|
||||
const t = svg("text", { x, y: y0, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
|
||||
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
|
||||
// knob rises (appearance is gated by the Right knob via `strength` above).
|
||||
const raw = strings[f.key];
|
||||
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
|
||||
// Nudge vertically off any chip plate / already-placed word, staying on-screen;
|
||||
// keep the least-overlapping spot if nothing is fully clear.
|
||||
const bb0 = t.getBBox();
|
||||
// Diagnostic seam (sibling of window.__hefState): disable nudging to A/B the
|
||||
// overlap-avoidance in tests.
|
||||
const deconflict = !(typeof window !== "undefined" && window.__hefNoDeconflict);
|
||||
if (deconflict && bb0.width) {
|
||||
let bestY = y0, bestHits = Infinity;
|
||||
for (const dy of [0, 4, -4, 8, -8, 12, -12, 16, -16, 20, -20]) {
|
||||
const yy = clamp(y0 + dy, 4, 96);
|
||||
const r = { x: bb0.x, y: bb0.y + (yy - y0), w: bb0.width, h: bb0.height };
|
||||
const hits = obstacles.concat(placed).filter((o) => rectsOverlap(r, o, 0.4)).length;
|
||||
if (hits < bestHits) { bestHits = hits; bestY = yy; if (hits === 0) break; }
|
||||
}
|
||||
if (bestY !== y0) t.setAttribute("y", bestY);
|
||||
}
|
||||
const bb = t.getBBox();
|
||||
placed.push({ x: bb.x, y: bb.y, w: bb.width, h: bb.height });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,9 +713,28 @@ function renderScaleReadout() {
|
||||
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
|
||||
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
renderDial();
|
||||
announceState(); // narrate the new altitude (scale + clip) for screen readers
|
||||
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
|
||||
}
|
||||
|
||||
// Turn the visual alteration into words for assistive tech: the current scale plus
|
||||
// the clip you've landed on. Deduped so a settle on the same state stays quiet.
|
||||
let lastAnnounced = "";
|
||||
function announce(text) {
|
||||
const el = $("sr-status");
|
||||
if (!el || !text || text === lastAnnounced) return;
|
||||
lastAnnounced = text;
|
||||
el.textContent = text;
|
||||
}
|
||||
function announceState() {
|
||||
if (!ring) return;
|
||||
const s = ring.scales[ringIndex];
|
||||
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
|
||||
const clip = activeClip();
|
||||
const title = clip && clip.title ? clip.title : "";
|
||||
announce(title ? `${scaleName}. ${title}` : scaleName);
|
||||
}
|
||||
|
||||
function controls() {
|
||||
// Video (on/off) and Audio (on/off) are orthogonal toggles (audio spec §2).
|
||||
// Audio on = the per-altitude soundtrack (white-noise is deferred). One bipolar
|
||||
@@ -682,10 +817,22 @@ const PER_ALTITUDE_MS = 1200;
|
||||
let autoRaf = 0;
|
||||
function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
if (isReduced()) { // reduced motion: no autonomous tween — jump to target + lock
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
setPos(targetPos);
|
||||
return;
|
||||
}
|
||||
if (autoRaf) cancelAnimationFrame(autoRaf);
|
||||
const fromPos = pos, dist = targetPos - fromPos;
|
||||
if (!dist) { setPos(targetPos); return; }
|
||||
const ms = perAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
|
||||
// Photosensitivity floor (WCAG 2.3.1): each altitude step is one luminance
|
||||
// transition (a blended crossfade), so a single step must take >= 1/3 s to stay
|
||||
// <= 3 flashes/sec. The clamp is a no-op at today's 1200ms but guards the floor
|
||||
// if PER_ALTITUDE_MS is ever lowered. (Audit: the only other luminance sources —
|
||||
// the #black cover fade (200ms, single, >=1.2s apart) and the audio-coupled
|
||||
// crossfade — cannot repeat faster than 3/sec, so they need no clamp.)
|
||||
const safeAltMs = HEFFlash.clampDurationMs(perAltMs, 1);
|
||||
const ms = safeAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
|
||||
let startTs = null;
|
||||
const tick = (ts) => {
|
||||
if (startTs === null) startTs = ts;
|
||||
@@ -752,6 +899,8 @@ function buildDial() {
|
||||
const t = svg("text", {
|
||||
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
|
||||
class: "dial-label", "data-index": String(i),
|
||||
role: "button", tabindex: "0",
|
||||
"aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang),
|
||||
}, dial);
|
||||
t.textContent = ring.scales[i].id;
|
||||
}
|
||||
@@ -777,6 +926,16 @@ function renderDial() {
|
||||
for (const el of dial.querySelectorAll(".dial-label")) {
|
||||
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
|
||||
}
|
||||
setDialAria();
|
||||
}
|
||||
|
||||
// Reflect the committed altitude into the dial's slider semantics for assistive tech.
|
||||
function setDialAria() {
|
||||
if (!dial || !ring) return;
|
||||
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
|
||||
dial.setAttribute("aria-valuenow", String(ringIndex));
|
||||
const s = ring.scales[ringIndex];
|
||||
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
|
||||
}
|
||||
|
||||
function dialAngle(e) {
|
||||
@@ -796,11 +955,18 @@ function angDelta(a, b) {
|
||||
// destination pick (was the server's job in /api/ring/advance; moved here so the
|
||||
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
|
||||
// canonical pure helper for the Pi player).
|
||||
function pickPoolClip(index) {
|
||||
function pickPoolClip(index, fromId) {
|
||||
const n = ring.scales.length;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
|
||||
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
const ids = poolIds(s);
|
||||
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
|
||||
// morphs both ways) — so the transition never stalls on un-downloaded media. The
|
||||
// eligible set grows as the background preload completes. Fallback to the first
|
||||
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
|
||||
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
|
||||
: HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
|
||||
@@ -810,8 +976,8 @@ function pickPoolClip(index) {
|
||||
function rebuildSegment(lo, enteredFrom) {
|
||||
const n = ring.scales.length;
|
||||
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
|
||||
const file = morphByPair[`${loId}→${hiId}`] || null;
|
||||
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
|
||||
if (file) {
|
||||
@@ -852,7 +1018,13 @@ function setPos(next) {
|
||||
busy = false;
|
||||
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
|
||||
playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
|
||||
// Land the loop on the frame the morph last showed for THIS travel direction:
|
||||
// descending lands on the morph's dst-tail (LOOP_TAIL_S), ascending on its src-head
|
||||
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
|
||||
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
|
||||
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
|
||||
if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop
|
||||
else { vid.pause(); loopVid.pause(); }
|
||||
showActiveSource();
|
||||
setNeedle(ringIndex * dialStep());
|
||||
renderScaleReadout();
|
||||
@@ -863,10 +1035,11 @@ function setPos(next) {
|
||||
}
|
||||
busy = true; // mid-morph: block update() from reloading the base clip under us
|
||||
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
|
||||
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
|
||||
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
|
||||
// Preload the clip we're heading toward into the LOOP element (paused on its landing
|
||||
// frame) so the landing is a swap, not a reload+seek. dir>0 lands on clipHi at the
|
||||
// morph's dst-tail (LOOP_TAIL_S); dir<0 lands on clipLo at the morph's src-head (0).
|
||||
const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
|
||||
loadLoop(clipsById[headingId]);
|
||||
loadLoop(clipsById[headingId], HEFScrub.loopLandFrame(dir, LOOP_TAIL_S));
|
||||
showActiveSource(); // morph element is the live source while scrubbing
|
||||
setNeedle(pos * dialStep());
|
||||
blendAudio(lo, frac);
|
||||
@@ -916,14 +1089,88 @@ function jumpToScale(idx) {
|
||||
if (d) autoScrub(Math.round(pos) + d);
|
||||
}
|
||||
|
||||
// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End
|
||||
// jump to the ends. Enter/Space on a focused label jumps to that scale.
|
||||
function onDialKey(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
const t = e.target;
|
||||
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
jumpToScale(+t.getAttribute("data-index"));
|
||||
return;
|
||||
}
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend
|
||||
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend
|
||||
case "Home": jumpToScale(0); break;
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
}
|
||||
|
||||
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
|
||||
// Off by default, state persisted in localStorage. Everything here is read from
|
||||
// data the renderer already has (clips, ring, the alteration response, the preload
|
||||
// cache) — no server endpoints. Editing labels stays in /author.html.
|
||||
const DEV_KEY = "hef.devMode";
|
||||
const WARN_KEY = "hef.motionWarnDismissed";
|
||||
const RM_KEY = "hef.reduceMotion";
|
||||
let devMode = false;
|
||||
let devStatsTimer = null;
|
||||
|
||||
// --- Reduced motion (freeze-to-stills) ---------------------------------------
|
||||
// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then
|
||||
// their choice persists. When ON: video holds a frame (paused), auto transitions
|
||||
// jump instantly instead of tweening — knob changes still re-grade the still.
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
function initReduceMotion() {
|
||||
const box = $("reduce-motion");
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
|
||||
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
reduceMotion = stored === null ? !!prefers : stored === "1";
|
||||
if (box) {
|
||||
box.checked = reduceMotion;
|
||||
box.addEventListener("change", () => {
|
||||
reduceMotion = box.checked;
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
applyReduceMotion();
|
||||
});
|
||||
}
|
||||
applyReduceMotion();
|
||||
}
|
||||
function applyReduceMotion() {
|
||||
if (reduceMotion) {
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
} else if (videoEverOn && $("visual").checked) {
|
||||
playLoop(); // resume the held loop on opt-out
|
||||
}
|
||||
}
|
||||
|
||||
// One-time photosensitivity notice shown over the stage before the experience
|
||||
// begins. Independent of media load; gated visually by the loading splash, then
|
||||
// dismissed-once (persisted) so returning visitors go straight to "Run simulation".
|
||||
function motionWarnDismissed() {
|
||||
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
|
||||
}
|
||||
function maybeShowMotionWarning() {
|
||||
const gate = $("motion-warning");
|
||||
if (!gate) return;
|
||||
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
|
||||
gate.classList.remove("hidden");
|
||||
$("motion-warning-continue").addEventListener("click", () => {
|
||||
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
|
||||
gate.classList.add("hidden");
|
||||
const btn = $("run-sim");
|
||||
if (btn) btn.focus({ preventScroll: true }); // move focus to the entry point
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
@@ -1272,6 +1519,7 @@ async function main() {
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
|
||||
initLanguage(); // populate the language dropdown + wire live switching
|
||||
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
@@ -1298,14 +1546,20 @@ async function main() {
|
||||
window.addEventListener("pointermove", onDialMove);
|
||||
window.addEventListener("pointerup", onDialUp);
|
||||
dial.addEventListener("wheel", onWheel, { passive: false });
|
||||
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
|
||||
$("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
update(); // render the initial state (both toggles off → black, silent)
|
||||
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
|
||||
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
|
||||
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
|
||||
await preloadAllMedia();
|
||||
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
|
||||
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
|
||||
// first turn because the random pick only ever lands on loaded clips (eligibility).
|
||||
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
|
||||
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
|
||||
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
|
||||
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
|
||||
// Each becomes eligible for the random pick only once fully loaded, so navigation
|
||||
// keeps using the already-loaded clips until the new ones are ready (no stall).
|
||||
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
|
||||
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
|
||||
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
|
||||
// outside a user gesture (muted video survives, sound does not), so an auto-start
|
||||
// would show video but stay silent. Instead the experience waits for the operator to
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
|
||||
// (luminance transitions) per second. Given a transition COUNT, returns the
|
||||
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
|
||||
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFFlash = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const MAX_PER_SEC = 3;
|
||||
function minSafeDurationMs(count) {
|
||||
const n = Math.max(0, Number(count) || 0);
|
||||
return (n / MAX_PER_SEC) * 1000;
|
||||
}
|
||||
function clampDurationMs(requestedMs, count) {
|
||||
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
|
||||
}
|
||||
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const F = require("./flash.js");
|
||||
|
||||
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
|
||||
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
|
||||
assert.strictEqual(F.minSafeDurationMs(6), 2000);
|
||||
assert.strictEqual(F.minSafeDurationMs(0), 0);
|
||||
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
|
||||
});
|
||||
|
||||
test("clampDurationMs: stretches only when too fast", () => {
|
||||
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
|
||||
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast -> clamped up
|
||||
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
|
||||
});
|
||||
@@ -31,6 +31,11 @@
|
||||
"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 ▶ 明" },
|
||||
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
|
||||
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
|
||||
"warn.title": { en: "Heads up — motion & flashing" },
|
||||
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
|
||||
"warn.continue": { en: "Continue" },
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<header>
|
||||
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
|
||||
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
|
||||
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits & licenses</a>
|
||||
</header>
|
||||
<main>
|
||||
@@ -24,21 +25,26 @@
|
||||
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
|
||||
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
|
||||
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
|
||||
<canvas id="paint"></canvas>
|
||||
<div id="tint"></div>
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<canvas id="paint" aria-hidden="true"></canvas>
|
||||
<div id="tint" aria-hidden="true"></div>
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||
<div id="black" class="black hidden"></div>
|
||||
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
|
||||
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
|
||||
<div class="mw-card">
|
||||
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
|
||||
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
|
||||
<fieldset>
|
||||
<legend data-i18n="output.legend">Output</legend>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="dev-switch" for="visual">
|
||||
<input type="checkbox" id="visual" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
@@ -48,12 +54,22 @@
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
<label class="dev-switch" for="reduce-motion">
|
||||
<input type="checkbox" id="reduce-motion" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
|
||||
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
|
||||
aria-label="Altitude — turn or use arrow keys to change scale"
|
||||
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>
|
||||
@@ -114,6 +130,8 @@
|
||||
<script src="config.js"></script>
|
||||
<script src="scrub.js"></script>
|
||||
<script src="alteration.js"></script>
|
||||
<script src="preload.js"></script>
|
||||
<script src="flash.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
|
||||
// universe; the random clip pick is then restricted to eligible clips (fully loaded
|
||||
// base + connecting morphs), so a transition never stalls on un-downloaded media and
|
||||
// the pool grows safely as the background preload finishes. UMD so the browser gets
|
||||
// `HEFPreload` and `node --test` can require() it.
|
||||
//
|
||||
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFPreload = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
// The minimal set to load before the simulator can run: the FIRST pool member of
|
||||
// every altitude + the morphs connecting adjacent first-members (both directions,
|
||||
// cyclic ring). One clip per altitude + corresponding morphs.
|
||||
function phase1Files(scales, deps) {
|
||||
const n = scales.length;
|
||||
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
|
||||
const files = new Set();
|
||||
for (const c of chosen) {
|
||||
const bf = deps.baseOf(c);
|
||||
if (bf) files.add(bf);
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = chosen[i], b = chosen[(i + 1) % n];
|
||||
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
|
||||
// cached BOTH ways (so the forward transition and a turn-back are ready).
|
||||
function eligibleDestinations(poolIds, fromId, deps) {
|
||||
return poolIds.filter((c) =>
|
||||
deps.isCached(deps.baseOf(c)) &&
|
||||
deps.isCached(deps.morphFile(fromId, c)) &&
|
||||
deps.isCached(deps.morphFile(c, fromId)));
|
||||
}
|
||||
|
||||
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
|
||||
function eligibleMembers(poolIds, deps) {
|
||||
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
|
||||
}
|
||||
|
||||
function pick(list, rnd) {
|
||||
return list.length ? list[Math.floor(rnd() * list.length)] : null;
|
||||
}
|
||||
|
||||
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
|
||||
});
|
||||
@@ -29,6 +29,18 @@
|
||||
return { from: 1 - f, to: f };
|
||||
}
|
||||
|
||||
// The base-loop frame a landing must continue from so the steady loop picks up
|
||||
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
|
||||
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
|
||||
// on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS
|
||||
// (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at
|
||||
// frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS
|
||||
// there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail
|
||||
// case (matches the loadLoop default used by non-scrub paths).
|
||||
function loopLandFrame(dir, loopTailS) {
|
||||
return dir < 0 ? 0 : loopTailS;
|
||||
}
|
||||
|
||||
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
|
||||
// exactly on an integer counts as crossing it (it commits that altitude).
|
||||
function integerCrossings(prevPos, pos) {
|
||||
@@ -41,5 +53,5 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
|
||||
});
|
||||
|
||||
@@ -90,8 +90,23 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
|
||||
}
|
||||
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
|
||||
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
|
||||
/* One-time photosensitivity warning, over the stage (above run-sim's z-60). */
|
||||
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
|
||||
align-items: center; justify-content: center; padding: 1rem;
|
||||
background: rgba(2, 4, 10, 0.92); }
|
||||
.motion-warning.hidden { display: none; }
|
||||
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
|
||||
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
|
||||
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #c3d2e8; }
|
||||
/* The Continue button is centered in flow here, not absolutely positioned. */
|
||||
.mw-card .run-sim { position: static; transform: none; }
|
||||
.mw-card .run-sim:hover { transform: scale(1.04); }
|
||||
.mw-card .run-sim:active { transform: scale(0.98); }
|
||||
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
|
||||
.dev-switch + .dev-switch { margin-top: 0.45rem; }
|
||||
/* Globe + language select on one row (the select no longer goes full-width here). */
|
||||
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0 0.2rem; }
|
||||
.lang-pick select { flex: 1; width: auto; }
|
||||
/* Live audio status readout (diagnostic) — turns green when actually playing. */
|
||||
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
|
||||
.audio-status.playing { color: #4e9; }
|
||||
@@ -112,17 +127,25 @@ input[type=range], select { width: 100%; }
|
||||
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
|
||||
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
|
||||
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
|
||||
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
.dial-label { fill: #b8cfe6; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.2px; cursor: pointer; }
|
||||
.dial-label:hover { fill: #cde; }
|
||||
.dial-label.active { fill: #9cf; font-weight: 700; }
|
||||
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
.dial-caption { fill: #8fa6c4; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dial-needle { fill: #9cf; }
|
||||
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
|
||||
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
|
||||
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #9fb3c8; }
|
||||
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
|
||||
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
|
||||
#dial:focus-visible { outline-offset: 4px; border-radius: 50%; }
|
||||
/* Screen-reader-only text (announcements, labels): present in the a11y tree, off-screen. */
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
|
||||
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const P = require("../static/preload.js");
|
||||
|
||||
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
|
||||
// morph file = "<from>>-<to>" when it exists.
|
||||
const scales = [
|
||||
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
|
||||
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
|
||||
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
|
||||
];
|
||||
const baseOf = (id) => id + ".b";
|
||||
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
|
||||
const cached = new Set();
|
||||
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
|
||||
|
||||
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
|
||||
const files = P.phase1Files(scales, deps());
|
||||
// bases: a1.b b1.b c1.b
|
||||
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
|
||||
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
|
||||
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
|
||||
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
|
||||
assert.ok(files.includes(m), `missing morph ${m}`);
|
||||
assert.equal(files.length, 3 + 6);
|
||||
});
|
||||
|
||||
test("eligibleDestinations requires base + BOTH morphs cached", () => {
|
||||
cached.clear();
|
||||
// from a1, candidate b1: nothing cached → not eligible
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
|
||||
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
|
||||
cached.add("b1>-a1"); // now reverse too
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
|
||||
// b2 fully cached too → both eligible
|
||||
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
|
||||
});
|
||||
|
||||
test("eligibleMembers requires only the base cached", () => {
|
||||
cached.clear();
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
|
||||
cached.add("a1.b");
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
|
||||
});
|
||||
|
||||
test("pick returns null on empty, a member otherwise", () => {
|
||||
assert.equal(P.pick([], Math.random), null);
|
||||
assert.equal(P.pick(["x"], () => 0), "x");
|
||||
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
|
||||
});
|
||||
@@ -58,3 +58,17 @@ test("integerCrossings: multiple crossings in travel order", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
|
||||
});
|
||||
|
||||
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
|
||||
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
|
||||
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
|
||||
// lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was
|
||||
// the D3 reverse-landing jump).
|
||||
assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail
|
||||
assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head
|
||||
// dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else).
|
||||
assert.equal(S.loopLandFrame(0, 3), 3);
|
||||
// honors any tail value
|
||||
assert.equal(S.loopLandFrame(1, 2.5), 2.5);
|
||||
assert.equal(S.loopLandFrame(-1, 2.5), 0);
|
||||
});
|
||||
|
||||
@@ -36,8 +36,8 @@ from simulator.app import MEDIA_DIR, create_app
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
|
||||
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
|
||||
"credits.html", "credits.js"]
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
|
||||
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
|
||||
|
||||
|
||||
def _bake_api(app_dir: Path) -> dict:
|
||||
@@ -84,7 +84,8 @@ def _version_assets(app_dir: Path) -> None:
|
||||
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
|
||||
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
|
||||
# ignores _headers for them — so without this, returning visitors run stale JS.
|
||||
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"]
|
||||
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
|
||||
"style.css", "credits.js", "config.js"]
|
||||
tok = {}
|
||||
for a in assets:
|
||||
p = app_dir / a
|
||||
|
||||
+677
-532
File diff suppressed because it is too large
Load Diff
+658
-15
@@ -36,6 +36,12 @@
|
||||
"anhelo",
|
||||
"añoranza",
|
||||
"una añoranza punzante"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
]
|
||||
},
|
||||
"cosmos_galaxies": {
|
||||
@@ -69,6 +75,13 @@
|
||||
"anhelo",
|
||||
"añoranza",
|
||||
"una añoranza punzante"
|
||||
],
|
||||
"measure.count": "~2 billones de galaxias",
|
||||
"feel.distance": [
|
||||
"lejos",
|
||||
"distancia",
|
||||
"lejanía",
|
||||
"una lejanía exquisita"
|
||||
]
|
||||
},
|
||||
"cosmos_orion": {
|
||||
@@ -108,7 +121,19 @@
|
||||
"estrella del Trapecio",
|
||||
"Trapecio · cálidas estrellas O que iluminan la nebulosa"
|
||||
],
|
||||
"measure.distance": "≈1.344 al"
|
||||
"measure.distance": "≈1.344 al",
|
||||
"feel.tenderness": [
|
||||
"suave",
|
||||
"ternura",
|
||||
"calidez",
|
||||
"la calidez de una cuna"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
]
|
||||
},
|
||||
"cosmos_tarantula": {
|
||||
"feel.wonder": [
|
||||
@@ -147,7 +172,13 @@
|
||||
"R136",
|
||||
"R136 · reúne algunas de las estrellas más masivas conocidas"
|
||||
],
|
||||
"measure.distance": "≈160.000 al"
|
||||
"measure.distance": "≈160.000 al",
|
||||
"feel.turbulence": [
|
||||
"agitación",
|
||||
"turbulencia",
|
||||
"fermento",
|
||||
"un fermento violento"
|
||||
]
|
||||
},
|
||||
"cosmos_westerlund": {
|
||||
"feel.wonder": [
|
||||
@@ -186,7 +217,19 @@
|
||||
"estrella O-type",
|
||||
"O-type · blanca azulada, decenas de masas solares"
|
||||
],
|
||||
"measure.distance": "≈20.000 al"
|
||||
"measure.distance": "≈20.000 al",
|
||||
"feel.radiance": [
|
||||
"brillo",
|
||||
"resplandor",
|
||||
"fulgor",
|
||||
"un fulgor de joyas"
|
||||
],
|
||||
"feel.exhilaration": [
|
||||
"yupi",
|
||||
"euforia",
|
||||
"júbilo",
|
||||
"un júbilo que se eleva"
|
||||
]
|
||||
},
|
||||
"cosmos_southernring": {
|
||||
"feel.wonder": [
|
||||
@@ -225,7 +268,19 @@
|
||||
"enana blanca",
|
||||
"enana blanca · el núcleo estelar caliente que queda"
|
||||
],
|
||||
"measure.distance": "≈2.500 al"
|
||||
"measure.distance": "≈2.500 al",
|
||||
"feel.mortality": [
|
||||
"fin",
|
||||
"mortalidad",
|
||||
"fugacidad",
|
||||
"la lenta muerte de una estrella"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"punzada",
|
||||
"desgarro",
|
||||
"melancolía dulce",
|
||||
"una despedida luminosa"
|
||||
]
|
||||
},
|
||||
"cosmos_carina_eso": {
|
||||
"feel.wonder": [
|
||||
@@ -264,7 +319,13 @@
|
||||
"estrella masiva",
|
||||
"estrella masiva · la que esculpe los acantilados de Carina"
|
||||
],
|
||||
"measure.distance": "≈7.500 al"
|
||||
"measure.distance": "≈7.500 al",
|
||||
"feel.abundance": [
|
||||
"lleno",
|
||||
"abundancia",
|
||||
"riqueza",
|
||||
"una riqueza rebosante"
|
||||
]
|
||||
},
|
||||
"orbit_planetearth": {
|
||||
"detected.cloud_band": [
|
||||
@@ -303,6 +364,18 @@
|
||||
"distancia",
|
||||
"lejanía",
|
||||
"una lejanía exquisita"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"brillo",
|
||||
"resplandor",
|
||||
"fulgor",
|
||||
"un fulgor de joyas"
|
||||
]
|
||||
},
|
||||
"orbit_bluemarble": {
|
||||
@@ -312,6 +385,18 @@
|
||||
"planeta terrestre",
|
||||
"planeta terrestre · 12.742 km de diámetro"
|
||||
],
|
||||
"detected.cloud_band": [
|
||||
"nubes",
|
||||
"sistemas meteorológicos",
|
||||
"sistemas nubosos",
|
||||
"sistemas nubosos globales · las nubes cubren ~67% de la Tierra"
|
||||
],
|
||||
"detected.starfield": [
|
||||
"estrellas",
|
||||
"campo estelar",
|
||||
"estrellas de fondo",
|
||||
"campo estelar de fondo · un telón renderizado, sin escala"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
@@ -335,6 +420,12 @@
|
||||
"distancia",
|
||||
"lejanía",
|
||||
"una lejanía exquisita"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"orbit_aurora2025": {
|
||||
@@ -374,7 +465,25 @@
|
||||
"limbo atmosférico",
|
||||
"limbo atmosférico · ~100 km de aire, brillando de canto"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
],
|
||||
"feel.eeriness": [
|
||||
"extraño",
|
||||
"inquietud",
|
||||
"una carga sobrenatural",
|
||||
"un susurro eléctrico y sobrenatural"
|
||||
]
|
||||
},
|
||||
"orbit_citylights": {
|
||||
"feel.serenity": [
|
||||
@@ -413,7 +522,19 @@
|
||||
"luminiscencia atmosférica",
|
||||
"luminiscencia del aire · la débil emisión nocturna de la alta atmósfera"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.tenderness": [
|
||||
"suave",
|
||||
"ternura",
|
||||
"calidez",
|
||||
"la calidez de una cuna"
|
||||
],
|
||||
"feel.longing": [
|
||||
"querer",
|
||||
"anhelo",
|
||||
"añoranza",
|
||||
"una añoranza punzante"
|
||||
]
|
||||
},
|
||||
"orbit_helene": {
|
||||
"feel.serenity": [
|
||||
@@ -452,7 +573,25 @@
|
||||
"limbo atmosférico",
|
||||
"limbo atmosférico · la fina capa donde vive el clima"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"agitación",
|
||||
"turbulencia",
|
||||
"fermento",
|
||||
"un fermento violento"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"orbit_epic": {
|
||||
"feel.serenity": [
|
||||
@@ -491,7 +630,13 @@
|
||||
"sistemas meteorológicos",
|
||||
"sistemas meteorológicos · arremolinándose sobre un planeta que gira"
|
||||
],
|
||||
"measure.distance": "~1,5 M km"
|
||||
"measure.distance": "~1,5 M km",
|
||||
"feel.insignificance": [
|
||||
"pequeño",
|
||||
"pequeñez",
|
||||
"insignificancia",
|
||||
"una insignificancia que humilla"
|
||||
]
|
||||
},
|
||||
"sky_grca_templesa": {
|
||||
"feel.exhilaration": [
|
||||
@@ -529,6 +674,30 @@
|
||||
"estratos de roca",
|
||||
"lechos sedimentarios",
|
||||
"estratos · épocas de sedimentación apiladas"
|
||||
],
|
||||
"detected.butte": [
|
||||
"torre",
|
||||
"cerro testigo",
|
||||
"templo de roca",
|
||||
"‘templo’ de roca · un cerro testigo de la erosión"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"sky_greenland_landice": {
|
||||
@@ -567,6 +736,30 @@
|
||||
"nevero",
|
||||
"neviza",
|
||||
"neviza · nieve vieja que se compacta hacia hielo glaciar"
|
||||
],
|
||||
"detected.crevasse": [
|
||||
"grietas",
|
||||
"grietas",
|
||||
"grietas glaciares",
|
||||
"grietas · el hielo fracturándose al fluir"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
],
|
||||
"feel.purity": [
|
||||
"puro",
|
||||
"pureza",
|
||||
"quietud",
|
||||
"un silencio glacial"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
]
|
||||
},
|
||||
"sky_greenland_suture": {
|
||||
@@ -605,6 +798,30 @@
|
||||
"canal",
|
||||
"canal abierto",
|
||||
"canal · una fractura de agua abierta entre témpanos"
|
||||
],
|
||||
"detected.floe": [
|
||||
"placas",
|
||||
"témpanos",
|
||||
"témpanos de banquisa",
|
||||
"témpanos · placas de mar congelado a la deriva"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
],
|
||||
"feel.solitude": [
|
||||
"solo",
|
||||
"soledad",
|
||||
"aislamiento",
|
||||
"una vasta soledad helada"
|
||||
]
|
||||
},
|
||||
"sky_jungle_amazon": {
|
||||
@@ -643,6 +860,24 @@
|
||||
"árbol alto",
|
||||
"árbol emergente",
|
||||
"emergente · un gigante que rompe sobre el techo del dosel"
|
||||
],
|
||||
"detected.mist": [
|
||||
"bruma",
|
||||
"bruma del dosel",
|
||||
"bruma de transpiración",
|
||||
"bruma de transpiración · el bosque exhalando vapor"
|
||||
],
|
||||
"feel.verdancy": [
|
||||
"frondoso",
|
||||
"verdor",
|
||||
"exuberancia",
|
||||
"una exuberancia verde y rebosante"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
]
|
||||
},
|
||||
"sky_jungle_waterfall": {
|
||||
@@ -681,6 +916,24 @@
|
||||
"dosel de la jungla",
|
||||
"dosel selvático",
|
||||
"dosel · bosque denso que abarrota la garganta"
|
||||
],
|
||||
"detected.spray": [
|
||||
"rocío",
|
||||
"aerosol",
|
||||
"rocío de caída",
|
||||
"rocío de caída · el río pulverizado al impactar"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.freshness": [
|
||||
"fresco",
|
||||
"frescura",
|
||||
"vitalidad",
|
||||
"una frescura que cae en cascada"
|
||||
]
|
||||
},
|
||||
"sky_coast_cliffspain": {
|
||||
@@ -719,6 +972,30 @@
|
||||
"rompiente",
|
||||
"oleaje rompiente",
|
||||
"oleaje rompiente · el océano que encuentra la piedra"
|
||||
],
|
||||
"detected.headland": [
|
||||
"punta",
|
||||
"promontorio",
|
||||
"promontorio rocoso",
|
||||
"promontorio · un brazo de tierra acantilado en el mar"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"agitación",
|
||||
"turbulencia",
|
||||
"fermento",
|
||||
"un fermento violento"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"sky_mtn_castlecrags": {
|
||||
@@ -757,6 +1034,24 @@
|
||||
"bosque de coníferas",
|
||||
"bosque montano",
|
||||
"bosque montano · cubriendo las laderas bajo los riscos"
|
||||
],
|
||||
"detected.talus": [
|
||||
"pedrera",
|
||||
"talud",
|
||||
"talud de derrubios",
|
||||
"talud · roca rota por el hielo amontonada bajo los riscos"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"sky_mtn_rocky": {
|
||||
@@ -795,6 +1090,24 @@
|
||||
"tundra alpina",
|
||||
"tundra alpina",
|
||||
"tundra alpina · plantas en cojín bajas sobre los árboles"
|
||||
],
|
||||
"detected.snowfield": [
|
||||
"nieve",
|
||||
"nevero",
|
||||
"nevero alpino",
|
||||
"nevero alpino · nieve persistente de gran altitud"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandioso",
|
||||
"lo sublime",
|
||||
"grandeza",
|
||||
"una grandeza catedralicia"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"coast_birdrock": {
|
||||
@@ -833,6 +1146,24 @@
|
||||
"melancolía",
|
||||
"anhelo",
|
||||
"un suave anhelo hacia el mar"
|
||||
],
|
||||
"detected.seabirds": [
|
||||
"aves",
|
||||
"aves marinas",
|
||||
"aves marinas anidando",
|
||||
"colonia de aves marinas · el criadero que da nombre a la roca"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
]
|
||||
},
|
||||
"coast_surfgrass": {
|
||||
@@ -871,6 +1202,30 @@
|
||||
"melancolía",
|
||||
"anhelo",
|
||||
"un suave anhelo hacia el mar"
|
||||
],
|
||||
"detected.tidepool": [
|
||||
"charca",
|
||||
"poza de marea",
|
||||
"poza intermareal",
|
||||
"poza de marea · un mar en miniatura al bajar la marea"
|
||||
],
|
||||
"feel.abundance": [
|
||||
"lleno",
|
||||
"abundancia",
|
||||
"riqueza",
|
||||
"una riqueza rebosante"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"dentro",
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"mira",
|
||||
"curiosidad",
|
||||
"fascinación",
|
||||
"una fascinación absorta"
|
||||
]
|
||||
},
|
||||
"coast_kelp": {
|
||||
@@ -909,6 +1264,36 @@
|
||||
"lámina",
|
||||
"fronda de alga",
|
||||
"fronda · flotadores llenos de gas la mantienen erguida"
|
||||
],
|
||||
"detected.pneumatocyst": [
|
||||
"flotadores",
|
||||
"vejigas de gas",
|
||||
"neumatocistos",
|
||||
"neumatocistos · flotadores de gas que alzan las hojas a la luz"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"dentro",
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
],
|
||||
"feel.freedom": [
|
||||
"libre",
|
||||
"libertad",
|
||||
"liberación",
|
||||
"una liberación sin límites"
|
||||
]
|
||||
},
|
||||
"coast_otters": {
|
||||
@@ -947,6 +1332,24 @@
|
||||
"estuario",
|
||||
"estero de marea",
|
||||
"estero de marea · aguas resguardadas de crianza"
|
||||
],
|
||||
"detected.fur": [
|
||||
"pelaje",
|
||||
"pelaje denso",
|
||||
"el pelaje más denso",
|
||||
"el pelaje más denso de la Tierra · ~1 M de pelos/in², sin grasa"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"suave",
|
||||
"ternura",
|
||||
"calidez",
|
||||
"la calidez de una cuna"
|
||||
],
|
||||
"feel.delight": [
|
||||
"divertido",
|
||||
"deleite",
|
||||
"alegría",
|
||||
"una alegría viva y centelleante"
|
||||
]
|
||||
},
|
||||
"coast_kalaloch": {
|
||||
@@ -985,6 +1388,18 @@
|
||||
"farallón",
|
||||
"farallón costero",
|
||||
"farallón · roca residual tallada por las olas"
|
||||
],
|
||||
"detected.sunset": [
|
||||
"resplandor",
|
||||
"atardecer",
|
||||
"hora dorada",
|
||||
"hora dorada · el sol bajo enrojecido por más atmósfera"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
]
|
||||
},
|
||||
"coast_seals": {
|
||||
@@ -1023,6 +1438,30 @@
|
||||
"roca de descanso",
|
||||
"roca intermareal",
|
||||
"descansadero · una repisa de descanso bañada por la marea"
|
||||
],
|
||||
"detected.whiskers": [
|
||||
"bigotes",
|
||||
"vibrisas",
|
||||
"bigotes sensores",
|
||||
"vibrisas · bigotes que detectan presas en agua turbia"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"suave",
|
||||
"ternura",
|
||||
"calidez",
|
||||
"la calidez de una cuna"
|
||||
],
|
||||
"feel.repose": [
|
||||
"descanso",
|
||||
"reposo",
|
||||
"sopor",
|
||||
"un sopor entibiado por el sol"
|
||||
],
|
||||
"feel.delight": [
|
||||
"divertido",
|
||||
"deleite",
|
||||
"alegría",
|
||||
"una alegría viva y centelleante"
|
||||
]
|
||||
},
|
||||
"coast_mist": {
|
||||
@@ -1061,6 +1500,30 @@
|
||||
"roca de orilla",
|
||||
"roca costera",
|
||||
"roca costera · el borde firme de la tierra"
|
||||
],
|
||||
"detected.swell": [
|
||||
"olas",
|
||||
"mar de fondo",
|
||||
"oleaje de fondo",
|
||||
"mar de fondo · olas formadas por tormentas lejanas"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.hush": [
|
||||
"silencio",
|
||||
"quietud",
|
||||
"sosiego",
|
||||
"un silencio contenido"
|
||||
]
|
||||
},
|
||||
"reef_lionfish": {
|
||||
@@ -1100,6 +1563,18 @@
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"feel.poise": [
|
||||
"quieto",
|
||||
"aplomo",
|
||||
"gracia",
|
||||
"un aplomo suspendido y ornado"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
]
|
||||
},
|
||||
"reef_spawning": {
|
||||
@@ -1139,6 +1614,18 @@
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"feel.flow": [
|
||||
"fluir",
|
||||
"flujo",
|
||||
"corriente",
|
||||
"un flujo de cardumen que barre"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"reef_hawkfish": {
|
||||
@@ -1172,6 +1659,18 @@
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"detected.coral": [
|
||||
"coral",
|
||||
"coral de arrecife",
|
||||
"coral duro",
|
||||
"coral duro · el arrecife que este pez muele en arena"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"brillo",
|
||||
"resplandor",
|
||||
"fulgor",
|
||||
"un fulgor de joyas"
|
||||
]
|
||||
},
|
||||
"reef_coralspacific": {
|
||||
@@ -1210,6 +1709,24 @@
|
||||
"inmersión",
|
||||
"absorción",
|
||||
"una absorción contenida y sin aliento"
|
||||
],
|
||||
"detected.polyp": [
|
||||
"pólipos",
|
||||
"pólipos de coral",
|
||||
"pólipos vivos",
|
||||
"pólipos · cada uno un animal diminuto, constructores de la colonia"
|
||||
],
|
||||
"feel.intricacy": [
|
||||
"fino",
|
||||
"complejidad",
|
||||
"detalle",
|
||||
"una densidad intrincada y tejida"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
]
|
||||
},
|
||||
"reef_redsea": {
|
||||
@@ -1249,7 +1766,19 @@
|
||||
"antias",
|
||||
"antias · nubes naranjas de comeplancton sobre el arrecife"
|
||||
],
|
||||
"measure.depth": "−10 m"
|
||||
"measure.depth": "−10 m",
|
||||
"feel.serenity": [
|
||||
"calma",
|
||||
"serenidad",
|
||||
"paz",
|
||||
"una paz serena e ingrávida"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"brillo",
|
||||
"resplandor",
|
||||
"fulgor",
|
||||
"un fulgor de joyas"
|
||||
]
|
||||
},
|
||||
"reef_flowergarden": {
|
||||
"feel.delight": [
|
||||
@@ -1288,7 +1817,13 @@
|
||||
"Abudefduf saxatilis",
|
||||
"Abudefduf · la damisela rayada “sargento mayor”"
|
||||
],
|
||||
"measure.depth": "−20 m"
|
||||
"measure.depth": "−20 m",
|
||||
"feel.freedom": [
|
||||
"libre",
|
||||
"libertad",
|
||||
"liberación",
|
||||
"una liberación sin límites"
|
||||
]
|
||||
},
|
||||
"abyss_wow": {
|
||||
"detected.jelly": [
|
||||
@@ -1327,6 +1862,12 @@
|
||||
"pavor",
|
||||
"presagio",
|
||||
"un presagio lento y frío"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grande",
|
||||
"vastedad",
|
||||
"inmensidad",
|
||||
"una inmensidad vertiginosa"
|
||||
]
|
||||
},
|
||||
"abyss_midwaterexp": {
|
||||
@@ -1366,6 +1907,18 @@
|
||||
"pavor",
|
||||
"presagio",
|
||||
"un presagio lento y frío"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"punzada",
|
||||
"desgarro",
|
||||
"melancolía dulce",
|
||||
"una despedida luminosa"
|
||||
]
|
||||
},
|
||||
"abyss_hiding": {
|
||||
@@ -1399,6 +1952,24 @@
|
||||
"pavor",
|
||||
"presagio",
|
||||
"un presagio lento y frío"
|
||||
],
|
||||
"detected.marinesnow": [
|
||||
"motas",
|
||||
"nieve marina",
|
||||
"detrito que cae",
|
||||
"nieve marina · restos orgánicos que descienden desde arriba"
|
||||
],
|
||||
"feel.spectral": [
|
||||
"fantasma",
|
||||
"lo espectral",
|
||||
"lo fantasmal",
|
||||
"un fantasma en el agua"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
]
|
||||
},
|
||||
"abyss_bigfin": {
|
||||
@@ -1432,7 +2003,19 @@
|
||||
"Magnapinna",
|
||||
"Magnapinna · brazos acodados que se extienden metros en la oscuridad"
|
||||
],
|
||||
"measure.depth": "−2.000 m"
|
||||
"measure.depth": "−2.000 m",
|
||||
"detected.arms": [
|
||||
"brazos",
|
||||
"brazos acodados",
|
||||
"filamentos colgantes",
|
||||
"brazos acodados · extendidos y luego colgando metros de hilo"
|
||||
],
|
||||
"feel.alienness": [
|
||||
"raro",
|
||||
"lo alienígena",
|
||||
"extrañeza",
|
||||
"una gracia alienígena"
|
||||
]
|
||||
},
|
||||
"abyss_dandelion": {
|
||||
"feel.unease": [
|
||||
@@ -1465,7 +2048,31 @@
|
||||
"sifonóforo diente de león",
|
||||
"Rhodaliidae · una colonia de clones anclada al lecho marino"
|
||||
],
|
||||
"measure.depth": "−2.500 m"
|
||||
"measure.depth": "−2.500 m",
|
||||
"detected.tentacle": [
|
||||
"hilos",
|
||||
"tentáculos",
|
||||
"tentáculos de caza",
|
||||
"tentáculos de caza · una red a la deriva para atrapar presas"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"brillo",
|
||||
"resplandor",
|
||||
"fulgor",
|
||||
"un fulgor de joyas"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"guau",
|
||||
"asombro",
|
||||
"sobrecogimiento",
|
||||
"sobrecogimiento trascendente"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"fino",
|
||||
"fragilidad",
|
||||
"vulnerabilidad",
|
||||
"una fragilidad tierna"
|
||||
]
|
||||
},
|
||||
"abyss_octopus": {
|
||||
"feel.unease": [
|
||||
@@ -1498,7 +2105,25 @@
|
||||
"Graneledone",
|
||||
"Graneledone boreopacifica · incuba sus huevos durante ~4,5 años"
|
||||
],
|
||||
"measure.depth": "−2.500 m"
|
||||
"measure.depth": "−2.500 m",
|
||||
"detected.arms": [
|
||||
"brazos",
|
||||
"ocho brazos",
|
||||
"brazos con ventosas",
|
||||
"ocho brazos · con ventosas que saborean lo que tocan"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"mira",
|
||||
"curiosidad",
|
||||
"fascinación",
|
||||
"una fascinación absorta"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"suave",
|
||||
"ternura",
|
||||
"calidez",
|
||||
"la calidez de una cuna"
|
||||
]
|
||||
},
|
||||
"abyss_seapig": {
|
||||
"feel.unease": [
|
||||
@@ -1531,6 +2156,24 @@
|
||||
"Enypniastes eximia",
|
||||
"Enypniastes · un pepino de mar nadador, el “pollo sin cabeza”"
|
||||
],
|
||||
"measure.depth": "−2.700 m"
|
||||
"measure.depth": "−2.700 m",
|
||||
"detected.veil": [
|
||||
"velo",
|
||||
"velo oral",
|
||||
"velo natatorio",
|
||||
"velo oral · barre el sedimento y aletea para nadar"
|
||||
],
|
||||
"feel.strangeness": [
|
||||
"raro",
|
||||
"rareza",
|
||||
"lo inquietante",
|
||||
"una rareza carnosa"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"mira",
|
||||
"curiosidad",
|
||||
"fascinación",
|
||||
"una fascinación absorta"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+658
-15
@@ -36,6 +36,12 @@
|
||||
"désir",
|
||||
"aspiration",
|
||||
"une aspiration douloureuse"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
]
|
||||
},
|
||||
"cosmos_galaxies": {
|
||||
@@ -69,6 +75,13 @@
|
||||
"désir",
|
||||
"aspiration",
|
||||
"une aspiration douloureuse"
|
||||
],
|
||||
"measure.count": "~2 000 milliards de galaxies",
|
||||
"feel.distance": [
|
||||
"loin",
|
||||
"distance",
|
||||
"éloignement",
|
||||
"un éloignement exquis"
|
||||
]
|
||||
},
|
||||
"cosmos_orion": {
|
||||
@@ -108,7 +121,19 @@
|
||||
"étoile du Trapèze",
|
||||
"Trapèze · étoiles O chaudes illuminant la nébuleuse"
|
||||
],
|
||||
"measure.distance": "≈1 344 al"
|
||||
"measure.distance": "≈1 344 al",
|
||||
"feel.tenderness": [
|
||||
"doux",
|
||||
"tendresse",
|
||||
"chaleur",
|
||||
"la chaleur d’un berceau"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
]
|
||||
},
|
||||
"cosmos_tarantula": {
|
||||
"feel.wonder": [
|
||||
@@ -147,7 +172,13 @@
|
||||
"R136",
|
||||
"R136 · concentre certaines des étoiles les plus massives connues"
|
||||
],
|
||||
"measure.distance": "≈160 000 al"
|
||||
"measure.distance": "≈160 000 al",
|
||||
"feel.turbulence": [
|
||||
"remous",
|
||||
"turbulence",
|
||||
"ferment",
|
||||
"un ferment violent"
|
||||
]
|
||||
},
|
||||
"cosmos_westerlund": {
|
||||
"feel.wonder": [
|
||||
@@ -186,7 +217,19 @@
|
||||
"étoile de type O",
|
||||
"O-type · blanc-bleu, des dizaines de masses solaires"
|
||||
],
|
||||
"measure.distance": "≈20 000 al"
|
||||
"measure.distance": "≈20 000 al",
|
||||
"feel.radiance": [
|
||||
"éclat",
|
||||
"rayonnement",
|
||||
"scintillement",
|
||||
"un scintillement de joyaux"
|
||||
],
|
||||
"feel.exhilaration": [
|
||||
"youpi",
|
||||
"exaltation",
|
||||
"euphorie",
|
||||
"une euphorie qui s'envole"
|
||||
]
|
||||
},
|
||||
"cosmos_southernring": {
|
||||
"feel.wonder": [
|
||||
@@ -225,7 +268,19 @@
|
||||
"naine blanche",
|
||||
"naine blanche · le cœur stellaire chaud laissé derrière"
|
||||
],
|
||||
"measure.distance": "≈2 500 al"
|
||||
"measure.distance": "≈2 500 al",
|
||||
"feel.mortality": [
|
||||
"fin",
|
||||
"mortalité",
|
||||
"impermanence",
|
||||
"la lente mort d’une étoile"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"pincement",
|
||||
"émotion poignante",
|
||||
"douceur amère",
|
||||
"un adieu lumineux"
|
||||
]
|
||||
},
|
||||
"cosmos_carina_eso": {
|
||||
"feel.wonder": [
|
||||
@@ -264,7 +319,13 @@
|
||||
"étoile massive",
|
||||
"étoile massive · celles qui sculptent les falaises de la Carène"
|
||||
],
|
||||
"measure.distance": "≈7 500 al"
|
||||
"measure.distance": "≈7 500 al",
|
||||
"feel.abundance": [
|
||||
"plein",
|
||||
"abondance",
|
||||
"richesse",
|
||||
"une richesse grouillante"
|
||||
]
|
||||
},
|
||||
"orbit_planetearth": {
|
||||
"detected.cloud_band": [
|
||||
@@ -303,6 +364,18 @@
|
||||
"distance",
|
||||
"éloignement",
|
||||
"un éloignement exquis"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"éclat",
|
||||
"rayonnement",
|
||||
"scintillement",
|
||||
"un scintillement de joyaux"
|
||||
]
|
||||
},
|
||||
"orbit_bluemarble": {
|
||||
@@ -312,6 +385,18 @@
|
||||
"planète tellurique",
|
||||
"planète tellurique · 12 742 km de diamètre"
|
||||
],
|
||||
"detected.cloud_band": [
|
||||
"nuages",
|
||||
"systèmes météo",
|
||||
"systèmes nuageux",
|
||||
"systèmes nuageux planétaires · les nuages couvrent ~67% de la Terre"
|
||||
],
|
||||
"detected.starfield": [
|
||||
"étoiles",
|
||||
"champ d’étoiles",
|
||||
"étoiles d’arrière-plan",
|
||||
"champ d’étoiles de fond · un décor rendu, hors échelle"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
@@ -335,6 +420,12 @@
|
||||
"distance",
|
||||
"éloignement",
|
||||
"un éloignement exquis"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"orbit_aurora2025": {
|
||||
@@ -374,7 +465,25 @@
|
||||
"limbe atmosphérique",
|
||||
"limbe atmosphérique · ~100 km d'air, brillant sur la tranche"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
],
|
||||
"feel.eeriness": [
|
||||
"étrange",
|
||||
"étrangeté",
|
||||
"une charge surnaturelle",
|
||||
"un frisson électrique et surnaturel"
|
||||
]
|
||||
},
|
||||
"orbit_citylights": {
|
||||
"feel.serenity": [
|
||||
@@ -413,7 +522,19 @@
|
||||
"luminescence atmosphérique",
|
||||
"luminescence atmosphérique · la faible émission nocturne de la haute atmosphère"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.tenderness": [
|
||||
"doux",
|
||||
"tendresse",
|
||||
"chaleur",
|
||||
"la chaleur d’un berceau"
|
||||
],
|
||||
"feel.longing": [
|
||||
"envie",
|
||||
"désir",
|
||||
"aspiration",
|
||||
"une aspiration douloureuse"
|
||||
]
|
||||
},
|
||||
"orbit_helene": {
|
||||
"feel.serenity": [
|
||||
@@ -452,7 +573,25 @@
|
||||
"limbe atmosphérique",
|
||||
"limbe atmosphérique · la fine pellicule où vit la météo"
|
||||
],
|
||||
"measure.altitude": "~408 km"
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"remous",
|
||||
"turbulence",
|
||||
"ferment",
|
||||
"un ferment violent"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"orbit_epic": {
|
||||
"feel.serenity": [
|
||||
@@ -491,7 +630,13 @@
|
||||
"systèmes météorologiques",
|
||||
"systèmes météorologiques · tourbillonnant sur une planète qui tourne"
|
||||
],
|
||||
"measure.distance": "~1,5 M km"
|
||||
"measure.distance": "~1,5 M km",
|
||||
"feel.insignificance": [
|
||||
"petit",
|
||||
"petitesse",
|
||||
"insignifiance",
|
||||
"une insignifiance qui rend humble"
|
||||
]
|
||||
},
|
||||
"sky_grca_templesa": {
|
||||
"feel.exhilaration": [
|
||||
@@ -529,6 +674,30 @@
|
||||
"strates rocheuses",
|
||||
"bancs sédimentaires",
|
||||
"strates · des époques de dépôt empilées"
|
||||
],
|
||||
"detected.butte": [
|
||||
"tour",
|
||||
"butte-témoin",
|
||||
"temple rocheux",
|
||||
"‘temple’ rocheux · une butte-témoin laissée par l’érosion"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"sky_greenland_landice": {
|
||||
@@ -567,6 +736,30 @@
|
||||
"champ de neige",
|
||||
"névé",
|
||||
"névé · vieille neige se compactant en glace glaciaire"
|
||||
],
|
||||
"detected.crevasse": [
|
||||
"fissures",
|
||||
"crevasses",
|
||||
"crevasses glaciaires",
|
||||
"crevasses · la glace se fracture en s’écoulant"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
],
|
||||
"feel.purity": [
|
||||
"pur",
|
||||
"pureté",
|
||||
"quiétude",
|
||||
"un silence glaciaire"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
]
|
||||
},
|
||||
"sky_greenland_suture": {
|
||||
@@ -605,6 +798,30 @@
|
||||
"chenal",
|
||||
"chenal libre",
|
||||
"chenal · une fracture d'eau libre entre les floes"
|
||||
],
|
||||
"detected.floe": [
|
||||
"plaques",
|
||||
"floes",
|
||||
"floes de banquise",
|
||||
"floes · plaques de mer gelée à la dérive"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
],
|
||||
"feel.solitude": [
|
||||
"seul",
|
||||
"solitude",
|
||||
"isolement",
|
||||
"une vaste solitude gelée"
|
||||
]
|
||||
},
|
||||
"sky_jungle_amazon": {
|
||||
@@ -643,6 +860,24 @@
|
||||
"grand arbre",
|
||||
"arbre émergent",
|
||||
"émergent · un géant perçant au-dessus de la canopée"
|
||||
],
|
||||
"detected.mist": [
|
||||
"brume",
|
||||
"brume de canopée",
|
||||
"brume de transpiration",
|
||||
"brume de transpiration · la forêt exhalant sa vapeur"
|
||||
],
|
||||
"feel.verdancy": [
|
||||
"luxuriant",
|
||||
"verdure",
|
||||
"abondance",
|
||||
"une abondance verte et grouillante"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
]
|
||||
},
|
||||
"sky_jungle_waterfall": {
|
||||
@@ -681,6 +916,24 @@
|
||||
"canopée de jungle",
|
||||
"canopée de forêt tropicale",
|
||||
"canopée · forêt dense pressée autour de la gorge"
|
||||
],
|
||||
"detected.spray": [
|
||||
"embruns",
|
||||
"embruns",
|
||||
"embruns de chute",
|
||||
"embruns de chute · la rivière pulvérisée à l’impact"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.freshness": [
|
||||
"frais",
|
||||
"fraîcheur",
|
||||
"vitalité",
|
||||
"une fraîcheur en cascade"
|
||||
]
|
||||
},
|
||||
"sky_coast_cliffspain": {
|
||||
@@ -719,6 +972,30 @@
|
||||
"ressac",
|
||||
"houle déferlante",
|
||||
"houle déferlante · l'océan à la rencontre de la pierre"
|
||||
],
|
||||
"detected.headland": [
|
||||
"pointe",
|
||||
"promontoire",
|
||||
"promontoire rocheux",
|
||||
"promontoire · un bras de terre escarpé dans la mer"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"remous",
|
||||
"turbulence",
|
||||
"ferment",
|
||||
"un ferment violent"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"sky_mtn_castlecrags": {
|
||||
@@ -757,6 +1034,24 @@
|
||||
"forêt de conifères",
|
||||
"forêt montagnarde",
|
||||
"forêt montagnarde · habillant les pentes sous les pics"
|
||||
],
|
||||
"detected.talus": [
|
||||
"éboulis",
|
||||
"talus",
|
||||
"talus d’éboulis",
|
||||
"éboulis · roches gélifractées entassées sous les aiguilles"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"sky_mtn_rocky": {
|
||||
@@ -795,6 +1090,24 @@
|
||||
"toundra alpine",
|
||||
"toundra alpine",
|
||||
"toundra alpine · plantes en coussin basses au-dessus des arbres"
|
||||
],
|
||||
"detected.snowfield": [
|
||||
"neige",
|
||||
"névé",
|
||||
"névé alpin",
|
||||
"névé alpin · neige persistante de haute altitude"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"grandiose",
|
||||
"le sublime",
|
||||
"grandeur",
|
||||
"une grandeur de cathédrale"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"coast_birdrock": {
|
||||
@@ -833,6 +1146,24 @@
|
||||
"mélancolie",
|
||||
"langueur",
|
||||
"une douce langueur tournée vers le large"
|
||||
],
|
||||
"detected.seabirds": [
|
||||
"oiseaux",
|
||||
"oiseaux marins",
|
||||
"oiseaux marins nicheurs",
|
||||
"colonie d’oiseaux marins · la rookerie qui nomme le rocher"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
]
|
||||
},
|
||||
"coast_surfgrass": {
|
||||
@@ -871,6 +1202,30 @@
|
||||
"mélancolie",
|
||||
"langueur",
|
||||
"une douce langueur tournée vers le large"
|
||||
],
|
||||
"detected.tidepool": [
|
||||
"mare",
|
||||
"cuvette de marée",
|
||||
"mare intertidale",
|
||||
"cuvette de marée · une mer de poche découverte à marée basse"
|
||||
],
|
||||
"feel.abundance": [
|
||||
"plein",
|
||||
"abondance",
|
||||
"richesse",
|
||||
"une richesse grouillante"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"dedans",
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"regarde",
|
||||
"curiosité",
|
||||
"fascination",
|
||||
"une fascination absorbée"
|
||||
]
|
||||
},
|
||||
"coast_kelp": {
|
||||
@@ -909,6 +1264,36 @@
|
||||
"lame",
|
||||
"fronde de varech",
|
||||
"fronde · des flotteurs remplis de gaz la maintiennent droite"
|
||||
],
|
||||
"detected.pneumatocyst": [
|
||||
"flotteurs",
|
||||
"vésicules à gaz",
|
||||
"pneumatocystes",
|
||||
"pneumatocystes · des flotteurs à gaz qui hissent les lames vers la lumière"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"dedans",
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
],
|
||||
"feel.freedom": [
|
||||
"libre",
|
||||
"liberté",
|
||||
"délivrance",
|
||||
"une délivrance sans limites"
|
||||
]
|
||||
},
|
||||
"coast_otters": {
|
||||
@@ -947,6 +1332,24 @@
|
||||
"estuaire",
|
||||
"chenal de marée",
|
||||
"chenal de marée · eaux de nourricerie abritées"
|
||||
],
|
||||
"detected.fur": [
|
||||
"fourrure",
|
||||
"fourrure dense",
|
||||
"la fourrure la plus dense",
|
||||
"la fourrure la plus dense au monde · ~1 M de poils/in², sans graisse"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"doux",
|
||||
"tendresse",
|
||||
"chaleur",
|
||||
"la chaleur d’un berceau"
|
||||
],
|
||||
"feel.delight": [
|
||||
"amusant",
|
||||
"ravissement",
|
||||
"joie",
|
||||
"une joie vive et frétillante"
|
||||
]
|
||||
},
|
||||
"coast_kalaloch": {
|
||||
@@ -985,6 +1388,18 @@
|
||||
"aiguille marine",
|
||||
"aiguille côtière",
|
||||
"aiguille marine · roche résiduelle taillée par les vagues"
|
||||
],
|
||||
"detected.sunset": [
|
||||
"lueur",
|
||||
"coucher de soleil",
|
||||
"heure dorée",
|
||||
"heure dorée · le soleil bas rougi par plus d’atmosphère"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
]
|
||||
},
|
||||
"coast_seals": {
|
||||
@@ -1023,6 +1438,30 @@
|
||||
"rocher reposoir",
|
||||
"rocher intertidal",
|
||||
"reposoir · une corniche de repos balayée par la marée"
|
||||
],
|
||||
"detected.whiskers": [
|
||||
"moustaches",
|
||||
"vibrisses",
|
||||
"moustaches sensorielles",
|
||||
"vibrisses · des moustaches qui sentent les proies en eau trouble"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"doux",
|
||||
"tendresse",
|
||||
"chaleur",
|
||||
"la chaleur d’un berceau"
|
||||
],
|
||||
"feel.repose": [
|
||||
"repos",
|
||||
"quiétude",
|
||||
"somnolence",
|
||||
"une somnolence réchauffée de soleil"
|
||||
],
|
||||
"feel.delight": [
|
||||
"amusant",
|
||||
"ravissement",
|
||||
"joie",
|
||||
"une joie vive et frétillante"
|
||||
]
|
||||
},
|
||||
"coast_mist": {
|
||||
@@ -1061,6 +1500,30 @@
|
||||
"rocher de rivage",
|
||||
"rocher côtier",
|
||||
"rocher côtier · le bord dressé de la terre"
|
||||
],
|
||||
"detected.swell": [
|
||||
"vagues",
|
||||
"houle",
|
||||
"houle océanique",
|
||||
"houle · des vagues nées de tempêtes lointaines"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.hush": [
|
||||
"silence",
|
||||
"calme",
|
||||
"quiétude",
|
||||
"un silence retenu"
|
||||
]
|
||||
},
|
||||
"reef_lionfish": {
|
||||
@@ -1100,6 +1563,18 @@
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"feel.poise": [
|
||||
"immobile",
|
||||
"aplomb",
|
||||
"grâce",
|
||||
"un aplomb suspendu et ouvragé"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
]
|
||||
},
|
||||
"reef_spawning": {
|
||||
@@ -1139,6 +1614,18 @@
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"feel.flow": [
|
||||
"aller",
|
||||
"flux",
|
||||
"courant",
|
||||
"un flux de banc qui ondule"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"reef_hawkfish": {
|
||||
@@ -1172,6 +1659,18 @@
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"detected.coral": [
|
||||
"corail",
|
||||
"corail récifal",
|
||||
"corail dur",
|
||||
"corail dur · le récif que ce poisson broie en sable"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"éclat",
|
||||
"rayonnement",
|
||||
"scintillement",
|
||||
"un scintillement de joyaux"
|
||||
]
|
||||
},
|
||||
"reef_coralspacific": {
|
||||
@@ -1210,6 +1709,24 @@
|
||||
"immersion",
|
||||
"absorption",
|
||||
"une absorption retenue, à couper le souffle"
|
||||
],
|
||||
"detected.polyp": [
|
||||
"polypes",
|
||||
"polypes coralliens",
|
||||
"polypes vivants",
|
||||
"polypes · chacun un animal minuscule, bâtisseurs de la colonie"
|
||||
],
|
||||
"feel.intricacy": [
|
||||
"fin",
|
||||
"complexité",
|
||||
"détail",
|
||||
"une densité tissée et complexe"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
]
|
||||
},
|
||||
"reef_redsea": {
|
||||
@@ -1249,7 +1766,19 @@
|
||||
"anthias",
|
||||
"anthias · nuages orange de mangeurs de plancton au-dessus du récif"
|
||||
],
|
||||
"measure.depth": "−10 m"
|
||||
"measure.depth": "−10 m",
|
||||
"feel.serenity": [
|
||||
"calme",
|
||||
"sérénité",
|
||||
"paix",
|
||||
"une paix tranquille et sans pesanteur"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"éclat",
|
||||
"rayonnement",
|
||||
"scintillement",
|
||||
"un scintillement de joyaux"
|
||||
]
|
||||
},
|
||||
"reef_flowergarden": {
|
||||
"feel.delight": [
|
||||
@@ -1288,7 +1817,13 @@
|
||||
"Abudefduf saxatilis",
|
||||
"Abudefduf · la demoiselle rayée “sergent-major”"
|
||||
],
|
||||
"measure.depth": "−20 m"
|
||||
"measure.depth": "−20 m",
|
||||
"feel.freedom": [
|
||||
"libre",
|
||||
"liberté",
|
||||
"délivrance",
|
||||
"une délivrance sans limites"
|
||||
]
|
||||
},
|
||||
"abyss_wow": {
|
||||
"detected.jelly": [
|
||||
@@ -1327,6 +1862,12 @@
|
||||
"effroi",
|
||||
"pressentiment",
|
||||
"un pressentiment lent et glacé"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"grand",
|
||||
"vastitude",
|
||||
"immensité",
|
||||
"une immensité vertigineuse"
|
||||
]
|
||||
},
|
||||
"abyss_midwaterexp": {
|
||||
@@ -1366,6 +1907,18 @@
|
||||
"effroi",
|
||||
"pressentiment",
|
||||
"un pressentiment lent et glacé"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"pincement",
|
||||
"émotion poignante",
|
||||
"douceur amère",
|
||||
"un adieu lumineux"
|
||||
]
|
||||
},
|
||||
"abyss_hiding": {
|
||||
@@ -1399,6 +1952,24 @@
|
||||
"effroi",
|
||||
"pressentiment",
|
||||
"un pressentiment lent et glacé"
|
||||
],
|
||||
"detected.marinesnow": [
|
||||
"points",
|
||||
"neige marine",
|
||||
"détritus tombants",
|
||||
"neige marine · débris organiques descendant des couches supérieures"
|
||||
],
|
||||
"feel.spectral": [
|
||||
"fantôme",
|
||||
"le spectral",
|
||||
"hantise",
|
||||
"un fantôme dans l’eau"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
]
|
||||
},
|
||||
"abyss_bigfin": {
|
||||
@@ -1432,7 +2003,19 @@
|
||||
"Magnapinna",
|
||||
"Magnapinna · des bras coudés traînant sur des mètres dans l'obscurité"
|
||||
],
|
||||
"measure.depth": "−2 000 m"
|
||||
"measure.depth": "−2 000 m",
|
||||
"detected.arms": [
|
||||
"bras",
|
||||
"bras coudés",
|
||||
"filaments traînants",
|
||||
"bras coudés · tendus puis traînant des mètres de fil"
|
||||
],
|
||||
"feel.alienness": [
|
||||
"bizarre",
|
||||
"l’étrangeté",
|
||||
"altérité",
|
||||
"une grâce extraterrestre"
|
||||
]
|
||||
},
|
||||
"abyss_dandelion": {
|
||||
"feel.unease": [
|
||||
@@ -1465,7 +2048,31 @@
|
||||
"siphonophore pissenlit",
|
||||
"Rhodaliidae · une colonie de clones amarrée au fond marin"
|
||||
],
|
||||
"measure.depth": "−2 500 m"
|
||||
"measure.depth": "−2 500 m",
|
||||
"detected.tentacle": [
|
||||
"fils",
|
||||
"tentacules",
|
||||
"tentacules de capture",
|
||||
"tentacules de capture · un filet dérivant pour les proies"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"éclat",
|
||||
"rayonnement",
|
||||
"scintillement",
|
||||
"un scintillement de joyaux"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"ouah",
|
||||
"émerveillement",
|
||||
"saisissement",
|
||||
"un saisissement transcendant"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"mince",
|
||||
"fragilité",
|
||||
"vulnérabilité",
|
||||
"une tendre fragilité"
|
||||
]
|
||||
},
|
||||
"abyss_octopus": {
|
||||
"feel.unease": [
|
||||
@@ -1498,7 +2105,25 @@
|
||||
"Graneledone",
|
||||
"Graneledone boreopacifica · couve ses œufs pendant ~4,5 ans"
|
||||
],
|
||||
"measure.depth": "−2 500 m"
|
||||
"measure.depth": "−2 500 m",
|
||||
"detected.arms": [
|
||||
"bras",
|
||||
"huit bras",
|
||||
"bras à ventouses",
|
||||
"huit bras · garnis de ventouses qui goûtent ce qu’elles touchent"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"regarde",
|
||||
"curiosité",
|
||||
"fascination",
|
||||
"une fascination absorbée"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"doux",
|
||||
"tendresse",
|
||||
"chaleur",
|
||||
"la chaleur d’un berceau"
|
||||
]
|
||||
},
|
||||
"abyss_seapig": {
|
||||
"feel.unease": [
|
||||
@@ -1531,6 +2156,24 @@
|
||||
"Enypniastes eximia",
|
||||
"Enypniastes · un concombre de mer nageur, le “poulet sans tête”"
|
||||
],
|
||||
"measure.depth": "−2 700 m"
|
||||
"measure.depth": "−2 700 m",
|
||||
"detected.veil": [
|
||||
"voile",
|
||||
"voile oral",
|
||||
"voile natatoire",
|
||||
"voile oral · balaie le sédiment et bat pour nager"
|
||||
],
|
||||
"feel.strangeness": [
|
||||
"étrange",
|
||||
"bizarrerie",
|
||||
"l’inquiétant",
|
||||
"une étrangeté charnue"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"regarde",
|
||||
"curiosité",
|
||||
"fascination",
|
||||
"une fascination absorbée"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+658
-15
@@ -36,6 +36,12 @@
|
||||
"憧れ",
|
||||
"切望",
|
||||
"胸を締めつける切望"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
]
|
||||
},
|
||||
"cosmos_galaxies": {
|
||||
@@ -69,6 +75,13 @@
|
||||
"憧れ",
|
||||
"切望",
|
||||
"胸を締めつける切望"
|
||||
],
|
||||
"measure.count": "約2兆個の銀河",
|
||||
"feel.distance": [
|
||||
"遠い",
|
||||
"隔たり",
|
||||
"遥かさ",
|
||||
"この上なく美しい遥かさ"
|
||||
]
|
||||
},
|
||||
"cosmos_orion": {
|
||||
@@ -108,7 +121,19 @@
|
||||
"Trapezium星",
|
||||
"Trapezium · 星雲を照らす高温のO-type星"
|
||||
],
|
||||
"measure.distance": "≈1,344 光年"
|
||||
"measure.distance": "≈1,344 光年",
|
||||
"feel.tenderness": [
|
||||
"やさしい",
|
||||
"優しさ",
|
||||
"ぬくもり",
|
||||
"ゆりかごのようなぬくもり"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
]
|
||||
},
|
||||
"cosmos_tarantula": {
|
||||
"feel.wonder": [
|
||||
@@ -147,7 +172,13 @@
|
||||
"R136",
|
||||
"R136 · 知られている中で最も重い部類の星々を抱える"
|
||||
],
|
||||
"measure.distance": "≈160,000 光年"
|
||||
"measure.distance": "≈160,000 光年",
|
||||
"feel.turbulence": [
|
||||
"渦巻き",
|
||||
"乱流",
|
||||
"胎動",
|
||||
"激しい胎動"
|
||||
]
|
||||
},
|
||||
"cosmos_westerlund": {
|
||||
"feel.wonder": [
|
||||
@@ -186,7 +217,19 @@
|
||||
"O-type星",
|
||||
"O-type · 青白い、太陽の数十倍の質量"
|
||||
],
|
||||
"measure.distance": "≈20,000 光年"
|
||||
"measure.distance": "≈20,000 光年",
|
||||
"feel.radiance": [
|
||||
"輝き",
|
||||
"光輝",
|
||||
"煌めき",
|
||||
"宝石のような煌めき"
|
||||
],
|
||||
"feel.exhilaration": [
|
||||
"わーい",
|
||||
"高揚",
|
||||
"歓喜",
|
||||
"舞い上がる歓喜"
|
||||
]
|
||||
},
|
||||
"cosmos_southernring": {
|
||||
"feel.wonder": [
|
||||
@@ -225,7 +268,19 @@
|
||||
"白色矮星",
|
||||
"白色矮星 · 残された高温の星の中心核"
|
||||
],
|
||||
"measure.distance": "≈2,500 光年"
|
||||
"measure.distance": "≈2,500 光年",
|
||||
"feel.mortality": [
|
||||
"終わり",
|
||||
"死",
|
||||
"無常",
|
||||
"星のゆるやかな死"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"切なさ",
|
||||
"哀切",
|
||||
"ほろ苦さ",
|
||||
"光に満ちた別れ"
|
||||
]
|
||||
},
|
||||
"cosmos_carina_eso": {
|
||||
"feel.wonder": [
|
||||
@@ -264,7 +319,13 @@
|
||||
"重い星",
|
||||
"重い星 · カリーナの崖を彫り出すたぐいの星"
|
||||
],
|
||||
"measure.distance": "≈7,500 光年"
|
||||
"measure.distance": "≈7,500 光年",
|
||||
"feel.abundance": [
|
||||
"いっぱい",
|
||||
"豊かさ",
|
||||
"充溢",
|
||||
"あふれんばかりの充溢"
|
||||
]
|
||||
},
|
||||
"orbit_planetearth": {
|
||||
"detected.cloud_band": [
|
||||
@@ -303,6 +364,18 @@
|
||||
"隔たり",
|
||||
"遥かさ",
|
||||
"この上なく美しい遥かさ"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"輝き",
|
||||
"光輝",
|
||||
"煌めき",
|
||||
"宝石のような煌めき"
|
||||
]
|
||||
},
|
||||
"orbit_bluemarble": {
|
||||
@@ -312,6 +385,18 @@
|
||||
"岩石惑星",
|
||||
"岩石惑星 · 直径12,742 km"
|
||||
],
|
||||
"detected.cloud_band": [
|
||||
"雲",
|
||||
"気象システム",
|
||||
"雲システム",
|
||||
"地球規模の雲システム · 雲は地表の約67%を覆う"
|
||||
],
|
||||
"detected.starfield": [
|
||||
"星",
|
||||
"星野",
|
||||
"背景の星",
|
||||
"背景の星野 · 描画された背景で実寸ではない"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
@@ -335,6 +420,12 @@
|
||||
"隔たり",
|
||||
"遥かさ",
|
||||
"この上なく美しい遥かさ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"orbit_aurora2025": {
|
||||
@@ -374,7 +465,25 @@
|
||||
"大気の縁",
|
||||
"大気の縁 · 約100 kmの大気層が縁で光る"
|
||||
],
|
||||
"measure.altitude": "約408 km"
|
||||
"measure.altitude": "約408 km",
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
],
|
||||
"feel.eeriness": [
|
||||
"異様",
|
||||
"不気味さ",
|
||||
"異世界の気配",
|
||||
"電気を帯びた異世界の静けさ"
|
||||
]
|
||||
},
|
||||
"orbit_citylights": {
|
||||
"feel.serenity": [
|
||||
@@ -413,7 +522,19 @@
|
||||
"超高層の大気光",
|
||||
"大気光 · 高層大気がかすかに放つ夜間の光"
|
||||
],
|
||||
"measure.altitude": "約408 km"
|
||||
"measure.altitude": "約408 km",
|
||||
"feel.tenderness": [
|
||||
"やさしい",
|
||||
"優しさ",
|
||||
"ぬくもり",
|
||||
"ゆりかごのようなぬくもり"
|
||||
],
|
||||
"feel.longing": [
|
||||
"欲しい",
|
||||
"憧れ",
|
||||
"切望",
|
||||
"胸を締めつける切望"
|
||||
]
|
||||
},
|
||||
"orbit_helene": {
|
||||
"feel.serenity": [
|
||||
@@ -452,7 +573,25 @@
|
||||
"大気の縁",
|
||||
"大気の縁 · 気象が宿る薄い層"
|
||||
],
|
||||
"measure.altitude": "約408 km"
|
||||
"measure.altitude": "約408 km",
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"渦巻き",
|
||||
"乱流",
|
||||
"胎動",
|
||||
"激しい胎動"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"orbit_epic": {
|
||||
"feel.serenity": [
|
||||
@@ -491,7 +630,13 @@
|
||||
"気象系",
|
||||
"気象系 · 回転する惑星の上を渦巻く"
|
||||
],
|
||||
"measure.distance": "約150万 km"
|
||||
"measure.distance": "約150万 km",
|
||||
"feel.insignificance": [
|
||||
"小さい",
|
||||
"小ささ",
|
||||
"ちっぽけさ",
|
||||
"謙虚にさせるちっぽけさ"
|
||||
]
|
||||
},
|
||||
"sky_grca_templesa": {
|
||||
"feel.exhilaration": [
|
||||
@@ -529,6 +674,30 @@
|
||||
"岩層",
|
||||
"堆積層",
|
||||
"地層 · 積み重なった堆積の時代"
|
||||
],
|
||||
"detected.butte": [
|
||||
"塔",
|
||||
"残丘",
|
||||
"岩の神殿",
|
||||
"岩の‘神殿’ · 浸食に取り残された残丘"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"sky_greenland_landice": {
|
||||
@@ -567,6 +736,30 @@
|
||||
"雪原",
|
||||
"フィルン",
|
||||
"フィルン · 氷河の氷へと締まっていく古い雪"
|
||||
],
|
||||
"detected.crevasse": [
|
||||
"亀裂",
|
||||
"クレバス",
|
||||
"氷河の裂け目",
|
||||
"クレバス · 流れる氷が割れてできた裂け目"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
],
|
||||
"feel.purity": [
|
||||
"清らか",
|
||||
"純粋さ",
|
||||
"静寂",
|
||||
"氷河のような静寂"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
]
|
||||
},
|
||||
"sky_greenland_suture": {
|
||||
@@ -605,6 +798,30 @@
|
||||
"水路",
|
||||
"リード",
|
||||
"リード · 氷盤の間に開いた水の裂け目"
|
||||
],
|
||||
"detected.floe": [
|
||||
"板状氷",
|
||||
"氷盤",
|
||||
"流氷の氷盤",
|
||||
"氷盤 · 漂う凍った海の板"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
],
|
||||
"feel.solitude": [
|
||||
"ひとり",
|
||||
"孤独",
|
||||
"隔絶",
|
||||
"凍てつく広大な孤独"
|
||||
]
|
||||
},
|
||||
"sky_jungle_amazon": {
|
||||
@@ -643,6 +860,24 @@
|
||||
"高い木",
|
||||
"突出木",
|
||||
"突出木 · 樹冠の天井を突き抜ける巨木"
|
||||
],
|
||||
"detected.mist": [
|
||||
"もや",
|
||||
"林冠のもや",
|
||||
"蒸散のもや",
|
||||
"蒸散のもや · 森が吐き出す水蒸気"
|
||||
],
|
||||
"feel.verdancy": [
|
||||
"緑豊か",
|
||||
"青々しさ",
|
||||
"繁茂",
|
||||
"生い茂る緑の豊かさ"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
]
|
||||
},
|
||||
"sky_jungle_waterfall": {
|
||||
@@ -681,6 +916,24 @@
|
||||
"ジャングルの樹冠",
|
||||
"熱帯雨林の樹冠",
|
||||
"樹冠 · 峡谷に密集する濃い森"
|
||||
],
|
||||
"detected.spray": [
|
||||
"水しぶき",
|
||||
"しぶき",
|
||||
"落下のしぶき",
|
||||
"落下のしぶき · 衝突で霧化した川"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.freshness": [
|
||||
"涼しい",
|
||||
"清々しさ",
|
||||
"生命力",
|
||||
"滝のように降りそそぐ清涼"
|
||||
]
|
||||
},
|
||||
"sky_coast_cliffspain": {
|
||||
@@ -719,6 +972,30 @@
|
||||
"打ち寄せる波",
|
||||
"砕けるうねり",
|
||||
"砕けるうねり · 海が岩に出会う"
|
||||
],
|
||||
"detected.headland": [
|
||||
"岬",
|
||||
"岬",
|
||||
"岩の岬",
|
||||
"岬 · 海へ突き出す断崖の地"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
],
|
||||
"feel.turbulence": [
|
||||
"渦巻き",
|
||||
"乱流",
|
||||
"胎動",
|
||||
"激しい胎動"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"sky_mtn_castlecrags": {
|
||||
@@ -757,6 +1034,24 @@
|
||||
"針葉樹林",
|
||||
"山地林",
|
||||
"山地林 · 岩峰の下の斜面を覆う"
|
||||
],
|
||||
"detected.talus": [
|
||||
"岩屑",
|
||||
"タルス",
|
||||
"岩屑の斜面",
|
||||
"タルス · 凍結破砕した岩が岩峰の下に堆積"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"sky_mtn_rocky": {
|
||||
@@ -795,6 +1090,24 @@
|
||||
"ツンドラ",
|
||||
"高山ツンドラ",
|
||||
"高山ツンドラ · 樹林の上に育つ低いクッション状の植物"
|
||||
],
|
||||
"detected.snowfield": [
|
||||
"雪",
|
||||
"雪原",
|
||||
"高山の雪原",
|
||||
"高山の雪原 · 標高の高い場所に残る雪"
|
||||
],
|
||||
"feel.sublime": [
|
||||
"雄大",
|
||||
"崇高",
|
||||
"荘厳",
|
||||
"大聖堂のような荘厳さ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"coast_birdrock": {
|
||||
@@ -833,6 +1146,24 @@
|
||||
"憂い",
|
||||
"恋しさ",
|
||||
"海へと向かう柔らかな恋しさ"
|
||||
],
|
||||
"detected.seabirds": [
|
||||
"鳥",
|
||||
"海鳥",
|
||||
"営巣する海鳥",
|
||||
"海鳥のコロニー · 岩の名の由来となる集団繁殖地"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
]
|
||||
},
|
||||
"coast_surfgrass": {
|
||||
@@ -871,6 +1202,30 @@
|
||||
"憂い",
|
||||
"恋しさ",
|
||||
"海へと向かう柔らかな恋しさ"
|
||||
],
|
||||
"detected.tidepool": [
|
||||
"潮だまり",
|
||||
"タイドプール",
|
||||
"潮間帯の池",
|
||||
"潮だまり · 干潮で現れる小さな海"
|
||||
],
|
||||
"feel.abundance": [
|
||||
"いっぱい",
|
||||
"豊かさ",
|
||||
"充溢",
|
||||
"あふれんばかりの充溢"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"中へ",
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"見て",
|
||||
"好奇心",
|
||||
"魅了",
|
||||
"のめり込む魅了"
|
||||
]
|
||||
},
|
||||
"coast_kelp": {
|
||||
@@ -909,6 +1264,36 @@
|
||||
"葉状体",
|
||||
"ケルプの葉",
|
||||
"葉状部 · ガスを含んだ浮き袋が直立させる"
|
||||
],
|
||||
"detected.pneumatocyst": [
|
||||
"浮き",
|
||||
"気胞",
|
||||
"気胞(ニューマトシスト)",
|
||||
"気胞 · 葉を光へ持ち上げるガスの浮き"
|
||||
],
|
||||
"feel.immersion": [
|
||||
"中へ",
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
],
|
||||
"feel.freedom": [
|
||||
"自由",
|
||||
"自由さ",
|
||||
"解放",
|
||||
"果てしない解放"
|
||||
]
|
||||
},
|
||||
"coast_otters": {
|
||||
@@ -947,6 +1332,24 @@
|
||||
"河口",
|
||||
"潮汐の入り江",
|
||||
"潮の入り江 · 守られた育成の水域"
|
||||
],
|
||||
"detected.fur": [
|
||||
"毛皮",
|
||||
"密な毛皮",
|
||||
"世界一密な毛皮",
|
||||
"世界一密な毛皮 · 約100万本/in²、脂肪層なし"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"やさしい",
|
||||
"優しさ",
|
||||
"ぬくもり",
|
||||
"ゆりかごのようなぬくもり"
|
||||
],
|
||||
"feel.delight": [
|
||||
"楽しい",
|
||||
"喜び",
|
||||
"歓び",
|
||||
"ちらちらと輝く歓び"
|
||||
]
|
||||
},
|
||||
"coast_kalaloch": {
|
||||
@@ -985,6 +1388,18 @@
|
||||
"海食柱",
|
||||
"海岸の海食柱",
|
||||
"海食柱 · 波に削り残された岩"
|
||||
],
|
||||
"detected.sunset": [
|
||||
"輝き",
|
||||
"夕焼け",
|
||||
"ゴールデンアワー",
|
||||
"ゴールデンアワー · 大気を長く通り赤らむ低い太陽"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
]
|
||||
},
|
||||
"coast_seals": {
|
||||
@@ -1023,6 +1438,30 @@
|
||||
"上陸岩",
|
||||
"潮間帯の岩",
|
||||
"上陸地 · 潮に洗われる休息の岩棚"
|
||||
],
|
||||
"detected.whiskers": [
|
||||
"ひげ",
|
||||
"触毛",
|
||||
"感覚のひげ",
|
||||
"触毛 · 濁った水中で獲物を感じ取るひげ"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"やさしい",
|
||||
"優しさ",
|
||||
"ぬくもり",
|
||||
"ゆりかごのようなぬくもり"
|
||||
],
|
||||
"feel.repose": [
|
||||
"休息",
|
||||
"安らぎ",
|
||||
"まどろみ",
|
||||
"陽だまりのまどろみ"
|
||||
],
|
||||
"feel.delight": [
|
||||
"楽しい",
|
||||
"喜び",
|
||||
"歓び",
|
||||
"ちらちらと輝く歓び"
|
||||
]
|
||||
},
|
||||
"coast_mist": {
|
||||
@@ -1061,6 +1500,30 @@
|
||||
"岸の岩",
|
||||
"海岸の岩",
|
||||
"海岸の岩 · 陸の立つ縁"
|
||||
],
|
||||
"detected.swell": [
|
||||
"波",
|
||||
"うねり",
|
||||
"海のうねり",
|
||||
"うねり · 遠くの嵐が生んだ波"
|
||||
],
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.hush": [
|
||||
"静か",
|
||||
"静けさ",
|
||||
"沈黙",
|
||||
"息をひそめた静けさ"
|
||||
]
|
||||
},
|
||||
"reef_lionfish": {
|
||||
@@ -1100,6 +1563,18 @@
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"feel.poise": [
|
||||
"静止",
|
||||
"気品",
|
||||
"優雅",
|
||||
"宙に浮く華麗な気品"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
]
|
||||
},
|
||||
"reef_spawning": {
|
||||
@@ -1139,6 +1614,18 @@
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"feel.flow": [
|
||||
"流れ",
|
||||
"フロー",
|
||||
"奔流",
|
||||
"群れが織りなすうねり"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"reef_hawkfish": {
|
||||
@@ -1172,6 +1659,18 @@
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"detected.coral": [
|
||||
"サンゴ",
|
||||
"礁サンゴ",
|
||||
"造礁サンゴ",
|
||||
"造礁サンゴ · この魚が削って砂にする礁"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"輝き",
|
||||
"光輝",
|
||||
"煌めき",
|
||||
"宝石のような煌めき"
|
||||
]
|
||||
},
|
||||
"reef_coralspacific": {
|
||||
@@ -1210,6 +1709,24 @@
|
||||
"没入",
|
||||
"没頭",
|
||||
"息を呑む没頭"
|
||||
],
|
||||
"detected.polyp": [
|
||||
"ポリプ",
|
||||
"サンゴのポリプ",
|
||||
"生きたポリプ",
|
||||
"ポリプ · 一つ一つが小さな動物、群体の建設者"
|
||||
],
|
||||
"feel.intricacy": [
|
||||
"精緻",
|
||||
"複雑さ",
|
||||
"細部",
|
||||
"織り込まれた緻密さ"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
]
|
||||
},
|
||||
"reef_redsea": {
|
||||
@@ -1249,7 +1766,19 @@
|
||||
"ハナダイ",
|
||||
"ハナダイ · 礁の上を漂う、プランクトンを食むオレンジの群れ"
|
||||
],
|
||||
"measure.depth": "−10 m"
|
||||
"measure.depth": "−10 m",
|
||||
"feel.serenity": [
|
||||
"穏やか",
|
||||
"静けさ",
|
||||
"安らぎ",
|
||||
"静かで無重力の安らぎ"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"輝き",
|
||||
"光輝",
|
||||
"煌めき",
|
||||
"宝石のような煌めき"
|
||||
]
|
||||
},
|
||||
"reef_flowergarden": {
|
||||
"feel.delight": [
|
||||
@@ -1288,7 +1817,13 @@
|
||||
"Abudefduf saxatilis",
|
||||
"Abudefduf · 縞模様の「sergeant major」スズメダイ"
|
||||
],
|
||||
"measure.depth": "−20 m"
|
||||
"measure.depth": "−20 m",
|
||||
"feel.freedom": [
|
||||
"自由",
|
||||
"自由さ",
|
||||
"解放",
|
||||
"果てしない解放"
|
||||
]
|
||||
},
|
||||
"abyss_wow": {
|
||||
"detected.jelly": [
|
||||
@@ -1327,6 +1862,12 @@
|
||||
"恐れ",
|
||||
"胸騒ぎ",
|
||||
"ゆっくりと冷たい胸騒ぎ"
|
||||
],
|
||||
"feel.vastness": [
|
||||
"大きい",
|
||||
"広大さ",
|
||||
"無限の広がり",
|
||||
"めまいを誘う無限の広がり"
|
||||
]
|
||||
},
|
||||
"abyss_midwaterexp": {
|
||||
@@ -1366,6 +1907,18 @@
|
||||
"恐れ",
|
||||
"胸騒ぎ",
|
||||
"ゆっくりと冷たい胸騒ぎ"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
],
|
||||
"feel.poignancy": [
|
||||
"切なさ",
|
||||
"哀切",
|
||||
"ほろ苦さ",
|
||||
"光に満ちた別れ"
|
||||
]
|
||||
},
|
||||
"abyss_hiding": {
|
||||
@@ -1399,6 +1952,24 @@
|
||||
"恐れ",
|
||||
"胸騒ぎ",
|
||||
"ゆっくりと冷たい胸騒ぎ"
|
||||
],
|
||||
"detected.marinesnow": [
|
||||
"粒子",
|
||||
"マリンスノー",
|
||||
"落下する有機物",
|
||||
"マリンスノー · 上層から沈む有機物の屑"
|
||||
],
|
||||
"feel.spectral": [
|
||||
"亡霊",
|
||||
"幽玄",
|
||||
"亡霊めいた気配",
|
||||
"水中の亡霊"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
]
|
||||
},
|
||||
"abyss_bigfin": {
|
||||
@@ -1432,7 +2003,19 @@
|
||||
"Magnapinna",
|
||||
"Magnapinna · 肘のように曲がる腕を闇へ何メートルも垂らす"
|
||||
],
|
||||
"measure.depth": "−2,000 m"
|
||||
"measure.depth": "−2,000 m",
|
||||
"detected.arms": [
|
||||
"腕",
|
||||
"肘状の腕",
|
||||
"垂れ下がる糸",
|
||||
"肘状の腕 · 横に張り出し、数メートルの糸を引く"
|
||||
],
|
||||
"feel.alienness": [
|
||||
"奇妙",
|
||||
"異質さ",
|
||||
"異形",
|
||||
"異形の優雅さ"
|
||||
]
|
||||
},
|
||||
"abyss_dandelion": {
|
||||
"feel.unease": [
|
||||
@@ -1465,7 +2048,31 @@
|
||||
"タンポポクダクラゲ",
|
||||
"Rhodaliidae · 海底につながれたクローンの群体"
|
||||
],
|
||||
"measure.depth": "−2,500 m"
|
||||
"measure.depth": "−2,500 m",
|
||||
"detected.tentacle": [
|
||||
"糸",
|
||||
"触手",
|
||||
"捕食の触手",
|
||||
"捕食の触手 · 獲物を捕らえる漂う網"
|
||||
],
|
||||
"feel.radiance": [
|
||||
"輝き",
|
||||
"光輝",
|
||||
"煌めき",
|
||||
"宝石のような煌めき"
|
||||
],
|
||||
"feel.wonder": [
|
||||
"わあ",
|
||||
"驚き",
|
||||
"畏敬",
|
||||
"超越的な畏敬"
|
||||
],
|
||||
"feel.fragility": [
|
||||
"薄い",
|
||||
"儚さ",
|
||||
"脆さ",
|
||||
"いたわりたくなる儚さ"
|
||||
]
|
||||
},
|
||||
"abyss_octopus": {
|
||||
"feel.unease": [
|
||||
@@ -1498,7 +2105,25 @@
|
||||
"Graneledone",
|
||||
"Graneledone boreopacifica · 卵を約4.5年抱き続ける"
|
||||
],
|
||||
"measure.depth": "−2,500 m"
|
||||
"measure.depth": "−2,500 m",
|
||||
"detected.arms": [
|
||||
"腕",
|
||||
"八本の腕",
|
||||
"吸盤の並ぶ腕",
|
||||
"八本の腕 · 触れたものを味わう吸盤付き"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"見て",
|
||||
"好奇心",
|
||||
"魅了",
|
||||
"のめり込む魅了"
|
||||
],
|
||||
"feel.tenderness": [
|
||||
"やさしい",
|
||||
"優しさ",
|
||||
"ぬくもり",
|
||||
"ゆりかごのようなぬくもり"
|
||||
]
|
||||
},
|
||||
"abyss_seapig": {
|
||||
"feel.unease": [
|
||||
@@ -1531,6 +2156,24 @@
|
||||
"Enypniastes eximia",
|
||||
"Enypniastes · 泳ぐナマコ、「頭のない鶏」"
|
||||
],
|
||||
"measure.depth": "−2,700 m"
|
||||
"measure.depth": "−2,700 m",
|
||||
"detected.veil": [
|
||||
"ヴェール",
|
||||
"口前のヴェール",
|
||||
"遊泳用のヴェール",
|
||||
"口前のヴェール · 堆積物を掃き、羽ばたいて泳ぐ"
|
||||
],
|
||||
"feel.strangeness": [
|
||||
"変",
|
||||
"奇妙さ",
|
||||
"不気味",
|
||||
"肉感的な奇妙さ"
|
||||
],
|
||||
"feel.curiosity": [
|
||||
"見て",
|
||||
"好奇心",
|
||||
"魅了",
|
||||
"のめり込む魅了"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user