← back to Afterlight Exhibition
Afterlight exhibition: rebuild + extend Qwen3 14B artifact into a working single-file exhibition
9295b9d6ae5db3f556654b2b5c955e866978c623 · 2026-09-01 06:43:29 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .gitignoreA README.mdA index.html
Diff
commit 9295b9d6ae5db3f556654b2b5c955e866978c623
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 06:43:29 2026 -0700
Afterlight exhibition: rebuild + extend Qwen3 14B artifact into a working single-file exhibition
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 10 +
README.md | 28 ++
index.html | 887 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 925 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ed9365
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
+shot-*.png
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8b7d4fd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,28 @@
+# 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)
+- Graceful media 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/index.html b/index.html
new file mode 100644
index 0000000..e286673
--- /dev/null
+++ b/index.html
@@ -0,0 +1,887 @@
+<!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);
+ }
+
+ /* 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 ⏸";
+ }
+ function applyContrast() {
+ root.setAttribute("data-contrast", contrastHigh ? "high" : "normal");
+ const b = $("#btnContrast");
+ b.setAttribute("aria-pressed", String(contrastHigh));
+ b.textContent = contrastHigh ? "Contrast ●" : "Contrast";
+ }
+ 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>
+ <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);
+ }
+
+ // Save an initial position marker so Resume is available on revisit
+ savePosition();
+})();
+</script>
+</body>
+</html>
(oldest)
·
back to Afterlight Exhibition
·
Add per-object motion studies (in-page Canvas, $0) — era-ton 637567c →