← back to Gallery Agentabrams
gallery.agentabrams.com: build gallery hub + Afterlight as Build 01
094706d7ba351c94b0510c818de725d018a8656c · 2026-09-01 07:49:48 -0700 · Steve Abrams
Static showcase modeled on games/graphics fleet pattern: sort+density grid,
build-number badges, hover-play video previews, preview overlay, open-in-own-window.
Build 01 = the Afterlight exhibition (self-contained, with Seedance motion clips).
DNS + Kamatera deploy scripted in deploy/ (Steve-gated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .gitignoreA README.mdA builds.jsonA builds/afterlight/README.mdA builds/afterlight/cover.jpgA builds/afterlight/cover.mp4A builds/afterlight/index.htmlA builds/afterlight/media/obj-1.jpgA builds/afterlight/media/obj-1.mp4A builds/afterlight/media/obj-2.jpgA builds/afterlight/media/obj-2.mp4A builds/afterlight/media/obj-3.jpgA builds/afterlight/media/obj-3.mp4A builds/afterlight/media/obj-4.jpgA builds/afterlight/media/obj-4.mp4A builds/afterlight/media/obj-5.jpgA builds/afterlight/media/obj-5.mp4A deploy/deploy.shA deploy/gallery.agentabrams.com.nginxA index.html
Diff
commit 094706d7ba351c94b0510c818de725d018a8656c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 07:49:48 2026 -0700
gallery.agentabrams.com: build gallery hub + Afterlight as Build 01
Static showcase modeled on games/graphics fleet pattern: sort+density grid,
build-number badges, hover-play video previews, preview overlay, open-in-own-window.
Build 01 = the Afterlight exhibition (self-contained, with Seedance motion clips).
DNS + Kamatera deploy scripted in deploy/ (Steve-gated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 9 +
README.md | 41 ++
builds.json | 16 +
builds/afterlight/README.md | 29 +
builds/afterlight/cover.jpg | Bin 0 -> 201458 bytes
builds/afterlight/cover.mp4 | Bin 0 -> 1995505 bytes
builds/afterlight/index.html | 1130 ++++++++++++++++++++++++++++++++++
builds/afterlight/media/obj-1.jpg | Bin 0 -> 201458 bytes
builds/afterlight/media/obj-1.mp4 | Bin 0 -> 1995505 bytes
builds/afterlight/media/obj-2.jpg | Bin 0 -> 242745 bytes
builds/afterlight/media/obj-2.mp4 | Bin 0 -> 3265094 bytes
builds/afterlight/media/obj-3.jpg | Bin 0 -> 124549 bytes
builds/afterlight/media/obj-3.mp4 | Bin 0 -> 779503 bytes
builds/afterlight/media/obj-4.jpg | Bin 0 -> 181666 bytes
builds/afterlight/media/obj-4.mp4 | Bin 0 -> 766463 bytes
builds/afterlight/media/obj-5.jpg | Bin 0 -> 77107 bytes
builds/afterlight/media/obj-5.mp4 | Bin 0 -> 1339467 bytes
deploy/deploy.sh | 48 ++
deploy/gallery.agentabrams.com.nginx | 41 ++
index.html | 237 +++++++
20 files changed, 1551 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..169aa3b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2e556f5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# gallery.agentabrams.com — Agent Abrams build gallery
+
+A public showcase of finished builds. Each build is **self-contained** (runs
+standalone in an iframe or its own window) and lives under `builds/<id>/`.
+Mirrors the `games.agentabrams.com` / `graphics.agentabrams.com` fleet pattern
+(Kamatera `/var/www/gallery.agentabrams.com`, nginx static, Cloudflare A-record).
+
+The gallery grid has **sort + density** (persisted to localStorage, per the DW
+grid rule), build-number badges, hover-play video previews, a Preview overlay,
+and **open-in-own-window** (the fleet hub UX).
+
+## Builds
+
+| № | Build | What |
+|---|-------|------|
+| 01 | Afterlight: Rooms Remember | Single-file digital exhibition — five objects (1928–2040), curator notes, provenance, conservation, audio transcripts, and Seedance motion studies. Compare mode, reading-progress, high-contrast, reduced-motion, keyboard-restorable reading position. |
+
+## Adding a build
+
+1. Drop a self-contained build at `builds/<id>/index.html` (plus any `media/`).
+ It must run standalone in an iframe and in its own window.
+2. Add an entry to `builds.json`:
+ ```json
+ { "num": N, "id": "<id>", "file": "<Name>.aa", "title": "…", "desc": "…",
+ "path": "builds/<id>/", "cover_video": "builds/<id>/cover.mp4",
+ "cover_poster": "builds/<id>/cover.jpg", "tags": ["…"], "added": "YYYY-MM-DD" }
+ ```
+3. Deploy (**Steve-gated**): `deploy/deploy.sh` (creates the DNS record + nginx
+ vhost + SSL on first run; re-runs just mirror content).
+
+## Run locally
+
+```
+# any static server from the repo root, e.g.
+python3 -m http.server 8792 # http://127.0.0.1:8792
+```
+
+## Go-live status
+
+DNS `gallery.agentabrams.com` is **not yet created** — first `deploy/deploy.sh`
+run creates it. That step is customer-facing + DNS, so it stays Steve-gated.
diff --git a/builds.json b/builds.json
new file mode 100644
index 0000000..5c090e7
--- /dev/null
+++ b/builds.json
@@ -0,0 +1,16 @@
+{
+ "builds": [
+ {
+ "num": 1,
+ "id": "afterlight",
+ "file": "Afterlight.aa",
+ "title": "Afterlight: Rooms Remember",
+ "desc": "A single-file digital exhibition — five objects (1928–2040), each with a curator note, material provenance, conservation status, an audio-transcript excerpt, and a Seedance motion study. Compare mode, reading-progress, high-contrast, reduced-motion, and keyboard-restorable reading position.",
+ "path": "builds/afterlight/",
+ "cover_video": "builds/afterlight/cover.mp4",
+ "cover_poster": "builds/afterlight/cover.jpg",
+ "tags": ["Editorial", "Single-file", "Canvas", "Seedance", "A11y"],
+ "added": "2026-09-01"
+ }
+ ]
+}
diff --git a/builds/afterlight/README.md b/builds/afterlight/README.md
new file mode 100644
index 0000000..1b60590
--- /dev/null
+++ b/builds/afterlight/README.md
@@ -0,0 +1,29 @@
+# Afterlight: Rooms Remember
+
+A single-file digital exhibition for an imaginary museum show — five objects
+(1928–2040), each with a curator note, material provenance, conservation status,
+and an audio-transcript excerpt.
+
+Open `index.html` in any browser. No build step, no dependencies.
+
+## Features
+- Cinematic, accessible title sequence (scroll-past, not a blocking overlay)
+- Chromatic timeline — one accent hue per era carries the 1928→2040 relationship
+- Object index (dialog) and user-selectable **Compare** mode (any two material histories, side by side)
+- Reading-progress indicator
+- Reduced-motion behavior (honors OS setting; manual toggle)
+- High-contrast toggle (pure #000/#fff, persisted)
+- Per-object **motion study** — a live, in-page Canvas composition in the object's era hue (no video files, no paid generation); freezes under reduced-motion
+- Graceful audio fallback — transcript is the primary artifact; a player appears only where a recording exists
+- Reading position is saved and restorable — **fully keyboard operable**
+- 390px: preserves narrative order and comparison utility (stacks, never reorders/hides)
+
+## Keyboard
+`1`–`5` jump to object · `↑`/`↓` (or `j`/`k`) move · `I`ndex · `C`ompare ·
+`H` contrast · `M` motion · `R` resume last position · `Home`/`End` · `Esc` closes.
+
+## Provenance
+Rebuilt and extended from a Model Arena starting artifact (Qwen3 14B). Fixed:
+default high-contrast bug, broken compare-modal markup, non-functional scroll
+progress, blocking title overlay, unusable keyboard nav, missing reading-position
+restore, and broken empty-`<audio>` players. Content is invented but plausible.
diff --git a/builds/afterlight/cover.jpg b/builds/afterlight/cover.jpg
new file mode 100644
index 0000000..a8724fb
Binary files /dev/null and b/builds/afterlight/cover.jpg differ
diff --git a/builds/afterlight/cover.mp4 b/builds/afterlight/cover.mp4
new file mode 100644
index 0000000..dcf8ab5
Binary files /dev/null and b/builds/afterlight/cover.mp4 differ
diff --git a/builds/afterlight/index.html b/builds/afterlight/index.html
new file mode 100644
index 0000000..ce21393
--- /dev/null
+++ b/builds/afterlight/index.html
@@ -0,0 +1,1130 @@
+<!DOCTYPE html>
+<html lang="en" data-motion="auto" data-contrast="normal">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>Afterlight: Rooms Remember — A Digital Exhibition</title>
+<meta name="description" content="Afterlight: Rooms Remember — a five-object exhibition on what interiors keep of us, 1928–2040." />
+<style>
+ /* ============================================================
+ AFTERLIGHT: ROOMS REMEMBER
+ A single-file digital exhibition.
+ Relationships are carried by typography, scale, and a
+ chromatic timeline (one accent hue per era) — not by cards
+ or gradients. Motion is restrained and fully opt-out.
+ ============================================================ */
+
+ :root {
+ --bg: #0b0b0d;
+ --bg-raise: #131317;
+ --ink: #ece7df; /* warm off-white */
+ --ink-dim: #9a948b;
+ --ink-faint: #6a655d;
+ --line: #2a2a30;
+ --line-soft: #1c1c21;
+ --focus: #ffd27a;
+
+ /* Chromatic timeline — one hue per object/era */
+ --era-1: #d8a24a; /* 1928 — amber */
+ --era-2: #5aa2e0; /* 2003 — signal blue */
+ --era-3: #46c0b0; /* 2015 — teal */
+ --era-4: #a98bd8; /* 2022 — violet */
+ --era-5: #8fd06a; /* 2040 — pale green */
+
+ --serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
+ --sans: 'Helvetica Neue', Arial, system-ui, sans-serif;
+
+ --maxw: 46rem;
+ --dur: .6s;
+ --ease: cubic-bezier(.2,.7,.2,1);
+ --accent: var(--ink); /* set per-section */
+ }
+
+ /* High-contrast toggle: maximize luminance contrast, drop tints,
+ thicken structural lines, underline links. */
+ html[data-contrast="high"] {
+ --bg: #000000;
+ --bg-raise: #000000;
+ --ink: #ffffff;
+ --ink-dim: #ffffff;
+ --ink-faint: #d8d8d8;
+ --line: #ffffff;
+ --line-soft: #bdbdbd;
+ --focus: #ffff00;
+ --era-1: #ffffff; --era-2:#ffffff; --era-3:#ffffff; --era-4:#ffffff; --era-5:#ffffff;
+ }
+ html[data-contrast="high"] a { text-decoration: underline; }
+ html[data-contrast="high"] .field-rule,
+ html[data-contrast="high"] .era-rule { height: 2px; }
+
+ * { box-sizing: border-box; }
+ html { scroll-behavior: smooth; }
+ html[data-motion="reduce"] { scroll-behavior: auto; }
+
+ body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 18px;
+ line-height: 1.65;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ overflow-x: hidden;
+ }
+
+ a { color: var(--ink); }
+
+ /* ---- Skip link ---- */
+ .skip {
+ position: fixed; left: 8px; top: -60px; z-index: 400;
+ background: var(--bg-raise); color: var(--ink);
+ border: 1px solid var(--line); padding: .6rem 1rem;
+ transition: top .2s;
+ }
+ .skip:focus { top: 8px; }
+
+ /* ---- Focus visibility ---- */
+ :focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 3px;
+ }
+
+ /* ============ TOP BAR ============ */
+ .bar {
+ position: fixed; inset: 0 0 auto 0; z-index: 200;
+ display: flex; align-items: center; gap: 1rem;
+ padding: .55rem clamp(1rem, 4vw, 2.5rem);
+ background: color-mix(in srgb, var(--bg) 86%, transparent);
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--line-soft);
+ }
+ html[data-contrast="high"] .bar { background: #000; }
+
+ .bar .wordmark {
+ font-family: var(--serif);
+ font-size: 1.02rem;
+ letter-spacing: .06em;
+ white-space: nowrap;
+ }
+ .bar .wordmark b { font-weight: 600; }
+ .bar .wordmark span { color: var(--ink-dim); font-style: italic; }
+
+ .bar .spacer { flex: 1; }
+
+ .bar nav { display: flex; gap: .15rem; }
+ .btn {
+ font-family: var(--sans);
+ font-size: .8rem; letter-spacing: .04em;
+ color: var(--ink-dim);
+ background: transparent;
+ border: 1px solid transparent;
+ padding: .4rem .6rem; border-radius: 2px;
+ cursor: pointer;
+ transition: color .2s, border-color .2s, background .2s;
+ white-space: nowrap;
+ }
+ .btn:hover, .btn:focus-visible { color: var(--ink); border-color: var(--line); }
+ .btn[aria-pressed="true"] { color: var(--bg); background: var(--ink); border-color: var(--ink); }
+ html[data-contrast="high"] .btn[aria-pressed="true"] { color: #000; background: #fff; }
+
+ /* Progress meter lives under the bar */
+ .progress {
+ position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 201;
+ background: transparent; pointer-events: none;
+ }
+ .progress i {
+ display: block; height: 100%; width: 0%;
+ background: var(--ink);
+ transition: width .1s linear;
+ }
+ html[data-motion="reduce"] .progress i { transition: none; }
+
+ /* ============ RESUME BANNER ============ */
+ .resume {
+ position: fixed; z-index: 199;
+ left: 50%; transform: translateX(-50%);
+ bottom: 1.25rem;
+ display: none;
+ align-items: center; gap: 1rem;
+ background: var(--bg-raise);
+ border: 1px solid var(--line);
+ padding: .7rem 1rem;
+ max-width: min(92vw, 34rem);
+ box-shadow: 0 10px 40px rgba(0,0,0,.5);
+ }
+ .resume.show { display: flex; }
+ .resume p { margin: 0; font-size: .85rem; color: var(--ink-dim); }
+ .resume p b { color: var(--ink); font-family: var(--serif); }
+ .resume .btn { border-color: var(--line); color: var(--ink); }
+
+ /* ============ TITLE SEQUENCE ============ */
+ .hero {
+ min-height: 100vh;
+ display: flex; flex-direction: column; justify-content: center;
+ padding: 7rem clamp(1.25rem, 6vw, 5rem) 4rem;
+ position: relative;
+ }
+ .hero .kicker {
+ font-size: .78rem; letter-spacing: .32em; text-transform: uppercase;
+ color: var(--ink-faint); margin: 0 0 2rem;
+ }
+ .hero h1 {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(2.8rem, 11vw, 8.5rem);
+ line-height: .95;
+ letter-spacing: -.01em;
+ margin: 0;
+ }
+ .hero h1 em { font-style: italic; color: var(--ink-dim); }
+ .hero .sub {
+ font-family: var(--serif); font-style: italic;
+ font-size: clamp(1.1rem, 3.4vw, 1.9rem);
+ color: var(--ink-dim);
+ margin: 1.5rem 0 0; max-width: 30ch;
+ }
+ .hero .thesis {
+ margin: 2.6rem 0 0; max-width: var(--maxw);
+ color: var(--ink); font-size: 1.05rem;
+ }
+ .hero .meta {
+ margin-top: 2.2rem; display: flex; flex-wrap: wrap; gap: .6rem 1.6rem;
+ font-size: .8rem; letter-spacing: .06em; color: var(--ink-faint);
+ text-transform: uppercase;
+ }
+ .hero .hint {
+ margin-top: 3rem; font-size: .82rem; color: var(--ink-faint);
+ }
+ .hero .hint kbd {
+ font-family: var(--sans); font-size: .74rem;
+ border: 1px solid var(--line); border-radius: 3px;
+ padding: .05rem .4rem; color: var(--ink-dim);
+ background: var(--bg-raise);
+ }
+
+ /* Entrance animation (opt-out) */
+ html[data-motion="animate"] .hero .kicker,
+ html[data-motion="animate"] .hero h1 .ln,
+ html[data-motion="animate"] .hero .sub,
+ html[data-motion="animate"] .hero .thesis,
+ html[data-motion="animate"] .hero .meta,
+ html[data-motion="animate"] .hero .hint {
+ opacity: 0;
+ transform: translateY(14px);
+ animation: rise 1s var(--ease) forwards;
+ }
+ html[data-motion="animate"] .hero h1 .ln { transform: translateY(24px); }
+ html[data-motion="animate"] .hero .kicker { animation-delay: .1s; }
+ html[data-motion="animate"] .hero h1 .ln:nth-child(1) { animation-delay: .35s; }
+ html[data-motion="animate"] .hero h1 .ln:nth-child(2) { animation-delay: .55s; }
+ html[data-motion="animate"] .hero .sub { animation-delay: .95s; }
+ html[data-motion="animate"] .hero .thesis { animation-delay: 1.15s; }
+ html[data-motion="animate"] .hero .meta { animation-delay: 1.35s; }
+ html[data-motion="animate"] .hero .hint { animation-delay: 1.55s; }
+ @keyframes rise { to { opacity: 1; transform: translateY(0); } }
+
+ /* ============ OBJECTS ============ */
+ main { display: block; }
+
+ .object {
+ position: relative;
+ padding: 8vh clamp(1.25rem, 6vw, 5rem);
+ max-width: 68rem; margin: 0 auto;
+ scroll-margin-top: 5rem;
+ border-top: 1px solid var(--line-soft);
+ }
+
+ .object .index-tag {
+ font-size: .75rem; letter-spacing: .28em; text-transform: uppercase;
+ color: var(--ink-faint);
+ display: flex; align-items: center; gap: .8rem;
+ margin: 0 0 1.2rem;
+ }
+ .object .index-tag .dot {
+ width: 9px; height: 9px; border-radius: 50%;
+ background: var(--accent);
+ }
+
+ /* Oversized era numeral drives the timeline reading by scale + hue */
+ .object .year {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(4.5rem, 20vw, 15rem);
+ line-height: .8;
+ letter-spacing: -.02em;
+ color: var(--accent);
+ margin: 0;
+ }
+ .object h2 {
+ font-family: var(--serif); font-weight: 400;
+ font-size: clamp(1.7rem, 5vw, 3.1rem);
+ line-height: 1.05;
+ margin: .5rem 0 0;
+ max-width: 20ch;
+ }
+ .object .curator {
+ font-size: clamp(1.05rem, 2.6vw, 1.3rem);
+ line-height: 1.6;
+ margin: 1.8rem 0 0;
+ max-width: var(--maxw);
+ color: var(--ink);
+ }
+
+ /* Motion study — a live, in-page canvas composition per object.
+ No video files, no paid generation; restrained era-tinted motion. */
+ .motion {
+ margin: 2.2rem 0 0; max-width: var(--maxw);
+ position: relative;
+ background: #000;
+ border: 1px solid var(--line-soft);
+ border-top: 2px solid var(--accent);
+ overflow: hidden;
+ }
+ .motion video, .motion canvas { display: block; width: 100%; aspect-ratio: 16 / 7; object-fit: cover; }
+ .motion canvas[hidden] { display: none; }
+ html[data-motion="reduce"] .motion video { filter: saturate(.9); }
+ .motion figcaption {
+ position: absolute; left: .8rem; bottom: .6rem;
+ font-size: .64rem; letter-spacing: .2em; text-transform: uppercase;
+ color: var(--ink-dim);
+ text-shadow: 0 1px 4px #000, 0 0 2px #000;
+ pointer-events: none;
+ }
+ .motion .paused-note {
+ position: absolute; right: .8rem; bottom: .6rem;
+ font-size: .62rem; letter-spacing: .14em; text-transform: uppercase;
+ color: var(--ink-faint);
+ text-shadow: 0 1px 4px #000; display: none;
+ }
+ html[data-motion="reduce"] .motion .paused-note { display: block; }
+ html[data-contrast="high"] .motion { border: 1px solid #fff; border-top: 2px solid #fff; }
+
+ /* Provenance / conservation as editorial fields, not boxed cards */
+ .fields {
+ margin: 2.8rem 0 0;
+ display: grid; gap: 1.8rem;
+ grid-template-columns: 1fr 1fr;
+ max-width: var(--maxw);
+ }
+ .field { min-width: 0; }
+ .field .label {
+ font-size: .72rem; letter-spacing: .2em; text-transform: uppercase;
+ color: var(--ink-faint); margin: 0 0 .5rem;
+ }
+ .field-rule {
+ height: 1px; background: var(--accent); opacity: .8;
+ width: 2.2rem; margin: 0 0 .7rem;
+ }
+ .field .val { margin: 0; font-size: .96rem; color: var(--ink-dim); }
+ .field .val b { color: var(--ink); font-weight: 600; }
+
+ .status {
+ display: inline-flex; align-items: center; gap: .5rem;
+ font-size: .96rem; color: var(--ink);
+ }
+ .status .swatch {
+ width: 10px; height: 10px; border-radius: 2px;
+ box-shadow: inset 0 0 0 1px var(--line);
+ }
+ .status[data-s="stable"] .swatch { background: #6bbf7a; }
+ .status[data-s="fragile"] .swatch { background: #e0b055; }
+ .status[data-s="failed"] .swatch { background: #d0685e; }
+ .status[data-s="unstable"].swatch,
+ .status[data-s="unstable"] .swatch { background: #b07ad0; }
+ html[data-contrast="high"] .status .swatch { background: #fff; box-shadow: inset 0 0 0 1px #000; }
+
+ /* Audio transcript — transcript is the primary artifact; audio is
+ an enhancement that degrades gracefully. */
+ .audio {
+ margin: 2.8rem 0 0; max-width: var(--maxw);
+ border-top: 1px solid var(--line-soft);
+ padding-top: 1.6rem;
+ }
+ .audio .a-label {
+ font-size: .72rem; letter-spacing: .2em; text-transform: uppercase;
+ color: var(--ink-faint); margin: 0 0 1rem;
+ display: flex; align-items: center; gap: .8rem; flex-wrap: wrap;
+ }
+ .audio .a-label .src {
+ color: var(--accent); letter-spacing: .12em;
+ }
+ .audio blockquote {
+ margin: 0; font-family: var(--serif);
+ font-size: clamp(1.15rem, 3vw, 1.5rem); line-height: 1.5;
+ font-style: italic; color: var(--ink);
+ border-left: 2px solid var(--accent); padding-left: 1.2rem;
+ }
+ .audio .speaker {
+ margin: 1rem 0 0; font-size: .85rem; color: var(--ink-dim);
+ font-style: normal;
+ }
+ .audio .player { margin-top: 1.2rem; }
+ .audio audio { width: 100%; max-width: 22rem; }
+ .audio .fallback {
+ margin-top: 1rem; font-size: .82rem; color: var(--ink-faint);
+ display: flex; align-items: center; gap: .5rem;
+ }
+ .audio .fallback::before { content: "◇"; color: var(--accent); }
+
+ /* Scroll-reveal (opt-out) */
+ html[data-motion="animate"] .reveal {
+ opacity: 0; transform: translateY(20px);
+ transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
+ }
+ html[data-motion="animate"] .reveal.in { opacity: 1; transform: none; }
+
+ .colophon {
+ max-width: 68rem; margin: 0 auto;
+ padding: 10vh clamp(1.25rem, 6vw, 5rem) 14vh;
+ border-top: 1px solid var(--line-soft);
+ color: var(--ink-dim);
+ }
+ .colophon h2 {
+ font-family: var(--serif); font-weight: 400;
+ font-size: clamp(1.4rem, 4vw, 2.2rem); color: var(--ink); margin: 0 0 1rem;
+ }
+ .colophon p { max-width: var(--maxw); }
+
+ /* ============ DIALOGS (index + compare) ============ */
+ .overlay {
+ position: fixed; inset: 0; z-index: 300;
+ background: color-mix(in srgb, var(--bg) 72%, transparent);
+ backdrop-filter: blur(6px);
+ display: none; align-items: stretch; justify-content: flex-end;
+ }
+ html[data-contrast="high"] .overlay { background: rgba(0,0,0,.85); }
+ .overlay.open { display: flex; }
+ html[data-motion="animate"] .overlay.open .panel { animation: slidein .4s var(--ease); }
+ @keyframes slidein { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } }
+
+ .panel {
+ background: var(--bg-raise);
+ border-left: 1px solid var(--line);
+ width: min(30rem, 100%);
+ height: 100%; overflow-y: auto;
+ padding: 1.4rem clamp(1.25rem, 4vw, 2rem) 3rem;
+ }
+ .panel .p-head {
+ display: flex; align-items: baseline; justify-content: space-between;
+ gap: 1rem; margin: 0 0 1.5rem;
+ position: sticky; top: -1.4rem; background: var(--bg-raise);
+ padding: 1.4rem 0 .8rem; border-bottom: 1px solid var(--line-soft);
+ }
+ .panel h2 { font-family: var(--serif); font-weight: 400; font-size: 1.5rem; margin: 0; }
+
+ /* Index list */
+ .indexlist { list-style: none; margin: 0; padding: 0; }
+ .indexlist li { border-bottom: 1px solid var(--line-soft); }
+ .indexlist a {
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center;
+ gap: 1rem; padding: 1rem .2rem; text-decoration: none; color: var(--ink);
+ }
+ .indexlist a:hover, .indexlist a:focus-visible { background: color-mix(in srgb, var(--ink) 6%, transparent); }
+ .indexlist .n { font-family: var(--serif); font-size: 1.1rem; color: var(--accent, var(--ink-dim)); }
+ .indexlist .t { font-family: var(--serif); font-size: 1.05rem; }
+ .indexlist .t small { display: block; font-family: var(--sans); font-size: .74rem; color: var(--ink-faint); letter-spacing: .06em; text-transform: uppercase; }
+ .indexlist .y { font-size: .82rem; color: var(--ink-dim); }
+ .indexlist .dot { width: 8px; height: 8px; border-radius: 50%; }
+
+ /* Compare */
+ .compare-wide .panel { width: min(64rem, 100%); border-left: 1px solid var(--line); }
+ .cmp-pickers { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 0 0 1.6rem; }
+ .cmp-pick label { font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-faint); display: block; margin: 0 0 .4rem; }
+ .cmp-pick select {
+ width: 100%; font-family: var(--serif); font-size: 1rem;
+ background: var(--bg); color: var(--ink);
+ border: 1px solid var(--line); padding: .6rem .7rem; border-radius: 2px;
+ }
+ .cmp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4rem 2rem; }
+ .cmp-col { min-width: 0; border-top: 2px solid var(--col-accent, var(--line)); padding-top: 1rem; }
+ .cmp-col .c-year { font-family: var(--serif); font-size: 2.4rem; line-height: 1; color: var(--col-accent, var(--ink)); margin: 0; }
+ .cmp-col .c-title { font-family: var(--serif); font-size: 1.15rem; margin: .3rem 0 1.1rem; }
+ .cmp-row { margin: 0 0 1.1rem; }
+ .cmp-row .k { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-faint); margin: 0 0 .3rem; }
+ .cmp-row .v { margin: 0; font-size: .92rem; color: var(--ink-dim); }
+ .cmp-row .v b { color: var(--ink); }
+ .cmp-note { margin: 1.4rem 0 0; grid-column: 1 / -1; border-top: 1px solid var(--line-soft); padding-top: 1rem; font-size: .82rem; color: var(--ink-faint); }
+
+ /* Visually-hidden util */
+ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+ /* ============ 390px NARROW ============ */
+ @media (max-width: 640px) {
+ body { font-size: 17px; }
+ .bar { gap: .5rem; padding: .5rem .8rem; }
+ .bar .wordmark span { display: none; }
+ .bar nav { gap: 0; }
+ .btn { padding: .4rem .45rem; font-size: .74rem; }
+ /* keep narrative order + comparison utility: fields & compare stack,
+ never reorder or hide the objects. */
+ .fields { grid-template-columns: 1fr; gap: 1.4rem; }
+ .cmp-pickers { grid-template-columns: 1fr; }
+ .cmp-grid { grid-template-columns: 1fr; gap: 1.2rem; }
+ .cmp-col:nth-child(2) { border-top: 2px solid var(--col-accent, var(--line)); padding-top: 1rem; margin-top: .4rem; }
+ .panel, .compare-wide .panel { width: 100%; border-left: none; }
+ }
+
+ /* OS-level reduced motion is honored on first load via JS setting
+ data-motion; this is a belt-and-braces fallback. */
+ @media (prefers-reduced-motion: reduce) {
+ html:not([data-motion="animate"]) * { animation: none !important; transition: none !important; }
+ }
+</style>
+</head>
+<body>
+ <a class="skip" href="#obj-1">Skip to the first object</a>
+
+ <div class="progress" aria-hidden="true"><i id="progressFill"></i></div>
+
+ <!-- =============== TOP BAR =============== -->
+ <header class="bar">
+ <div class="wordmark"><b>Afterlight</b> <span>· Rooms Remember</span></div>
+ <div class="spacer"></div>
+ <nav aria-label="Exhibition controls">
+ <button class="btn" id="btnIndex" aria-haspopup="dialog" aria-controls="indexDialog">Index</button>
+ <button class="btn" id="btnCompare" aria-haspopup="dialog" aria-controls="compareDialog">Compare</button>
+ <button class="btn" id="btnContrast" aria-pressed="false" title="Toggle high contrast (H)">Contrast</button>
+ <button class="btn" id="btnMotion" aria-pressed="false" title="Toggle reduced motion (M)">Motion</button>
+ </nav>
+ </header>
+
+ <!-- =============== RESUME =============== -->
+ <div class="resume" id="resumeBanner" role="status">
+ <p>You were reading <b id="resumeLabel">—</b>.</p>
+ <button class="btn" id="btnResume">Resume (R)</button>
+ <button class="btn" id="btnDismissResume" aria-label="Dismiss">Dismiss</button>
+ </div>
+
+ <!-- =============== TITLE SEQUENCE =============== -->
+ <section class="hero" id="top" aria-labelledby="title">
+ <p class="kicker">A Digital Exhibition</p>
+ <h1 id="title"><span class="ln">Afterlight:</span> <span class="ln"><em>Rooms Remember</em></span></h1>
+ <p class="sub">What an interior keeps of the people who leave it.</p>
+ <p class="thesis">Five objects, made between 1928 and 2040, are gathered here not for what they do but for what they hold. Each was recovered from a room that outlived its use — a schoolroom, a control room, a chamber that shifts with mood. Read in sequence, they trace a single argument: <b>memory is a material property.</b> It accumulates in oak and optical fibre alike, and conservation is the quiet art of deciding what a room is allowed to forget.</p>
+ <div class="meta">
+ <span>5 Objects</span>
+ <span>1928 – 2040</span>
+ <span>Curated by the Department of Interiors</span>
+ </div>
+ <p class="hint">Navigate with <kbd>↑</kbd> <kbd>↓</kbd> or <kbd>1</kbd>–<kbd>5</kbd> · <kbd>I</kbd>ndex · <kbd>C</kbd>ompare · <kbd>H</kbd> contrast · <kbd>M</kbd>otion · <kbd>R</kbd>esume · <kbd>Esc</kbd> closes.</p>
+ </section>
+
+ <!-- =============== OBJECTS (rendered from data) =============== -->
+ <main id="objects"></main>
+
+ <section class="colophon" aria-label="About this exhibition">
+ <h2>On seeing these together</h2>
+ <p>The thesis of <em>Afterlight</em> is that rooms are recording instruments of the slowest kind. Place the amber of the Echo Bench beside the pale green of the Future Room and the exhibition’s ninety-year arc reads as a single gesture: from wood that remembers by scarring, to walls that remember by rewriting themselves. Use <b>Compare</b> to hold any two material histories side by side, or the <b>Index</b> to move between rooms. Your reading position is kept as you go — return any time with <b>R</b>.</p>
+ <p style="margin-top:1.4rem;font-size:.82rem;color:var(--ink-faint)">All objects, provenance, conservation notes, and audio transcripts are invented for this exhibition. No media files are bundled; each audio excerpt is presented as a transcript, with a player that appears only where a recording exists.</p>
+ </section>
+
+ <!-- =============== INDEX DIALOG =============== -->
+ <div class="overlay" id="indexDialog" role="dialog" aria-modal="true" aria-labelledby="indexTitle">
+ <div class="panel">
+ <div class="p-head">
+ <h2 id="indexTitle">Object Index</h2>
+ <button class="btn" data-close aria-label="Close index">Close ✕</button>
+ </div>
+ <ol class="indexlist" id="indexList"></ol>
+ </div>
+ </div>
+
+ <!-- =============== COMPARE DIALOG =============== -->
+ <div class="overlay compare-wide" id="compareDialog" role="dialog" aria-modal="true" aria-labelledby="compareTitle">
+ <div class="panel">
+ <div class="p-head">
+ <h2 id="compareTitle">Compare Material Histories</h2>
+ <button class="btn" data-close aria-label="Close compare">Close ✕</button>
+ </div>
+ <div class="cmp-pickers">
+ <div class="cmp-pick">
+ <label for="cmpLeft">Left work</label>
+ <select id="cmpLeft"></select>
+ </div>
+ <div class="cmp-pick">
+ <label for="cmpRight">Right work</label>
+ <select id="cmpRight"></select>
+ </div>
+ </div>
+ <div class="cmp-grid" id="cmpGrid"></div>
+ </div>
+ </div>
+
+<script>
+(function () {
+ "use strict";
+
+ /* ---------------- DATA (all invented, plausible) ---------------- */
+ const OBJECTS = [
+ {
+ id: 1, year: "1928", accent: "var(--era-1)",
+ title: "The Echo Bench",
+ curator: "Recovered from Saint Anselm’s Free Library after the reading-room fire of 1922, this bench was carried to a nearby schoolroom and used for six more years before the school itself closed. Along its seat, initials cut by children who studied there survive in the scorched grain — pressed deeper, the conservators note, wherever the wood had already been softened by heat. It is the earliest object in the exhibition and the plainest: a thing that remembers by being marked.",
+ material: "Salvaged white oak and ash with iron fixings, reclaimed from the 1922 Saint Anselm’s fire. Seat stabilised with microcrystalline wax; two ash rails are later (c. 1926) replacements.",
+ conservation: "Surface char mapped by raking light in 2019; no active flaking. Environmentally sensitive — held at 45% RH.",
+ status: "stable", statusLabel: "Stable",
+ transcript: "You can feel where the letters are before you can see them. The children pressed hardest into the parts the fire had already touched — as if the wood had been made ready to remember them.",
+ speaker: "Ana Reyes, Furniture Conservator — Oral History Reel 04 (2019)",
+ audio: null
+ },
+ {
+ id: 2, year: "2003", accent: "var(--era-2)",
+ title: "The Data Veil",
+ curator: "A translucent hanging woven with optical fibre, commissioned for the operations room of the Mid-Atlantic grid and salvaged after the room was decommissioned. Its threads once carried a live status feed; the makers preserved a single recording — the fourteen seconds of the 2003 cascade blackout — which the veil replays as a slow migration of light. It is a room’s memory of the moment it went dark.",
+ material: "Polyester ground woven with side-emitting optical fibre reclaimed from decommissioned grid switching gear; driver electronics from the original control desk.",
+ conservation: "Fragile. Roughly one fibre in nine no longer conducts; the 2003 loop is preserved on redundant media. Not to be flexed.",
+ status: "fragile", statusLabel: "Fragile",
+ transcript: "People think we kept the blackout because it was dramatic. We kept it because it was the only fourteen seconds the room ever agreed on. Everything before was routine. Everything after was argument.",
+ speaker: "T. Okafor, Systems Archivist — Oral History Reel 11 (2016)",
+ audio: null
+ },
+ {
+ id: 3, year: "2015", accent: "var(--era-3)",
+ title: "The Memory Lens",
+ curator: "An optical assembly modified in a since-closed perception laboratory to photograph, its makers claimed, only light already seen — reflections a room had, in some sense, held. The lens cannot be brought to focus; every exposure returns the same soft, familiar blur. Whether it records anything at all is unresolved, and the exhibition keeps that question open on purpose.",
+ material: "Ground glass and machined aluminium with custom sensor board, from the estate of the Coreman Perception Lab. Housing 3-D printed to a 2015 pattern.",
+ conservation: "Functional but intermittent — the sensor drops out below 18 °C. Powered only during supervised viewings.",
+ status: "failed", statusLabel: "Intermittent",
+ transcript: "Every frame comes back the same. We stopped calling it a fault. A lens that only ever shows you the one thing you half-remember — maybe that was the finding.",
+ speaker: "Dr. M. Coreman, Principal Investigator — Oral History Reel 07 (2018)",
+ audio: null
+ },
+ {
+ id: 4, year: "2022", accent: "var(--era-4)",
+ title: "The Last-Breath Clock",
+ curator: "A clock built to advance once an hour, driven by the exhaled breath of a single registered donor. When the donor’s participation ended, the clock stopped; its hands hold the hour of its final movement. It is displayed running-down rather than repaired — a deliberate refusal, the curators felt, to give a room back a time it had already surrendered.",
+ material: "Brass movement and carbon-fibre escapement with a sealed pneumatic donor cell (now inert). Case reclaimed from a demolished sanatorium corridor.",
+ conservation: "Non-functional by intent. Hands fixed at the last recorded advance, 04:00, 11 March 2022. Movement conserved, not restarted.",
+ status: "failed", statusLabel: "Non-functional",
+ transcript: "It was never meant to be wound. It kept a person’s time, and when that ran out the clock was honest enough to stop. Restarting it would be a kind of forgetting.",
+ speaker: "L. Adeyemi, Horological Conservator — Oral History Reel 09 (2022)",
+ audio: null
+ },
+ {
+ id: 5, year: "2040", accent: "var(--era-5)",
+ title: "The Future Room",
+ curator: "The exhibition’s only room-as-object: a chamber whose walls shift colour in response to the occupant, authored by a generative system that has since been withdrawn from service. Reassembled here at partial scale, it still responds, though no one can now say to what. It is the endpoint of the show’s argument — a room that remembers by continuously rewriting itself, and so, perhaps, remembers nothing.",
+ material: "Self-modifying surface panels of undisclosed composition (maker dissolved 2039); control model preserved as an offline snapshot. Provenance chain incomplete by design.",
+ conservation: "Unstable. Behaviour drifts between viewings; the snapshot is frozen but the panels are not. Under active study.",
+ status: "unstable", statusLabel: "Unstable",
+ transcript: "We froze the model, but the walls kept changing. You can archive the instructions and still lose the room. That gap — that’s the whole exhibition, isn’t it.",
+ speaker: "Curatorial note, read by S. Vance — Oral History Reel 14 (2040)",
+ audio: null
+ }
+ ];
+
+ const root = document.documentElement;
+ const $ = (s, c) => (c || document).querySelector(s);
+ const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
+
+ /* ---------------- MOTION + CONTRAST STATE ---------------- */
+ const prefersReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const savedMotion = localStorage.getItem("afterlight:motion"); // "animate" | "reduce" | null
+ const savedContrast = localStorage.getItem("afterlight:contrast"); // "high" | "normal" | null
+
+ let motionOn = savedMotion ? (savedMotion === "animate") : !prefersReduce;
+ let contrastHigh = savedContrast === "high";
+
+ function applyMotion() {
+ root.setAttribute("data-motion", motionOn ? "animate" : "reduce");
+ const b = $("#btnMotion");
+ // The control toggles REDUCED motion; pressed = reduced.
+ b.setAttribute("aria-pressed", String(!motionOn));
+ b.textContent = motionOn ? "Motion" : "Motion ⏸";
+ if (window.__motionStudioSync) window.__motionStudioSync();
+ }
+ function applyContrast() {
+ root.setAttribute("data-contrast", contrastHigh ? "high" : "normal");
+ const b = $("#btnContrast");
+ b.setAttribute("aria-pressed", String(contrastHigh));
+ b.textContent = contrastHigh ? "Contrast ●" : "Contrast";
+ if (window.__motionStudioSync) window.__motionStudioSync();
+ }
+ applyMotion();
+ applyContrast();
+
+ /* ---------------- RENDER OBJECTS ---------------- */
+ const main = $("#objects");
+ main.innerHTML = OBJECTS.map(o => `
+ <article class="object" id="obj-${o.id}" style="--accent:${o.accent}" tabindex="-1"
+ aria-labelledby="obj-${o.id}-title">
+ <p class="index-tag reveal"><span class="dot"></span> Object ${o.id} of ${OBJECTS.length}</p>
+ <p class="year reveal" aria-hidden="true">${o.year}</p>
+ <h2 id="obj-${o.id}-title" class="reveal">${o.title}<span class="vh"> (${o.year})</span></h2>
+
+ <figure class="motion reveal" data-obj="${o.id}">
+ <video class="motion-video" muted loop playsinline preload="metadata"
+ poster="media/obj-${o.id}.jpg"
+ aria-label="Motion study evoking ${o.title}, ${o.year}.">
+ <source src="media/obj-${o.id}.mp4" type="video/mp4" />
+ </video>
+ <canvas class="motion-canvas" role="img" hidden
+ aria-label="Motion study evoking ${o.title}, ${o.year} — restrained, era-toned."></canvas>
+ <figcaption>Motion Study · ${o.year}</figcaption>
+ <span class="paused-note">Motion paused</span>
+ </figure>
+
+ <p class="curator reveal">${o.curator}</p>
+
+ <div class="fields reveal">
+ <div class="field">
+ <p class="label">Material Provenance</p>
+ <div class="field-rule"></div>
+ <p class="val">${o.material}</p>
+ </div>
+ <div class="field">
+ <p class="label">Conservation Status</p>
+ <div class="field-rule"></div>
+ <p class="val"><span class="status" data-s="${o.status}"><span class="swatch"></span> <b>${o.statusLabel}.</b></span> ${o.conservation}</p>
+ </div>
+ </div>
+
+ <div class="audio reveal">
+ <p class="a-label">Audio Transcript · <span class="src">${o.speaker.split("—")[1] ? o.speaker.split("—")[1].trim() : "Excerpt"}</span></p>
+ <blockquote>${o.transcript}</blockquote>
+ <p class="speaker">— ${o.speaker.split("—")[0].trim()}</p>
+ <div class="player" data-audio="${o.audio ? o.audio : ''}"></div>
+ </div>
+ </article>
+ `).join("");
+
+ /* Media: attach a real player only where a source exists; otherwise
+ show the transcript-only fallback message. */
+ $$(".player").forEach(p => {
+ const src = p.getAttribute("data-audio");
+ if (src) {
+ const a = document.createElement("audio");
+ a.controls = true; a.preload = "none"; a.src = src;
+ const fb = document.createElement("p");
+ fb.className = "fallback"; fb.hidden = true;
+ fb.textContent = "Recording unavailable — the transcript above is the complete excerpt.";
+ a.addEventListener("error", () => { a.hidden = true; fb.hidden = false; });
+ p.append(a, fb);
+ } else {
+ const fb = document.createElement("p");
+ fb.className = "fallback";
+ fb.textContent = "No recording is bundled with this exhibition — the transcript above is the complete excerpt.";
+ p.append(fb);
+ }
+ });
+
+ /* ---------------- INDEX DIALOG ---------------- */
+ $("#indexList").innerHTML = OBJECTS.map(o => `
+ <li>
+ <a href="#obj-${o.id}" data-goto="${o.id}" style="--accent:${o.accent}">
+ <span class="n">${o.id}</span>
+ <span class="t">${o.title}<small>${o.statusLabel}</small></span>
+ <span class="y"><span class="dot" style="background:${o.accent};display:inline-block;margin-right:.5rem"></span>${o.year}</span>
+ </a>
+ </li>`).join("");
+
+ /* ---------------- COMPARE DIALOG ---------------- */
+ const cmpLeft = $("#cmpLeft"), cmpRight = $("#cmpRight");
+ const opt = o => `<option value="${o.id}">${o.year} — ${o.title}</option>`;
+ cmpLeft.innerHTML = OBJECTS.map(opt).join("");
+ cmpRight.innerHTML = OBJECTS.map(opt).join("");
+
+ function colHTML(o) {
+ return `
+ <div class="cmp-col" style="--col-accent:${o.accent}">
+ <p class="c-year">${o.year}</p>
+ <p class="c-title">${o.title}</p>
+ <div class="cmp-row"><p class="k">Material Provenance</p><p class="v">${o.material}</p></div>
+ <div class="cmp-row"><p class="k">Conservation Status</p><p class="v"><b>${o.statusLabel}.</b> ${o.conservation}</p></div>
+ <div class="cmp-row"><p class="k">From the transcript</p><p class="v">“${o.transcript}”</p></div>
+ </div>`;
+ }
+ function renderCompare() {
+ const a = OBJECTS.find(o => o.id === +cmpLeft.value);
+ const b = OBJECTS.find(o => o.id === +cmpRight.value);
+ const span = Math.abs(+a.year - +b.year);
+ $("#cmpGrid").innerHTML = colHTML(a) + colHTML(b) +
+ `<p class="cmp-note">Two material histories, ${a.id === b.id ? "the same object" : span + " years apart"}: from <b>${a.statusLabel.toLowerCase()}</b> (${a.year}) to <b>${b.statusLabel.toLowerCase()}</b> (${b.year}). What each room was allowed to keep — a scar, a blackout, a blur, an hour, a rewrite — is the exhibition’s subject.</p>`;
+ }
+ cmpLeft.addEventListener("change", renderCompare);
+ cmpRight.addEventListener("change", renderCompare);
+
+ /* ---------------- DIALOG OPEN/CLOSE + FOCUS ---------------- */
+ let lastFocus = null;
+ function openDialog(el, focusEl) {
+ lastFocus = document.activeElement;
+ el.classList.add("open");
+ (focusEl || $("[data-close]", el)).focus();
+ document.addEventListener("keydown", trapTab);
+ el._trapRoot = el;
+ }
+ function closeDialog(el) {
+ el.classList.remove("open");
+ document.removeEventListener("keydown", trapTab);
+ if (lastFocus && lastFocus.focus) lastFocus.focus();
+ }
+ function trapTab(e) {
+ if (e.key !== "Tab") return;
+ const open = $(".overlay.open"); if (!open) return;
+ const f = $$('a[href], button, select, [tabindex]:not([tabindex="-1"])', open)
+ .filter(x => x.offsetParent !== null);
+ if (!f.length) return;
+ const first = f[0], last = f[f.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ }
+ function anyOpen() { return $(".overlay.open"); }
+
+ $("#btnIndex").addEventListener("click", () => openDialog($("#indexDialog")));
+ $("#btnCompare").addEventListener("click", () => {
+ // Default to the object currently in view + the next one.
+ const cur = currentObjectId();
+ cmpLeft.value = String(cur);
+ cmpRight.value = String(cur % OBJECTS.length + 1);
+ renderCompare();
+ openDialog($("#compareDialog"), cmpLeft);
+ });
+ $$("[data-close]").forEach(b => b.addEventListener("click", () => closeDialog(b.closest(".overlay"))));
+ $$(".overlay").forEach(ov => ov.addEventListener("mousedown", e => { if (e.target === ov) closeDialog(ov); }));
+
+ // Index links: jump + close + remember focus target
+ $("#indexList").addEventListener("click", e => {
+ const a = e.target.closest("a[data-goto]"); if (!a) return;
+ e.preventDefault();
+ closeDialog($("#indexDialog"));
+ gotoObject(+a.dataset.goto);
+ });
+
+ /* ---------------- SCROLL PROGRESS ---------------- */
+ const fill = $("#progressFill");
+ function onScroll() {
+ const h = document.documentElement.scrollHeight - window.innerHeight;
+ const pct = h > 0 ? (window.scrollY / h) * 100 : 0;
+ fill.style.width = Math.max(0, Math.min(100, pct)) + "%";
+ }
+ window.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("resize", onScroll);
+ onScroll();
+
+ /* ---------------- READING POSITION (persist + restore) ---------------- */
+ let active = 0; // 0 = hero, 1..5 = object
+ const sections = [$("#top"), ...OBJECTS.map(o => $("#obj-" + o.id))];
+
+ const io = new IntersectionObserver(entries => {
+ entries.forEach(en => {
+ if (en.isIntersecting) {
+ // reveal
+ if (en.target.classList.contains("object")) {
+ $$(".reveal", en.target).forEach(r => r.classList.add("in"));
+ }
+ // active section = the one most in view near the top third
+ }
+ });
+ }, { threshold: [0.15] });
+ $$(".object").forEach(s => io.observe(s));
+ // reveal hero children immediately handled by CSS animation
+
+ // Track active object with a scroll-position heuristic (robust + keyboard-safe)
+ function currentObjectId() {
+ const mid = window.scrollY + window.innerHeight * 0.35;
+ let cur = 1;
+ OBJECTS.forEach(o => {
+ const el = $("#obj-" + o.id);
+ if (el && el.offsetTop <= mid) cur = o.id;
+ });
+ return cur;
+ }
+ let saveTimer = null;
+ function savePosition() {
+ const inHero = window.scrollY < ($("#obj-1").offsetTop - window.innerHeight * 0.4);
+ active = inHero ? 0 : currentObjectId();
+ try {
+ localStorage.setItem("afterlight:pos", JSON.stringify({ id: active, y: Math.round(window.scrollY) }));
+ } catch (e) {}
+ }
+ window.addEventListener("scroll", () => {
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(savePosition, 250);
+ }, { passive: true });
+
+ /* Navigate to an object and move focus there (keyboard return) */
+ function gotoObject(id) {
+ const el = $("#obj-" + id);
+ if (!el) return;
+ el.scrollIntoView({ behavior: motionOn ? "smooth" : "auto", block: "start" });
+ // focus the section for screen-reader + keyboard continuity
+ el.focus({ preventScroll: true });
+ $$(".reveal", el).forEach(r => r.classList.add("in"));
+ }
+
+ /* ---------------- RESUME BANNER ---------------- */
+ const banner = $("#resumeBanner");
+ let savedPos = null;
+ try { savedPos = JSON.parse(localStorage.getItem("afterlight:pos") || "null"); } catch (e) {}
+ if (savedPos && savedPos.id >= 1) {
+ const o = OBJECTS.find(x => x.id === savedPos.id);
+ if (o) {
+ $("#resumeLabel").textContent = "Object " + o.id + " · " + o.title;
+ banner.classList.add("show");
+ }
+ }
+ function doResume() {
+ if (savedPos && savedPos.id >= 1) gotoObject(savedPos.id);
+ banner.classList.remove("show");
+ }
+ $("#btnResume").addEventListener("click", doResume);
+ $("#btnDismissResume").addEventListener("click", () => banner.classList.remove("show"));
+
+ /* ---------------- TOGGLES ---------------- */
+ $("#btnMotion").addEventListener("click", () => {
+ motionOn = !motionOn; localStorage.setItem("afterlight:motion", motionOn ? "animate" : "reduce"); applyMotion();
+ if (motionOn) $$(".object .reveal").forEach(r => { if (isInView(r)) r.classList.add("in"); });
+ });
+ $("#btnContrast").addEventListener("click", () => {
+ contrastHigh = !contrastHigh; localStorage.setItem("afterlight:contrast", contrastHigh ? "high" : "normal"); applyContrast();
+ });
+ function isInView(el) {
+ const r = el.getBoundingClientRect();
+ return r.top < window.innerHeight && r.bottom > 0;
+ }
+ // When motion is off, show all reveals (no hidden content)
+ function syncReveals() {
+ if (!motionOn) $$(".reveal").forEach(r => r.classList.add("in"));
+ }
+ syncReveals();
+
+ /* ---------------- KEYBOARD ---------------- */
+ document.addEventListener("keydown", e => {
+ // Don't hijack typing in selects / when modifiers held
+ const tag = (e.target.tagName || "").toLowerCase();
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+
+ if (e.key === "Escape") {
+ const ov = anyOpen(); if (ov) { closeDialog(ov); e.preventDefault(); }
+ return;
+ }
+ if (anyOpen()) return; // inside a dialog, let native controls work
+
+ if (tag === "select" || tag === "input" || tag === "textarea") return;
+
+ const k = e.key.toLowerCase();
+ if (k >= "1" && k <= String(OBJECTS.length)) { gotoObject(+k); e.preventDefault(); return; }
+ switch (k) {
+ case "i": $("#btnIndex").click(); e.preventDefault(); break;
+ case "c": $("#btnCompare").click(); e.preventDefault(); break;
+ case "h": $("#btnContrast").click(); e.preventDefault(); break;
+ case "m": $("#btnMotion").click(); e.preventDefault(); break;
+ case "r": doResume(); e.preventDefault(); break;
+ case "arrowdown": case "j": moveObject(1); e.preventDefault(); break;
+ case "arrowup": case "k": moveObject(-1); e.preventDefault(); break;
+ case "home": window.scrollTo({ top: 0, behavior: motionOn ? "smooth" : "auto" }); e.preventDefault(); break;
+ case "end": gotoObject(OBJECTS.length); e.preventDefault(); break;
+ }
+ });
+ function moveObject(dir) {
+ const cur = (window.scrollY < $("#obj-1").offsetTop - 10) ? 0 : currentObjectId();
+ const next = Math.max(1, Math.min(OBJECTS.length, cur + dir));
+ gotoObject(next);
+ }
+
+ /* ---------------- MOTION STUDIES (in-page canvas, $0) ---------------- */
+ const MotionStudio = (function () {
+ const ACCENT = { 1:[216,162,74], 2:[90,162,224], 3:[70,192,176], 4:[169,139,216], 5:[143,208,106] };
+ const studies = [];
+ let raf = null, startT = 0;
+
+ function col(s, a) {
+ const hi = root.getAttribute("data-contrast") === "high";
+ const [r,g,b] = hi ? [255,255,255] : s.rgb;
+ return `rgba(${r},${g},${b},${a})`;
+ }
+ const wrap = (v, m) => ((v % m) + m) % m;
+
+ /* ---- renderers: restrained, slow, deterministic in t (seconds) ---- */
+ // 1928 — Echo Bench: dust rising through an amber shaft of light.
+ function echo(ctx, w, h, t, s) {
+ ctx.fillStyle = "#070604"; ctx.fillRect(0,0,w,h);
+ const g = ctx.createLinearGradient(w*0.1,0, w*0.55,h);
+ g.addColorStop(0, col(s,0.10)); g.addColorStop(1, col(s,0));
+ ctx.fillStyle = g;
+ ctx.beginPath(); ctx.moveTo(w*0.12,0); ctx.lineTo(w*0.42,0);
+ ctx.lineTo(w*0.66,h); ctx.lineTo(w*0.30,h); ctx.closePath(); ctx.fill();
+ if (!s.dust) s.dust = Array.from({length:46}, (_,i) => ({
+ x: Math.random()*w, y0: Math.random()*h,
+ r: 0.4 + Math.random()*1.3, sp: 5 + Math.random()*12,
+ ph: Math.random()*6.28, sw: 0.4 + Math.random()*0.8
+ }));
+ s.dust.forEach(p => {
+ const y = wrap(p.y0 - t*p.sp, h);
+ const x = p.x + Math.sin(t*0.3 + p.ph) * 6 * p.sw;
+ const tw = 0.35 + 0.65*Math.abs(Math.sin(t*0.8 + p.ph));
+ ctx.beginPath(); ctx.arc(x, y, p.r, 0, 6.2832);
+ ctx.fillStyle = col(s, 0.55*tw); ctx.fill();
+ });
+ // faint carved initials, static
+ ctx.strokeStyle = col(s, 0.06); ctx.lineWidth = 1.4;
+ for (let i=0;i<7;i++){ const bx = w*(0.2+i*0.09), by = h*0.86;
+ ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx+6,by-10); ctx.lineTo(bx+12,by); ctx.stroke(); }
+ }
+
+ // 2003 — Data Veil: signal-blue points migrating along a woven grid.
+ function veil(ctx, w, h, t, s) {
+ ctx.fillStyle = "#03060a"; ctx.fillRect(0,0,w,h);
+ const N = 22;
+ ctx.strokeStyle = col(s, 0.05); ctx.lineWidth = 1;
+ for (let i=0;i<N;i++){ const x = (i+0.5)/N*w;
+ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
+ if (!s.pts) s.pts = Array.from({length:26}, () => ({
+ col: Math.floor(Math.random()*N), ph: Math.random(),
+ sp: 0.04 + Math.random()*0.10, dir: Math.random()<0.5?1:-1
+ }));
+ s.pts.forEach(p => {
+ const x = (p.col+0.5)/N*w;
+ const y = wrap((p.dir*t*p.sp + p.ph), 1) * h;
+ const rg = ctx.createRadialGradient(x,y,0, x,y,9);
+ rg.addColorStop(0, col(s,0.9)); rg.addColorStop(1, col(s,0));
+ ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(x,y,9,0,6.2832); ctx.fill();
+ });
+ }
+
+ // 2015 — Memory Lens: focus rings breathing around a blur that never resolves.
+ function lens(ctx, w, h, t, s) {
+ ctx.fillStyle = "#03080a"; ctx.fillRect(0,0,w,h);
+ const cx = w*0.5, cy = h*0.5, maxR = Math.min(w,h)*0.62;
+ const core = ctx.createRadialGradient(cx,cy,0, cx,cy, maxR*0.5);
+ core.addColorStop(0, col(s, 0.28 + 0.06*Math.sin(t*0.7)));
+ core.addColorStop(1, col(s, 0));
+ ctx.fillStyle = core; ctx.fillRect(0,0,w,h);
+ for (let k=0;k<4;k++){
+ const ph = wrap(t*0.22 + k/4, 1);
+ const r = ph*maxR, a = (1-ph)*0.38;
+ ctx.strokeStyle = col(s, a); ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.arc(cx,cy,r,0,6.2832); ctx.stroke();
+ }
+ }
+
+ // 2022 — Last-Breath Clock: hands fixed at 4:00, a single slow particle.
+ function clock(ctx, w, h, t, s) {
+ ctx.fillStyle = "#06040a"; ctx.fillRect(0,0,w,h);
+ const cx = w*0.5, cy = h*0.5, R = Math.min(w,h)*0.34;
+ ctx.strokeStyle = col(s, 0.14); ctx.lineWidth = 1.4;
+ ctx.beginPath(); ctx.arc(cx,cy,R,0,6.2832); ctx.stroke();
+ for (let i=0;i<12;i++){ const a = i/12*6.2832;
+ const x1 = cx+Math.cos(a)*R*0.9, y1 = cy+Math.sin(a)*R*0.9;
+ const x2 = cx+Math.cos(a)*R, y2 = cy+Math.sin(a)*R;
+ ctx.strokeStyle = col(s, 0.12); ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
+ // motionless hands: hour to 4 (120° from 12), minute to 12
+ const hourA = -Math.PI/2 + (4/12)*6.2832, minA = -Math.PI/2;
+ ctx.strokeStyle = col(s, 0.55); ctx.lineWidth = 2.4;
+ ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+Math.cos(hourA)*R*0.5, cy+Math.sin(hourA)*R*0.5); ctx.stroke();
+ ctx.lineWidth = 1.6;
+ ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx+Math.cos(minA)*R*0.78, cy+Math.sin(minA)*R*0.78); ctx.stroke();
+ // barely-perceptible rim breath
+ ctx.strokeStyle = col(s, 0.06 + 0.06*Math.sin(t*0.5)); ctx.lineWidth = 3;
+ ctx.beginPath(); ctx.arc(cx,cy,R*1.06,0,6.2832); ctx.stroke();
+ // one drifting dust particle
+ const px = wrap(t*0.045, 1)*w, py = cy + Math.sin(t*0.4)*7;
+ ctx.fillStyle = col(s, 0.5); ctx.beginPath(); ctx.arc(px,py,1.3,0,6.2832); ctx.fill();
+ }
+
+ // 2040 — Future Room: a wall of cells slowly shifting hue.
+ function room(ctx, w, h, t, s) {
+ ctx.fillStyle = "#04070a"; ctx.fillRect(0,0,w,h);
+ const cols = 12, rows = 5, cw = w/cols, ch = h/rows;
+ for (let y=0;y<rows;y++) for (let x=0;x<cols;x++){
+ const v = 0.5 + 0.25*Math.sin(x*0.6 + t*0.5) + 0.25*Math.sin(y*0.9 - t*0.35 + x*0.2);
+ ctx.fillStyle = col(s, Math.max(0, v)*0.5);
+ ctx.fillRect(x*cw, y*ch, cw+1, ch+1);
+ }
+ }
+
+ const RENDER = { 1:echo, 2:veil, 3:lens, 4:clock, 5:room };
+
+ function size(s) {
+ const rect = s.canvas.getBoundingClientRect();
+ if (rect.width < 2) return;
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
+ s.w = rect.width; s.h = rect.height;
+ s.canvas.width = Math.round(rect.width*dpr);
+ s.canvas.height = Math.round(rect.height*dpr);
+ s.ctx.setTransform(dpr,0,0,dpr,0,0);
+ }
+ function draw(s, t) { if (s.w) s.render(s.ctx, s.w, s.h, t, s); }
+
+ const motionOnNow = () => root.getAttribute("data-motion") === "animate";
+
+ // Canvas fallback loop — only drives studies whose video failed to load.
+ function frame(now) {
+ const t = (now - startT)/1000;
+ for (const s of studies) if (s.videoFailed && s.inView) draw(s, t);
+ raf = requestAnimationFrame(frame);
+ }
+ function start() { if (!raf) { startT = performance.now(); raf = requestAnimationFrame(frame); } }
+ function stop() { if (raf) { cancelAnimationFrame(raf); raf = null; } }
+ const anyFallback = () => studies.some(s => s.videoFailed);
+
+ // Reveal the canvas fallback when a video can't be fetched/decoded.
+ function failToCanvas(s) {
+ if (s.videoFailed) return;
+ s.videoFailed = true;
+ if (s.video) s.video.style.display = "none";
+ s.canvas.hidden = false;
+ size(s); draw(s, 2.4);
+ sync();
+ }
+
+ function videoState(s) {
+ if (s.videoFailed || !s.video) return;
+ if (motionOnNow() && s.inView) s.video.play().catch(() => {});
+ else { s.video.pause(); if (!motionOnNow()) { try { s.video.currentTime = 0; } catch (e) {} } }
+ }
+
+ function sync() {
+ const on = motionOnNow();
+ studies.forEach(videoState); // videos: play/pause per state
+ if (on && anyFallback()) start(); else stop(); // canvas loop only if a video failed
+ if (!on) studies.forEach(s => { if (s.videoFailed) draw(s, 2.4); }); // frozen frame
+ }
+
+ function init() {
+ $$(".motion").forEach(fig => {
+ const id = +fig.dataset.obj;
+ const canvas = $(".motion-canvas", fig);
+ const video = $(".motion-video", fig);
+ const s = { id, fig, video, canvas, ctx: canvas.getContext("2d"), render: RENDER[id],
+ rgb: ACCENT[id], w: 0, h: 0, inView: false, videoFailed: false };
+ studies.push(s);
+ size(s);
+ if (video) {
+ video.addEventListener("error", () => failToCanvas(s));
+ // <source> failure surfaces on the media element after it exhausts sources
+ const src = $("source", video);
+ if (src) src.addEventListener("error", () => failToCanvas(s));
+ } else {
+ failToCanvas(s);
+ }
+ });
+ const io = new IntersectionObserver(ents => {
+ ents.forEach(e => {
+ const s = studies.find(x => x.fig === e.target);
+ if (!s) return;
+ s.inView = e.isIntersecting;
+ videoState(s);
+ });
+ }, { threshold: 0.01 });
+ studies.forEach(s => io.observe(s.fig));
+ window.addEventListener("resize", () => {
+ studies.forEach(size);
+ if (!motionOnNow()) studies.forEach(s => { if (s.videoFailed) draw(s, 2.4); });
+ });
+ sync();
+ }
+ return { init, sync };
+ })();
+
+ MotionStudio.init();
+ window.__motionStudioSync = MotionStudio.sync;
+ MotionStudio.sync();
+
+ // Save an initial position marker so Resume is available on revisit
+ savePosition();
+})();
+</script>
+</body>
+</html>
diff --git a/builds/afterlight/media/obj-1.jpg b/builds/afterlight/media/obj-1.jpg
new file mode 100644
index 0000000..a8724fb
Binary files /dev/null and b/builds/afterlight/media/obj-1.jpg differ
diff --git a/builds/afterlight/media/obj-1.mp4 b/builds/afterlight/media/obj-1.mp4
new file mode 100644
index 0000000..dcf8ab5
Binary files /dev/null and b/builds/afterlight/media/obj-1.mp4 differ
diff --git a/builds/afterlight/media/obj-2.jpg b/builds/afterlight/media/obj-2.jpg
new file mode 100644
index 0000000..8e99eb9
Binary files /dev/null and b/builds/afterlight/media/obj-2.jpg differ
diff --git a/builds/afterlight/media/obj-2.mp4 b/builds/afterlight/media/obj-2.mp4
new file mode 100644
index 0000000..e2e038b
Binary files /dev/null and b/builds/afterlight/media/obj-2.mp4 differ
diff --git a/builds/afterlight/media/obj-3.jpg b/builds/afterlight/media/obj-3.jpg
new file mode 100644
index 0000000..026350f
Binary files /dev/null and b/builds/afterlight/media/obj-3.jpg differ
diff --git a/builds/afterlight/media/obj-3.mp4 b/builds/afterlight/media/obj-3.mp4
new file mode 100644
index 0000000..20e627e
Binary files /dev/null and b/builds/afterlight/media/obj-3.mp4 differ
diff --git a/builds/afterlight/media/obj-4.jpg b/builds/afterlight/media/obj-4.jpg
new file mode 100644
index 0000000..97824e4
Binary files /dev/null and b/builds/afterlight/media/obj-4.jpg differ
diff --git a/builds/afterlight/media/obj-4.mp4 b/builds/afterlight/media/obj-4.mp4
new file mode 100644
index 0000000..3a63c43
Binary files /dev/null and b/builds/afterlight/media/obj-4.mp4 differ
diff --git a/builds/afterlight/media/obj-5.jpg b/builds/afterlight/media/obj-5.jpg
new file mode 100644
index 0000000..9a92c28
Binary files /dev/null and b/builds/afterlight/media/obj-5.jpg differ
diff --git a/builds/afterlight/media/obj-5.mp4 b/builds/afterlight/media/obj-5.mp4
new file mode 100644
index 0000000..bcfe191
Binary files /dev/null and b/builds/afterlight/media/obj-5.mp4 differ
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..7a154df
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# gallery.agentabrams.com — full first deploy (DNS + rsync + nginx + SSL).
+# STEVE-GATED: this creates a Cloudflare DNS record and publishes a public site.
+# Run only with explicit approval.
+set -euo pipefail
+KAMATERA=root@45.61.58.125
+SITE=gallery.agentabrams.com
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+
+# 1. DNS: A record gallery -> Kamatera (DNS-only, matches fleet pattern)
+export $(grep -E "^CLOUDFLARE_API_TOKEN=" ~/Projects/secrets-manager/.env)
+ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+ "https://api.cloudflare.com/client/v4/zones?name=agentabrams.com" \
+ | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
+EXISTS=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+ "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?name=$SITE" \
+ | python3 -c "import sys,json; print(len(json.load(sys.stdin)['result']))")
+if [ "$EXISTS" = "0" ]; then
+ curl -s -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
+ "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" \
+ -d "{\"type\":\"A\",\"name\":\"gallery\",\"content\":\"45.61.58.125\",\"ttl\":300,\"proxied\":false}" \
+ | python3 -c "import sys,json; r=json.load(sys.stdin); assert r['success'], r['errors']; print('DNS record created')"
+else
+ echo "DNS record already exists"
+fi
+
+# 2. Content — mirror the repo (minus dev files) to the site root
+rsync -az --delete --exclude .git --exclude deploy --exclude README.md "$HERE/" $KAMATERA:/var/www/$SITE/
+echo "content rsynced (mirrored)"
+
+# 3. nginx (80 first so certbot HTTP-01 can pass)
+scp -q "$HERE/deploy/$SITE.nginx" $KAMATERA:/etc/nginx/sites-available/$SITE
+ssh $KAMATERA "ln -sf /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE && nginx -t && systemctl reload nginx"
+echo "nginx staged"
+
+# 4. Wait for DNS, then SSL
+for i in $(seq 1 30); do
+ [ -n "$(dig +short $SITE @1.1.1.1)" ] && break
+ sleep 10
+done
+ssh $KAMATERA "certbot --nginx -d $SITE --non-interactive --agree-tos -m steve@designerwallcoverings.com && systemctl reload nginx"
+
+# 5. Smoke test
+sleep 2
+code=$(curl -s -o /dev/null -w '%{http_code}' https://$SITE/)
+echo "https://$SITE -> HTTP $code"
+curl -s https://$SITE/builds.json | head -c 200; echo
+[ "$code" = "200" ] && echo "DEPLOY OK" || { echo "DEPLOY FAILED"; exit 1; }
diff --git a/deploy/gallery.agentabrams.com.nginx b/deploy/gallery.agentabrams.com.nginx
new file mode 100644
index 0000000..140bf1c
--- /dev/null
+++ b/deploy/gallery.agentabrams.com.nginx
@@ -0,0 +1,41 @@
+# gallery.agentabrams.com — Agent Abrams build gallery (public static hub)
+server {
+ server_name gallery.agentabrams.com;
+
+ access_log /var/log/nginx/gallery.agentabrams.com.access.log;
+ error_log /var/log/nginx/gallery.agentabrams.com.error.log;
+
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ root /var/www/gallery.agentabrams.com;
+ index index.html;
+ autoindex off;
+
+ # builds ship self-contained MP4 motion clips — cache them hard
+ location ~* \.(mp4|webm|jpg|jpeg|png|webp)$ {
+ expires 30d;
+ add_header Cache-Control "public, max-age=2592000";
+ try_files $uri =404;
+ }
+
+ location / {
+ try_files $uri $uri/ $uri.html =404;
+ }
+
+ listen 45.61.58.125:443 ssl; # managed by Certbot
+ ssl_certificate /etc/letsencrypt/live/gallery.agentabrams.com/fullchain.pem; # managed by Certbot
+ ssl_certificate_key /etc/letsencrypt/live/gallery.agentabrams.com/privkey.pem; # managed by Certbot
+ include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+}
+
+server {
+ if ($host = gallery.agentabrams.com) {
+ return 301 https://$host$request_uri;
+ } # managed by Certbot
+
+ listen 45.61.58.125:80;
+ server_name gallery.agentabrams.com;
+ return 404; # managed by Certbot
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..b4d1f83
--- /dev/null
+++ b/index.html
@@ -0,0 +1,237 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>gallery.agentabrams.com — Builds</title>
+<meta name="description" content="A gallery of Agent Abrams builds." />
+<style>
+ :root {
+ --bg:#0c0d11; --panel:#14161d; --line:#262a35; --line-soft:#1c1f27;
+ --ink:#e9ebf2; --dim:#9aa0ad; --faint:#666c78; --accent:#5eead4;
+ --cols: 3;
+ --sans: ui-sans-serif, "Helvetica Neue", Arial, system-ui, sans-serif;
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
+ }
+ * { box-sizing:border-box; }
+ html,body { margin:0; }
+ body { background:var(--bg); color:var(--ink); font-family:var(--sans); }
+ a { color:inherit; }
+
+ header.top {
+ display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap;
+ padding:1.5rem clamp(1rem,4vw,2.5rem) .4rem;
+ }
+ .brand { font-family:var(--mono); font-size:1rem; letter-spacing:.14em; font-weight:700; }
+ .brand .dot { color:var(--accent); }
+ .brand .sub { color:var(--faint); font-weight:400; letter-spacing:.08em; }
+ header.top .count { margin-left:auto; color:var(--faint); font-size:.78rem; letter-spacing:.1em; font-family:var(--mono); }
+ .tagline { padding:0 clamp(1rem,4vw,2.5rem); color:var(--dim); font-size:.9rem; max-width:60ch; }
+
+ /* controls: sort + density (persisted) */
+ .controls {
+ display:flex; align-items:center; gap:1.2rem; flex-wrap:wrap;
+ padding:1.2rem clamp(1rem,4vw,2.5rem) 1.4rem;
+ border-bottom:1px solid var(--line-soft);
+ }
+ .controls label { font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--faint); margin-right:.5rem; }
+ .controls select {
+ font-family:var(--sans); font-size:.85rem; background:var(--panel); color:var(--ink);
+ border:1px solid var(--line); border-radius:4px; padding:.4rem .6rem;
+ }
+ .controls .density { display:flex; align-items:center; gap:.6rem; }
+ .controls input[type=range] { accent-color:var(--accent); width:130px; }
+
+ .grid {
+ display:grid; gap:1.2rem;
+ grid-template-columns:repeat(var(--cols), 1fr);
+ padding:1.6rem clamp(1rem,4vw,2.5rem) 4rem;
+ }
+
+ .card {
+ position:relative; background:var(--panel);
+ border:1px solid var(--line-soft); border-radius:10px; overflow:hidden;
+ display:flex; flex-direction:column;
+ transition:border-color .2s, transform .2s;
+ }
+ .card:hover { border-color:var(--line); transform:translateY(-2px); }
+ .card .cover { position:relative; aspect-ratio:16/9; background:#000; overflow:hidden; }
+ .card .cover video, .card .cover img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }
+ .card .cover img { z-index:1; }
+ .card .cover video { z-index:2; opacity:0; transition:opacity .4s; }
+ .card.playing .cover video { opacity:1; }
+ .card .num {
+ position:absolute; top:.6rem; left:.6rem; z-index:3;
+ font-family:var(--mono); font-size:.7rem; letter-spacing:.12em;
+ background:rgba(0,0,0,.65); color:var(--accent);
+ border:1px solid rgba(94,234,212,.35); border-radius:4px; padding:.2rem .5rem;
+ }
+ .card .body { padding:.9rem 1rem 1.1rem; display:flex; flex-direction:column; gap:.5rem; flex:1; }
+ .card h2 { margin:0; font-size:1.08rem; line-height:1.2; }
+ .card .desc { margin:0; font-size:.82rem; color:var(--dim); line-height:1.5; }
+ .card .tags { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.1rem; }
+ .card .tag { font-size:.64rem; letter-spacing:.06em; text-transform:uppercase; color:var(--dim);
+ border:1px solid var(--line); border-radius:20px; padding:.15rem .5rem; }
+ .card .meta { display:flex; align-items:center; gap:.6rem; margin-top:auto; padding-top:.4rem; }
+ .card .when { font-family:var(--mono); font-size:.68rem; color:var(--faint); }
+ .card .actions { display:flex; gap:.4rem; margin-left:auto; }
+ .card .actions button {
+ font-family:var(--sans); font-size:.74rem; cursor:pointer;
+ background:transparent; color:var(--ink); border:1px solid var(--line);
+ border-radius:5px; padding:.32rem .6rem; transition:background .2s,border-color .2s;
+ }
+ .card .actions button:hover { border-color:var(--accent); }
+ .card .actions .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }
+
+ .empty { padding:4rem; text-align:center; color:var(--faint); }
+
+ /* preview overlay */
+ .overlay { position:fixed; inset:0; z-index:100; background:rgba(0,0,0,.82);
+ display:none; flex-direction:column; }
+ .overlay.open { display:flex; }
+ .overlay .obar { display:flex; align-items:center; gap:1rem; padding:.7rem 1rem; background:var(--panel); border-bottom:1px solid var(--line); }
+ .overlay .obar .t { font-size:.9rem; font-weight:600; }
+ .overlay .obar .sp { flex:1; }
+ .overlay .obar button, .overlay .obar a {
+ font-size:.78rem; cursor:pointer; text-decoration:none;
+ background:transparent; color:var(--ink); border:1px solid var(--line);
+ border-radius:5px; padding:.35rem .7rem;
+ }
+ .overlay .obar .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }
+ .overlay iframe { flex:1; width:100%; border:0; background:#000; }
+
+ :focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
+
+ @media (max-width:900px){ :root{ --cols:2; } }
+ @media (max-width:560px){ :root{ --cols:1; } .controls{ gap:.8rem; } }
+</style>
+</head>
+<body>
+ <header class="top">
+ <div class="brand"><span class="dot">◆</span> GALLERY <span class="sub">· agentabrams.com</span></div>
+ <div class="count" id="count"></div>
+ </header>
+ <p class="tagline">Finished builds — self-contained, standalone, and openable in their own window.</p>
+
+ <div class="controls">
+ <div><label for="sort">Sort</label>
+ <select id="sort">
+ <option value="num-asc">Build № ↑</option>
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ </div>
+ <div class="density"><label for="density">Density</label>
+ <input id="density" type="range" min="1" max="5" step="1" value="3" aria-label="Grid density" />
+ </div>
+ </div>
+
+ <main class="grid" id="grid" aria-live="polite"></main>
+
+ <!-- preview overlay -->
+ <div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-label="Build preview">
+ <div class="obar">
+ <span class="t" id="ovTitle"></span>
+ <span class="sp"></span>
+ <a class="open" id="ovOpen" target="_blank" rel="noopener">Open in own window ↗</a>
+ <button id="ovClose">Close ✕</button>
+ </div>
+ <iframe id="ovFrame" title="Build preview" allow="autoplay; fullscreen"></iframe>
+ </div>
+
+<script>
+(function(){
+ "use strict";
+ const $ = s => document.querySelector(s);
+ const grid = $("#grid"), countEl = $("#count");
+ let builds = [];
+
+ fetch("builds.json").then(r=>r.json()).then(d=>{
+ builds = (d.builds||[]).slice();
+ countEl.textContent = builds.length + (builds.length===1?" build":" builds");
+ restoreControls();
+ render();
+ if (location.hash) openPreview(location.hash.slice(1));
+ }).catch(()=>{ grid.innerHTML = '<p class="empty">Could not load builds.json</p>'; });
+
+ const fmtDate = s => { try { return new Date(s+"T12:00:00").toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});} catch(e){ return s; } };
+
+ function sorted(){
+ const s = $("#sort").value;
+ const a = builds.slice();
+ if (s==="num-asc") a.sort((x,y)=>x.num-y.num);
+ else if (s==="newest") a.sort((x,y)=>(y.added||"").localeCompare(x.added||""));
+ else if (s==="oldest") a.sort((x,y)=>(x.added||"").localeCompare(y.added||""));
+ else if (s==="title") a.sort((x,y)=>x.title.localeCompare(y.title));
+ return a;
+ }
+
+ function render(){
+ const a = sorted();
+ grid.innerHTML = a.map(b => `
+ <article class="card" data-id="${b.id}">
+ <div class="cover">
+ <span class="num">BUILD ${String(b.num).padStart(2,"0")}</span>
+ ${b.cover_poster ? `<img src="${b.cover_poster}" alt="" loading="lazy" />` : ""}
+ ${b.cover_video ? `<video muted loop playsinline preload="none" src="${b.cover_video}"></video>` : ""}
+ </div>
+ <div class="body">
+ <h2>${b.title}</h2>
+ <p class="desc">${b.desc}</p>
+ <div class="tags">${(b.tags||[]).map(t=>`<span class="tag">${t}</span>`).join("")}</div>
+ <div class="meta">
+ <span class="when" title="Added ${b.added}">🕓 ${fmtDate(b.added)}</span>
+ <span class="actions">
+ <button class="preview" data-id="${b.id}">Preview</button>
+ <button class="open" data-open="${b.path}">Open ↗</button>
+ </span>
+ </div>
+ </div>
+ </article>`).join("");
+
+ // hover-play the cover video (perf: only load on hover)
+ grid.querySelectorAll(".card").forEach(card => {
+ const v = card.querySelector("video");
+ card.addEventListener("mouseenter", ()=>{ if(v){ card.classList.add("playing"); v.play().catch(()=>{});} });
+ card.addEventListener("mouseleave", ()=>{ if(v){ card.classList.remove("playing"); v.pause(); } });
+ });
+ }
+
+ // Open build in its own window (fleet "open-in-own-window" UX)
+ grid.addEventListener("click", e => {
+ const openBtn = e.target.closest("[data-open]");
+ if (openBtn){ window.open(openBtn.dataset.open, "_blank", "noopener"); return; }
+ const prev = e.target.closest(".preview");
+ if (prev){ openPreview(prev.dataset.id); }
+ });
+
+ const overlay = $("#overlay"), ovFrame = $("#ovFrame"), ovTitle = $("#ovTitle"), ovOpen = $("#ovOpen");
+ function openPreview(id){
+ const b = builds.find(x=>x.id===id); if(!b) return;
+ ovTitle.textContent = `Build ${String(b.num).padStart(2,"0")} · ${b.title}`;
+ ovFrame.src = b.path; ovOpen.href = b.path;
+ overlay.classList.add("open");
+ history.replaceState(null,"","#"+id);
+ $("#ovClose").focus();
+ }
+ function closePreview(){
+ overlay.classList.remove("open"); ovFrame.src = "about:blank";
+ history.replaceState(null,"",location.pathname);
+ }
+ $("#ovClose").addEventListener("click", closePreview);
+ overlay.addEventListener("mousedown", e => { if(e.target===overlay) closePreview(); });
+ document.addEventListener("keydown", e => { if(e.key==="Escape" && overlay.classList.contains("open")) closePreview(); });
+
+ // controls: persist sort + density (Steve's grid rule)
+ function restoreControls(){
+ const s = localStorage.getItem("gallery:sort"); if(s) $("#sort").value = s;
+ const d = localStorage.getItem("gallery:density"); if(d){ $("#density").value = d; document.documentElement.style.setProperty("--cols", d); }
+ else document.documentElement.style.setProperty("--cols", $("#density").value);
+ }
+ $("#sort").addEventListener("change", ()=>{ localStorage.setItem("gallery:sort", $("#sort").value); render(); });
+ $("#density").addEventListener("input", ()=>{ const v=$("#density").value; document.documentElement.style.setProperty("--cols", v); localStorage.setItem("gallery:density", v); });
+})();
+</script>
+</body>
+</html>
(oldest)
·
back to Gallery Agentabrams
·
auto-data-snapshot: 2026-09-01T08:28:16 (1 data files) — dep 79c078d →