Compare commits

..

35 Commits

Author SHA1 Message Date
BenStullsBets 1a6ffc74a1 feat(load): phase-1 preload gate + eligible-pick pool growth
Gate the universe on one clip per altitude + connecting morphs (~126 MB, ~10-20s),
then grow the pool in the background. The random pick (pickPoolClip/pickRandomMember)
is restricted to fully-loaded clips — base + connecting morphs both ways cached
(HEFPreload.eligibleDestinations) — so a transition never stalls on un-downloaded
media and new clips become reachable only once fully loaded. Pure planner in
preload.js (+ node tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:55:46 -07:00
BenStullsBets 380c2d13e7 Merge origin/main into feat/accessibility-pass (reconcile static-publish + a11y with main)
# Conflicts:
#	simulator/static/app.js
2026-06-30 09:47:40 -07:00
BenStullsBets ff06783173 add sessions/0033/SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--2026-06-30T09-43.md + replace placeholder/variant SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--INPROGRESS.md 2026-06-30 09:44:55 -07:00
BenStullsBets 87d605db39 Merge design/cloudflare-static-publish into main: Cloudflare static publish + CC credits
Integrates the fully-static Cloudflare publish line (live at
benstull.art/human-experience-simulator) into main: the static build (Pages + R2,
manifest-only media tree, baked API JSON), the "Run simulation" start button, the
public CC-BY/BY-SA credits page with the "License & reuse" CC BY-SA 4.0 derivative
declaration (Option A + D1), the repeatable Cloudflare deploy script, and assorted
load/audio fixes (progressive boot, scaleAudioUrl via mediaBase).

Clean auto-merge with main's per-clip affect/feelings + i18n work — only app.js and
altitude-lock.spec.ts overlapped, both auto-resolved. Verified green on the merged
tree: node 29, pytest 315 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:42:44 -07:00
BenStullsBets 75961ea18e claim human-experience-filter-art session 0033 (placeholder) + sessions.json entry 2026-06-30 09:39:18 -07:00
BenStullsBets b83758fbca feat(content): about.html — intent, provenance, honest limits; static-build copy
English-first page mirroring credits.html (for-everyone / tech arc
underwater→drones→space / built-with-LLMs / honest-it's-imperfect). Header
link + about.link i18n key; about.html + flash.js added to PUBLIC_ASSETS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:37:00 -07:00
BenStullsBets 61bd4a7943 Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 09:36:17 -07:00
BenStullsBets 79105a3ef9 feat(a11y): wire flash-clamp into autoScrub; document 3-flash/sec audit
Clamp each per-altitude step to the WCAG 2.3.1 floor (no-op at 1200ms, guards
a future lowering). Audit: #black fade + audio crossfade can't exceed 3/sec, no
clamp needed. boot() now seeds the gate-dismissed flag (returning visitor) so
the run-sim test isn't blocked by the new interstitial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:34:10 -07:00
BenStullsBets 2d54023f94 chore(i18n): re-extract en.json catalog to match per-clip affect
Keeps the translator source-of-record in sync with the manifest's English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:46 -07:00
BenStullsBets bf1013be74 feat(content): per-clip right-brain feelings drawn from each clip's footage
Affect was authored per SCALE — every clip in an altitude shared the same 4
emotion words. Now every clip carries its OWN feelings, read from that specific
footage (mood, motion, light), so e.g. the violent Tarantula nebula evokes "a
violent ferment" while the dying-star Southern Ring evokes "a star's slow dying /
a luminous farewell," and a drowsing seal haul-out evokes "a sun-warmed drowse."

Adds AFFECT_CLIP (per-clip override; falls back to the scale register) covering
all 41 clips — 41 distinct feeling-sets, one per clip. Keeps each scale's
emotional family as a base and swaps in 1-2 footage-specific feelings, reusing the
existing feel.* palette where it fits and adding 19 new nuance keys (sublime,
tenderness, turbulence, radiance, mortality, poignancy, eeriness, purity,
solitude, verdancy, freshness, repose, hush, poise, flow, intricacy, spectral,
alienness, strangeness) with full es/fr/ja. 42 distinct feeling keys now in use.

Drafted by viewing each clip's footage (per-scale passes); first-pass reads that
want operator by-eye review. Data-only (build_pool_manifest.py + manifest + i18n
catalogs). Verified: 34/34 catalog+i18n+build pytest, 16/16 node unit, 4-language
parity for every clip, live render shows distinct feelings per clip (incl. new
keys) with Think/Feel overlap still 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:21 -07:00
BenStullsBets f1ce23c4fe feat(a11y): aria-live narration of scale/clip; hide decorative SVGs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:28:19 -07:00
BenStullsBets 0db602ebd0 feat(a11y): keyboard + ARIA slider semantics for the Altitude dial
role=slider + tabindex on the dial; Arrow/Home/End stepping; labels become
Enter/Space buttons; aria-valuenow/valuetext track the committed altitude via
renderDial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:26:45 -07:00
BenStullsBets fa3bbef0d6 add sessions/0032/SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--2026-06-30T09-24.md + replace placeholder/variant SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--INPROGRESS.md 2026-06-30 09:26:01 -07:00
BenStullsBets 717bf5b08b feat(a11y): reduced-motion freeze-to-stills with OS-default toggle
Single guard in playLoop() holds a still frame; autoScrub jumps instantly;
applyReduceMotion pauses/resumes. Default from prefers-reduced-motion, then
the choice persists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:24:00 -07:00
BenStullsBets 64e8f658f6 claim human-experience-filter-art session 0032 (placeholder) + sessions.json entry 2026-06-30 09:21:27 -07:00
BenStullsBets 6cf8fffc51 feat(a11y): one-time photosensitivity warning gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:18:04 -07:00
BenStullsBets 1f98b7bd8c feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:16:02 -07:00
BenStullsBets 6e2e65202e feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:15:41 -07:00
BenStullsBets fe77677d4c feat(a11y): move language picker below Audio, globe inline-left
Adds the Reduce-motion toggle markup + i18n label in the same Output
fieldset (wired in a later task). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:14:47 -07:00
BenStullsBets 760fdc7550 add sessions/0031/SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--2026-06-30T09-11.md + replace placeholder/variant SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--INPROGRESS.md 2026-06-30 09:13:37 -07:00
BenStullsBets a895e7139b plan: accessibility pass + About page implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:11:43 -07:00
BenStullsBets e62382c5e5 claim human-experience-filter-art session 0031 (placeholder) + sessions.json entry 2026-06-30 09:08:16 -07:00
BenStullsBets 041fcdeb7d fix(sim): keep Feel (affect) words from overlapping Think (label) chips
Denser left labels (now >=3/clip) plus the always-4 right affect words made the
two HUD layers collide. renderAffect now nudges each Feel word vertically off the
Think chip plates (and off already-placed words + the ANALYSIS status tag),
staying on-screen, keeping the least-overlapping spot if nothing is fully clear.

Feelings are scene-level (soft position) so we move THEM, never a chip off its
subject. Obstacles are computed from the manifest, not the DOM, and a tracked
label's path is sampled across the loop — so the clearance holds for the whole
loop even though the per-frame track re-render keeps moving the chips (no per-frame
re-layout, no jitter). New diagnostic seam window.__hefNoDeconflict (sibling of
__hefState) A/B-toggles it.

Verified across all 6 scales at max knobs: authored overlaps drop to 0 with none
introduced (cosmos 3->0), and a tracked abyss clip stays at 0 overlaps sampled
over loop time as its chip drifts. No affect-position test exists to break;
16/16 node unit + frontend pytest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:06:36 -07:00
BenStullsBets 31956bfdfc add sessions/0030/SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--2026-06-30T08-50.md + replace placeholder/variant SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--INPROGRESS.md 2026-06-30 08:53:05 -07:00
BenStullsBets 5a08d1dd35 claim human-experience-filter-art session 0030 (placeholder) + sessions.json entry 2026-06-30 08:50:53 -07:00
BenStullsBets 768fee5ccd design: accessibility pass + About page (non-kiosk public web)
Reduced-motion freeze-to-stills, photosensitivity warning gate + flash
audit, keyboard/SR-operable Altitude dial, low-vision contrast, and a new
about.html (English-first). Targets WCAG 2.1 AA plus a motion/seizure layer
for the unattended public URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:49:02 -07:00
BenStullsBets 432fbd7703 add sessions/0029/SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--2026-06-30T08-41.md + replace placeholder/variant SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--INPROGRESS.md 2026-06-30 08:43:06 -07:00
BenStullsBets 4e4a5c256d Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 08:36:40 -07:00
BenStullsBets 8cfceffb76 feat(content): enrich left (Think) labels to >=3/clip + motion-track abyss creatures
Every clip now carries at least 3 factual LEFT annotations to match the 4 RIGHT
(Feel) affect words — fixes the "many clips missing Think labels" asymmetry
(right read "everywhere", left felt sparse). Adds one researched factual label to
each of the 23 thin clips (orbit_bluemarble had only 1; 22 had 2), with full
es/fr/ja parity via the catalogs, and converts the 4 still-static abyss creature
subjects (octopus, bigfin squid, dandelion siphonophore, sea pig) from fixed
boxes to 3-keyframe motion-tracked labels (tracked clips 7 -> 11).

Boxes/tracks are a first pass sampled by eye from base footage (t=0.05/0.5/0.95);
they want operator by-eye review in author mode to fine-tune positions. Remaining
static clips are landscapes (correctly static) plus 2 diffuse fish schools and 2
surface-animal scenes, available for author-mode tracking if wanted.

Data-only change (build_pool_manifest.py LABELS + manifest + i18n catalogs); no
app/engine code touched. Verified: 43/43 manifest+catalog+i18n pytest, 16/16 node
unit, live render shows 3 chips/clip in all 4 languages with no JS errors. (The 3
failing pytest are pre-existing cv2/ffmpeg-absent env limits, unrelated.)

Note: the reported "audio not playing" was a false alarm — exhaustive repro
(Chromium+WebKit, strict autoplay, both servers, RMS-verified non-silent files)
found the audio engine correct; the operator's Safari output was muted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:36:05 -07:00
BenStullsBets 5f8b8f14e6 claim human-experience-filter-art session 0029 (placeholder) + sessions.json entry 2026-06-30 08:12:39 -07:00
BenStullsBets 59c1c3a895 add sessions/0028/SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--2026-06-30T07-21.md + replace placeholder/variant SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--INPROGRESS.md 2026-06-30 07:23:18 -07:00
benstull 582183db2f Merge pull request 'fix(sim): kill the reverse-landing frame jump (D3)' (#30) from fix/d3-reverse-landing-jump into main 2026-06-30 14:17:56 +00:00
BenStullsBets c13ce36787 fix(sim): kill the reverse-landing frame jump (D3)
Ascending the altitude dial and landing on the higher-altitude clip jumped ~3
seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) ->
dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop
element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at
its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on
src at the morph's HEAD (src@0), so seeking the tail was a 3s jump.

Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir,
loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a
`landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap
handlers honor it (the base clips are already seamless crossfade-loops, so a
reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the
direction's frame so a fast ascent never jumps even if the heading clip wasn't
preloaded in time. App-side only — no 154-morph LFS re-bake.

Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop
anchors at the head (loopStart "0"), and the existing forward "loops from tail"
e2e still green (12/12 altitude-lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:16:47 -07:00
BenStullsBets 21830069ad claim human-experience-filter-art session 0028 (placeholder) + sessions.json entry 2026-06-30 06:56:42 -07:00
BenStullsBets 50366c056e add sessions/0027/SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--2026-06-30T06-47.md + replace placeholder/variant SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--INPROGRESS.md 2026-06-30 06:49:13 -07:00
31 changed files with 8646 additions and 2981 deletions
@@ -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 &amp; 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 AD; 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
```
+18
View File
@@ -79,5 +79,23 @@
},
"0027": {
"title": ""
},
"0028": {
"title": ""
},
"0029": {
"title": ""
},
"0030": {
"title": ""
},
"0031": {
"title": ""
},
"0032": {
"title": ""
},
"0033": {
"title": ""
}
}
+320 -8
View File
@@ -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 cradles 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 cradles 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 cradles 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 cradles 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 cradles 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 stars 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 colonys 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 canopys 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,
+116
View File
@@ -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();
});
});
+36 -1
View File
@@ -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())!;
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
+20
View File
@@ -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 };
});
+16
View File
@@ -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
});
+5
View File
@@ -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: "空" },
+26 -8
View File
@@ -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 &amp; 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 &amp; 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>
+52
View File
@@ -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 };
});
+13 -1
View File
@@ -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 };
});
+26 -3
View File
@@ -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;
+53
View File
@@ -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");
});
+14
View File
@@ -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);
});
+4 -3
View File
@@ -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
File diff suppressed because it is too large Load Diff
+658 -15
View File
@@ -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
View File
@@ -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 dun 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 dune é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 darriè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 dun 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 à limpact"
],
"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 doiseaux 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 dun 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 datmosphè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 dun 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 leau"
],
"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 quelles touchent"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun 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",
"linquiétant",
"une étrangeté charnue"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
]
}
}
+658 -15
View File
@@ -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": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
]
}
}