diff --git a/simulator/static/style.css b/simulator/static/style.css
index c92b1fd..849acbd 100644
--- a/simulator/static/style.css
+++ b/simulator/static/style.css
@@ -1,7 +1,32 @@
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
-header { padding: 0.6rem 1rem; background: #000; }
+header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
+ justify-content: space-between; gap: 1rem; }
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
+/* Always-visible link to the credits/colophon — reachable from the work, which is
+ what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
+.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
+.credits-link:hover { text-decoration: underline; }
+
+/* --- Credits / colophon page (credits.html) --- */
+.credits-page { background: #0c0f14; }
+.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
+.credits-wrap h1 { font-size: 1.6rem; }
+.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
+.credits-wrap a { color: #9af; }
+.credits-wrap .back-link { font-size: 13px; }
+.credits-wrap .note { color: #9ab; font-size: 13px; }
+.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
+ padding: 0.8rem 1rem; margin-top: 1rem; }
+.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
+.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
+.credit .title { font-weight: 600; color: #dfe7f2; }
+.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
+.credit .lic.missing { color: #f97; }
+.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
+.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
+.credits-list .loading, .credits-list .err { color: #9ab; }
+.credits-list .err { color: #f97; }
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
/* Stage stays pinned in view while the right pane scrolls on its own. */
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
diff --git a/simulator/unit/credits.test.js b/simulator/unit/credits.test.js
new file mode 100644
index 0000000..9d9cf9e
--- /dev/null
+++ b/simulator/unit/credits.test.js
@@ -0,0 +1,62 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const C = require("../static/credits.js");
+
+const CLIPS = [
+ { id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia " },
+ { id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
+ { id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
+];
+
+test("creditEntries returns one entry per clip, preserving license/source", () => {
+ const out = C.creditEntries(CLIPS);
+ assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
+ const reef = out.find((e) => e.id === "reef_x");
+ assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
+ assert.equal(reef.source, "Wikimedia ");
+ assert.equal(reef.title, "Reef X");
+});
+
+test("creditEntries is sorted by id for deterministic output", () => {
+ const ids = C.creditEntries(CLIPS).map((e) => e.id);
+ assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
+});
+
+test("creditEntries falls back title->id and tolerates missing fields", () => {
+ const out = C.creditEntries([{ id: "bare" }]);
+ assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
+});
+
+test("creditEntries handles null/empty input", () => {
+ assert.deepEqual(C.creditEntries(null), []);
+ assert.deepEqual(C.creditEntries([]), []);
+});
+
+test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
+ const html = C.creditsListHtml(CLIPS);
+ assert.match(html, /Cosmic Cliffs/);
+ assert.match(html, /Joe Mabel/);
+ // free-text source "Wikimedia " must be escaped, not injected as a tag
+ assert.match(html, /Wikimedia <reef>/);
+ assert.doesNotMatch(html, //);
+ // one block per clip
+ const blocks = html.match(/class="credit"/g) || [];
+ assert.equal(blocks.length, CLIPS.length);
+});
+
+test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
+ const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
+ assert.match(html, /