reading map: a second page, arranged to be read rather than to look like weather

The banner draws all 9,194 chunks in one flat colour. `nodes.book` was in the
payload but only ever reached the tooltip, so nothing on screen said which book
or which region you were looking at.

This adds /reading: the same corpus at the pre-flight's 15/0.1 instead of the
banner's 50/0.8, coloured by kind of book, with any single title isolatable.

The two projections are NOT interchangeable, and that is why this is a separate
page rather than a mode of the banner. 50/0.8 was chosen *because* it blurs book
territory into something that reads as a cloud; showing that territory is the one
thing this page exists to do.

Renderer notes:

  • Explorer mode turns on only when the payload actually carries a `kind`
    layer, not when the caller asks for it — a page pointed at the banner's
    file degrades to the flat cloud instead of throwing.
  • Smaller and denser points (0.032/0.62 vs 0.055/0.3). Three hues piling up
    in one volume average toward grey; that pairing is what keeps each one's
    hue, and it is not cosmetic.
  • Isolation dims to 8.5% rather than hiding. Against an empty frame you learn
    where a book's chunks are and lose the only question worth asking, which is
    where they sit relative to everything else.
  • `nebulaBase` keeps a pristine copy of the vertex colours; dimming from an
    already-dimmed buffer compounds and the cloud would fade on every hover.

Kind colours are slots 1-3 of the validated categorical palette, checked
ALL-PAIRS against this file's own #0b0d12 — a point cloud has no series order,
so the adjacent-pairs default that suits stacks and lines is the wrong test.
Worst pair CVD dE 9.4, worst normal-vision dE 20.9.

⚠️ `--map-kind-narrative` (aqua) sits near `--map-trace` (the garden green).
Tolerable only because form separates them absolutely — diffuse points versus
one continuous line — and nothing is encoded by the similarity. If the trace
ever becomes points, re-measure that pair.

The payload is gitignored for the same reason the banner's is: a tracked
generated file dirties the tree and breaks the `git pull` at the top of every
build tick. auto-build.sh's signature now watches both, or a corpus change
would rebuild one page and leave the other on yesterday's cloud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-15 20:42:14 -04:00
co-authored by Claude Opus 5
parent 78c74ff50b
commit 898c942f95
7 changed files with 470 additions and 8 deletions
+4
View File
@@ -11,6 +11,10 @@ public/nb/
# cyrion whenever the corpus or its located highlights move. It was tracked until 2026-08-14, # cyrion whenever the corpus or its located highlights move. It was tracked until 2026-08-14,
# which is why automating the export needed this line first. # which is why automating the export needed this line first.
public/reading_umap.json public/reading_umap.json
# the /reading explorer's payload — same generator, same timer, different projection
# (`export.py --explorer`, 15/0.1 plus the kind and section layers). Tracking it would dirty
# the tree exactly as above.
public/reading_explorer.json
.build_state .build_state
.build.lock .build.lock
.notebooks.lock .notebooks.lock
+5 -2
View File
@@ -31,8 +31,11 @@ sig=$({
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort
# same deal for the reading map: gitignored, written by library-rag-export.service on its # same deal for the reading map: gitignored, written by library-rag-export.service on its
# own timer, so git status/diff above cannot see it change # own timer, so git status/diff above cannot see it change. Both payloads — the banner's and
find "$REPO/public/reading_umap.json" -printf '%T@ %p\n' 2>/dev/null | sort # the /reading explorer's — or a corpus change would rebuild one page and leave the other
# serving yesterday's cloud.
find "$REPO/public/reading_umap.json" "$REPO/public/reading_explorer.json" \
-printf '%T@ %p\n' 2>/dev/null | sort
} | sha256sum | cut -d' ' -f1) } | sha256sum | cut -d' ' -f1)
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
+145
View File
@@ -0,0 +1,145 @@
---
/**
* The reading explorer — the same cloud as the banner, made legible.
*
* import ReadingExplorer from '../components/ReadingExplorer.astro';
* <ReadingExplorer />
*
* Pieces:
* src/components/ReadingExplorer.astro ← this file
* src/lib/reading-map.js ← the renderer, SHARED with the banner (explorer: true)
* src/styles/reading-map.css ← canvas, tooltip, every --map-* token
* src/styles/reading-explorer.css ← the legend and picker below
* public/reading_explorer.json ← generated; NOT in git
*
* ⚠️ **A different payload from the banner, and a different projection.** The banner is
* `export.py` at 50/0.8 (`airy`); this is `export.py --explorer` at 15/0.1 plus per-node `kind`
* and `section` layers. The two are not interchangeable — 50/0.8 was chosen for the banner
* *because* it shows book territory less clearly, which is the one thing this page exists to
* show. See library-rag/lab/PLAN.md §0.
*
* ⚠️ **The payload is gitignored and generated on cyrion**, exactly like the banner's. A fresh
* checkout has no map until the export runs; anywhere that is not cyrion, that is never, and the
* figure stays hidden. Fetch garden.c0smere.net/reading_explorer.json, or run
* `uv run umap/export.py --explorer -o <checkout>/public/reading_explorer.json` from library-rag/.
*/
import '../styles/reading-map.css';
import '../styles/reading-explorer.css';
interface Props {
/** Payload URL. Same-origin: a static file, not an API call. */
src?: string;
caption?: string | null;
}
const {
src = '/reading_explorer.json',
caption = 'Every passage of every book, placed by what it is about and coloured by what kind of book it came from. Pick a title to see where it sits.',
} = Astro.props;
---
<figure class="reading-explorer">
<div class="reading-map reading-map--explorer" data-src={src}></div>
{caption && <figcaption>{caption}</figcaption>}
{/* Filled from the payload at mount — counts and swatches both. Neither is hardcoded: a kind's
share moves every time a book is indexed, and two sources for one colour is how a legend
ends up describing a picture that is no longer on screen. */}
<ul class="reading-legend" data-legend></ul>
<div class="reading-picker">
<label for="reading-book">Isolate a book</label>
<select id="reading-book" data-picker>
<option value="">All {' '}books</option>
</select>
<button type="button" data-clear hidden>Show all</button>
</div>
</figure>
<script>
const figure = document.querySelector<HTMLElement>('.reading-explorer');
const container = figure?.querySelector<HTMLElement>('.reading-map');
if (container) {
try {
// Dynamic import so `three` lands in its own chunk. Same reasoning as the banner, and the
// same ⚠️: do NOT gate this on IntersectionObserver. `.reading-map:not([data-ready])` is
// `display: none`, a display-none element never intersects, and load-on-visible deadlocks.
const { mount } = await import('../lib/reading-map.js');
const map = await mount(container, {
src: container.dataset.src!,
explorer: true,
onError: (err: unknown) => console.warn('reading explorer unavailable:', err),
});
if (map) {
const legend = figure!.querySelector<HTMLElement>('[data-legend]');
const picker = figure!.querySelector<HTMLSelectElement>('[data-picker]');
const clear = figure!.querySelector<HTMLButtonElement>('[data-clear]');
// --- legend ---------------------------------------------------------------------
// `map.kinds` is empty when the payload has no kind layer (i.e. someone pointed this at
// the banner's file). Then there is nothing to explain and the legend stays empty rather
// than inventing categories.
if (legend && map.kinds.length) {
for (const k of map.kinds) {
const li = document.createElement('li');
const dot = document.createElement('i');
dot.style.background = k.color;
const name = document.createElement('b');
name.textContent = k.name;
const count = document.createElement('span');
count.className = 'reading-legend__count';
count.textContent = `${k.chunks.toLocaleString()} passages · ${k.books.length} books`;
li.append(dot, name, count);
legend.append(li);
}
}
// --- picker ---------------------------------------------------------------------
// Grouped by kind so the menu carries the same structure the colours do, and sorted
// by title inside each group. The option VALUE is the book's index in `data.books`,
// which is exactly what isolateBook() takes.
if (picker && map.kinds.length) {
for (const k of map.kinds) {
const group = document.createElement('optgroup');
group.label = k.name;
const books = [...k.books].sort((a, b) => a.title.localeCompare(b.title));
for (const { title, index } of books) {
const opt = document.createElement('option');
opt.value = String(index);
opt.textContent = title;
group.append(opt);
}
if (group.childElementCount) picker.append(group);
}
picker.addEventListener('change', () => {
const v = picker.value;
map.isolateBook(v === '' ? null : Number(v));
if (clear) clear.hidden = v === '';
});
clear?.addEventListener('click', () => {
picker.value = '';
map.isolateBook(null);
clear.hidden = true;
});
}
}
} catch (err) {
/* chunk failed to load: the figure stays hidden by the fail-closed CSS */
console.warn('reading explorer unavailable:', err);
}
}
</script>
<style>
.reading-explorer { margin: 0 0 2rem; }
.reading-explorer figcaption {
margin-top: 0.6rem;
font-size: 0.82rem;
line-height: 1.55;
opacity: 0.74;
}
.reading-explorer:not(:has([data-ready])) figcaption { display: none; }
</style>
+117 -6
View File
@@ -22,6 +22,15 @@ const HOLD_SECONDS = 4; // beat at the end before looping
const ORBIT_RATE = 0.035; // radians/sec — slow enough to read text over const ORBIT_RATE = 0.035; // radians/sec — slow enough to read text over
const POINTER_FINE = window.matchMedia('(pointer: fine)'); const POINTER_FINE = window.matchMedia('(pointer: fine)');
/**
* Explorer only: how much a chunk outside the isolated book is knocked back.
*
* A dim, not a hide. Isolating a book against an empty frame answers "where are its chunks" and
* destroys the only question worth asking — "where does it sit *relative to everything else*."
* The rest of the corpus has to stay legible as context or the feature is pointless.
*/
const ISOLATE_DIM = 0.085;
/** Read a CSS custom property off the container, with a fallback. Keeps theming in CSS. */ /** Read a CSS custom property off the container, with a fallback. Keeps theming in CSS. */
function cssVar(el, name, fallback) { function cssVar(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim(); const v = getComputedStyle(el).getPropertyValue(name).trim();
@@ -71,11 +80,11 @@ function perVertexSize(shader) {
shader.vertexShader.replace(target, 'gl_PointSize = size * aScale;'); shader.vertexShader.replace(target, 'gl_PointSize = size * aScale;');
} }
export async function mount(container, { src, onError } = {}) { export async function mount(container, { src, onError, explorer = false } = {}) {
try { try {
const res = await fetch(src); const res = await fetch(src);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return build(container, await res.json()); return build(container, await res.json(), { explorer });
} catch (err) { } catch (err) {
// Fail closed, the way the garden's other widgets do: leave the container hidden rather // Fail closed, the way the garden's other widgets do: leave the container hidden rather
// than showing a broken box. A missing payload should degrade to a plain page. // than showing a broken box. A missing payload should degrade to a plain page.
@@ -84,7 +93,7 @@ export async function mount(container, { src, onError } = {}) {
} }
} }
export function build(container, data) { export function build(container, data, { explorer = false } = {}) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const colors = { const colors = {
@@ -96,6 +105,24 @@ export function build(container, data) {
quoted: cssVar(container, '--map-quoted', '#e879f9'), quoted: cssVar(container, '--map-quoted', '#e879f9'),
}; };
// Kind colouring is on only when the payload actually CARRIES the layer, not merely when the
// caller asked for it. `export.py` and `export.py --explorer` write different files, and a
// page pointed at the wrong one should degrade to the flat cloud rather than throw.
const byKind = explorer && Array.isArray(data.nodes.kind) && Array.isArray(data.meta?.kinds);
if (byKind) {
// Slots 13 of the validated categorical palette, in fixed order: blue, orange, aqua. These
// three clear the ALL-PAIRS CVD and normal-vision floors against this exact surface
// (#0b0d12), which the adjacent-pairs default does not guarantee — a point cloud puts every
// category next to every other one, so all-pairs is the right test for it.
//
// ⚠️ Fixed order, never cycled. A fourth kind does not get a generated hue; it stays
// `unclassified` grey until someone decides what it is. See library-rag/umap/kinds.py.
data.meta.kinds.forEach((k, i) => {
colors[`kind-${i}`] = cssVar(container, `--map-kind-${k}`,
['#3987e5', '#d95926', '#199e70'][i] || '#6b7280');
});
}
// --- scene ------------------------------------------------------------------------------ // --- scene ------------------------------------------------------------------------------
const scene = new THREE.Scene(); const scene = new THREE.Scene();
@@ -125,16 +152,41 @@ export function build(container, data) {
// --- the nebula: every chunk, one draw call --------------------------------------------- // --- the nebula: every chunk, one draw call ---------------------------------------------
const nebulaGeo = new THREE.BufferGeometry(); const nebulaGeo = new THREE.BufferGeometry();
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3)); nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
// Per-vertex colour, and the untouched per-vertex BASE kept alongside it. Isolation rewrites
// the live attribute in place, so without a pristine copy to restore from, dimming twice
// would compound and the cloud would fade a notch on every hover.
let nebulaBase = null;
if (byKind) {
const rgb = new Float32Array(nodeCount * 3);
const swatch = data.meta.kinds.map((_, i) => new THREE.Color(colors[`kind-${i}`]));
for (let i = 0; i < nodeCount; i++) {
const c = swatch[data.nodes.kind[i]] || swatch[0];
rgb[i * 3] = c.r; rgb[i * 3 + 1] = c.g; rgb[i * 3 + 2] = c.b;
}
nebulaGeo.setAttribute('color', new THREE.BufferAttribute(rgb, 3));
nebulaBase = Float32Array.from(rgb);
}
// Big and faint rather than small and solid. Individual chunks are not the subject — the // Big and faint rather than small and solid. Individual chunks are not the subject — the
// density is — so overlapping soft sprites are allowed to pile into brighter regions where // density is — so overlapping soft sprites are allowed to pile into brighter regions where
// the corpus is thick, which is exactly where a book's territory sits. // the corpus is thick, which is exactly where a book's territory sits.
//
// ⚠️ The explorer runs SMALLER AND DENSER, and that pairing is not cosmetic. Three hues
// accumulating in one volume at the banner's 0.055/0.3 average toward a single grey-brown —
// soft overlapping sprites blend toward neutral, which is invisible with one colour and fatal
// with three. Smaller points overlap less; more opacity keeps each one's hue.
//
// `vertexColors` MULTIPLIES material.color by the attribute, so the material must be white or
// every kind would be filtered through the nebula hue. Same trap the signal layer documents.
const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({ const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({
color: new THREE.Color(colors.nebula), color: byKind ? 0xffffff : new THREE.Color(colors.nebula),
vertexColors: byKind,
map: sprite, map: sprite,
size: 0.055, size: byKind ? 0.032 : 0.055,
sizeAttenuation: true, sizeAttenuation: true,
transparent: true, transparent: true,
opacity: 0.3, opacity: byKind ? 0.62 : 0.3,
depthWrite: false, depthWrite: false,
fog: true, fog: true,
})); }));
@@ -420,6 +472,39 @@ export function build(container, data) {
}); });
} }
// --- isolation ----------------------------------------------------------------------------
/**
* Knock every chunk outside one book back to a whisper. `null` restores the whole cloud.
*
* Scales each dimmed vertex toward black rather than lerping toward the fog colour. The garden
* is fixed dark and `--map-fog` IS `--bg` (#0b0d12), so multiplying down lands where a real
* alpha fade would — and `PointsMaterial` has no per-vertex alpha, which is what this would
* otherwise need. ⚠️ On a light surface this must become a lerp toward fog instead; multiplying
* toward black there would make dimmed points the most prominent thing on screen.
*
* Always restores from `nebulaBase`, never from the live attribute — see its declaration.
*/
let isolated = null;
function isolateBook(bookIndex) {
if (!byKind || !nebulaBase) return null;
const attr = nebulaGeo.getAttribute('color');
const rgb = attr.array;
if (bookIndex === null || bookIndex === undefined) {
rgb.set(nebulaBase);
isolated = null;
} else {
for (let i = 0; i < nodeCount; i++) {
const k = data.nodes.book[i] === bookIndex ? 1 : ISOLATE_DIM;
rgb[i * 3] = nebulaBase[i * 3] * k;
rgb[i * 3 + 1] = nebulaBase[i * 3 + 1] * k;
rgb[i * 3 + 2] = nebulaBase[i * 3 + 2] * k;
}
isolated = bookIndex;
}
attr.needsUpdate = true;
return isolated;
}
// --- resize ------------------------------------------------------------------------------- // --- resize -------------------------------------------------------------------------------
// ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready` // ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready`
// is set, so a container measured first reports 0×0, the early return leaves the canvas at // is set, so a container measured first reports 0×0, the early return leaves the canvas at
@@ -527,6 +612,32 @@ export function build(container, data) {
// renderer drew with — including whichever `--map-*` scheme modifier is on the container. // renderer drew with — including whichever `--map-*` scheme modifier is on the container.
// Two sources for one colour is how a legend ends up quietly lying about the picture. // Two sources for one colour is how a legend ends up quietly lying about the picture.
colors, colors,
/** Explorer only. A no-op returning null when the payload carries no kind layer. */
isolateBook,
get isolatedBook() { return isolated; },
/**
* What a legend needs, COUNTED FROM THE PAYLOAD rather than hardcoded. A kind's share moves
* every time a book is indexed, and the same rule the swatches follow applies to the counts:
* a legend that disagrees with the picture is worse than no legend at all.
*
* Empty when there is no kind layer, so a caller can branch on `.length`.
*/
kinds: byKind
? data.meta.kinds.map((name, i) => ({
name,
color: colors[`kind-${i}`],
chunks: data.nodes.kind.reduce((n, k) => n + (k === i), 0),
// `{ title, index }`, not bare titles: `index` is the position in `data.books`, which
// is exactly what `isolateBook()` takes. Handing back titles alone would make every
// caller re-derive the index by searching on a string — and two books could share one.
books: data.books
.map((b, index) => ({ title: b.title, index, kind: b.kind }))
.filter((b) => b.kind === i)
.map(({ title, index }) => ({ title, index })),
}))
: [],
}; };
} }
+66
View File
@@ -0,0 +1,66 @@
---
import Base from '../layouts/Base.astro';
import ReadingExplorer from '../components/ReadingExplorer.astro';
---
<Base
title="The reading map"
description="Every passage of every book I have read, projected into three dimensions by what it is about — coloured by kind of book, and searchable one title at a time."
>
<h1>The reading map</h1>
<p>
The homepage banner draws this same cloud in a single colour, arranged to look like
weather. This is the version arranged to be <em>read</em>: same corpus, a tighter
projection, and every passage coloured by what kind of book it came from.
</p>
<ReadingExplorer />
<h2>What you are looking at</h2>
<p>
Every book is split into passages of roughly 500 words, and each passage is turned into
768 numbers by an embedding model — a position in a space where things that mean similar
things end up near each other. That space is then squashed down to three dimensions so it
fits in a browser. Nearness is meaning; the axes themselves mean nothing at all.
</p>
<p>
The obvious question is whether this is a map of <em>ideas</em> or just a map of
<em>books</em>, and it is measurably the latter: 82% of a passage's fifteen nearest
neighbours come from the same book, against 6% if they were drawn at random. Prose style
is a much louder signal than subject matter. So each book is an island — and the honest
interesting part is how the islands are <em>arranged</em>.
</p>
<p>
Cluster the map without telling it anything about the books, and it splits the Warhammer
40,000 novels from everything else with not one book straddling the line. Split it further
and the treatises — Aurelius, Seneca, Aristotle, Beauvoir — pull away from the narratives,
with <em>Nausea</em>, <em>The Stranger</em> and <em>Notes From Underground</em> landing
beside Franklin and Lovecraft rather than beside the philosophy they are usually shelved
with. Nobody labelled that. It fell out of the prose.
</p>
<p class="reading-note">
Two honest caveats. <em>Narrative</em> is one colour occupying two separate regions, because
<em>Empire of the Vampire</em> is large enough to hold a territory of its own — that is book
size, not a fourth kind of book. And <em>Notes From Underground</em> genuinely refuses to sit
still: only 68% of it lands with its own group, which is about right for a book that is half
confession and half argument.
</p>
<p class="reading-note">
The passages themselves are not in the file this page downloads — only coordinates, which
cannot be turned back into text. The quotes that appear when you hover a highlighted point
are ones already published under <a href="/Quotes/">Quotes</a>.
</p>
</Base>
<style>
.reading-note {
font-size: 0.85rem;
color: var(--muted);
}
</style>
+112
View File
@@ -0,0 +1,112 @@
/*
* Chrome for the reading EXPLORER — the /reading page.
*
* Loads on top of `reading-map.css`, which owns the canvas, the tooltip and every `--map-*`
* token including the kind colours. This file is only the controls the banner does not have:
* the kind legend and the book picker.
*
* ⚠️ Same rule as its sibling: the garden is fixed dark, so nothing here keys off
* `prefers-color-scheme`. The palette follows the SITE.
*/
/* Taller than the banner. This is the page's subject rather than a header ornament, and book
territory is what the reader is here to look at — at the banner's height, the isolated book's
chunks are too few pixels to locate. */
.reading-map--explorer {
height: clamp(26rem, 78vh, 52rem);
}
/* --- the kind legend ------------------------------------------------------------------------
Colour is doing real encoding work here, unlike on the banner, so the legend is not optional
chrome: it is where hue is bound to meaning. It stays in the DOM and unhidden even before the
map mounts, because it is also the page's only statement of what the three groups ARE. */
.reading-legend {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.3rem;
margin: 0.85rem 0 0;
padding: 0;
list-style: none;
font-size: 0.8rem;
line-height: 1.5;
}
.reading-legend li {
display: flex;
align-items: baseline;
gap: 0.45rem;
}
/* Swatches are painted from the palette the RENDERER resolved, not from CSS — see the component
script. One source for a colour, or the legend eventually describes a picture that changed. */
.reading-legend i {
flex: none;
width: 0.62rem;
height: 0.62rem;
border-radius: 50%;
transform: translateY(0.06rem);
}
.reading-legend b {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.reading-legend .reading-legend__count {
color: var(--muted, hsla(0, 0%, 100%, 0.64));
font-weight: 400;
}
/* --- the book picker ------------------------------------------------------------------------ */
.reading-picker {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
margin: 1rem 0 0;
font-size: 0.82rem;
}
.reading-picker label {
color: var(--muted, hsla(0, 0%, 100%, 0.64));
}
.reading-picker select {
flex: 1 1 16rem;
max-width: 28rem;
padding: 0.34rem 0.5rem;
font: inherit;
color: var(--fg, #fff);
background: var(--map-surface, rgba(16, 19, 27, 0.92));
border: 1px solid var(--hair, hsla(0, 0%, 100%, 0.14));
border-radius: 0.3rem;
}
/* The native menu renders in the OS palette on most platforms; these keep it legible on the
ones that honour author colours for options. */
.reading-picker select optgroup,
.reading-picker select option {
color: var(--fg, #fff);
background: #12151d;
}
.reading-picker button {
padding: 0.34rem 0.7rem;
font: inherit;
color: var(--fg, #fff);
background: transparent;
border: 1px solid var(--hair, hsla(0, 0%, 100%, 0.14));
border-radius: 0.3rem;
cursor: pointer;
}
.reading-picker button:hover { border-color: var(--link-bright, #46c421); }
.reading-picker button[hidden] { display: none; }
/* Hide the controls entirely when the map failed to mount — the fail-closed rule the banner
already follows. A picker that drives nothing is worse than no picker. */
.reading-explorer:not(:has([data-ready])) .reading-picker,
.reading-explorer:not(:has([data-ready])) .reading-legend { display: none; }
@media (prefers-reduced-motion: reduce) {
.reading-picker select, .reading-picker button { transition: none; }
}
+21
View File
@@ -29,6 +29,27 @@
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */ and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
--map-nebula: #46536b; --map-nebula: #46536b;
/* --- kind colours: the EXPLORER only ------------------------------------------------------
The banner draws one flat `--map-nebula`; /reading colours each chunk by what kind of book
it came from. Slots 13 of the validated categorical palette, in fixed order.
⚠️ Validated ALL-PAIRS, not adjacent-pairs, against this file's `--map-fog` (#0b0d12). A
point cloud has no series order — every kind touches every other kind somewhere in the
volume — so the adjacent-pairs default that suits stacks and lines is the wrong test here.
Measured (OKLab ×100): worst pair CVD ΔE 9.4 (deutan, aqua↔orange), worst normal-vision
ΔE 20.9 (aqua↔blue), all three ≥3:1 contrast on the surface.
⚠️ `--map-kind-narrative` is aqua and `--map-trace` is the garden green. They are close in
hue, and that is tolerable ONLY because form separates them absolutely: the kinds are a
diffuse cloud of points, the trace is one continuous line. Nothing is encoded by their
similarity. If the trace ever becomes points, this pair has to be re-measured.
A fourth kind is NOT given a generated hue — see umap/kinds.py. It falls back to grey. */
--map-kind-warhammer: #3987e5;
--map-kind-narrative: #d95926;
--map-kind-treatise: #199e70;
--map-kind-unclassified: #6b7280;
/* --- the chosen scheme: `frost` --------------------------------------------------------- /* --- the chosen scheme: `frost` ---------------------------------------------------------
White-hot highlights over a cool cloud, with the garden's green as the thread. Picked White-hot highlights over a cool cloud, with the garden's green as the thread. Picked
2026-08-12. It is also the only one of the three whose two marks are far enough apart 2026-08-12. It is also the only one of the three whose two marks are far enough apart