Reading map: a 3D hero of everything read, traced by what I highlighted

An orbiting point cloud of all 8,424 indexed passages, with the 262 highlighted
ones lit and threaded in the order they were marked. Data comes from library-rag
(chunks + embeddings) projected to 3D with UMAP; the page never touches Postgres.

Projection is `airy` (n_neighbors=50, min_dist=0.8) and the palette is `frost` --
both chosen by eye against the alternatives. Note that airy shows the underlying
book-territory structure LESS than the tight projection the pre-flight measured,
so the 0.805 k-NN purity figure describes that one, not this.

Page weight: the wrapper adds 321 B gzip to the homepage's blocking load. `three`
(127 KB gz) and the payload (56 KB gz) are a dynamic chunk fetched after render.
It fails closed -- no WebGL, no payload, or a dead fetch leaves a plain page.

The payload is a static snapshot; the 5-minute auto-build rebuilds the site, not
the data. New reading reaches this map only when export.py is re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-13 08:30:45 -04:00
co-authored by Claude Opus 5
parent 9da9a6cba2
commit 9519c46fe2
7 changed files with 593 additions and 0 deletions
+7
View File
@@ -17,6 +17,7 @@
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"satori": "^0.28.2", "satori": "^0.28.2",
"three": "^0.185.1",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
} }
}, },
@@ -6018,6 +6019,12 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tiny-inflate": { "node_modules/tiny-inflate": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+1
View File
@@ -18,6 +18,7 @@
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"satori": "^0.28.2", "satori": "^0.28.2",
"three": "^0.185.1",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"allowScripts": { "allowScripts": {
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
---
/**
* The reading map — an orbiting point cloud of every passage read, traced in the
* order it was highlighted.
*
* import ReadingMap from '../components/ReadingMap.astro';
* <ReadingMap />
*
* Pieces:
* src/components/ReadingMap.astro ← this file
* src/lib/reading-map.js ← the renderer (shared with the standalone preview
* in c0smere_devops/library-rag/umap/web)
* src/styles/reading-map.css ← the chrome
* public/reading_umap.json ← `uv run umap/export.py -o …`
*
* ⚠️ The payload is a STATIC SNAPSHOT. Nothing on cyrion regenerates it — the 5-minute
* auto-build rebuilds the site, not the data. New reading only reaches this map when
* someone re-runs `export.py` and commits the result. See library-rag/umap/PLAN.md §7.
*/
import '../styles/reading-map.css';
interface Props {
/** Payload URL. Same-origin: a static file, not an API call. */
src?: string;
/** Caption under the canvas. Pass `null` to omit. */
caption?: string | null;
}
const {
src = '/reading_umap.json',
caption = 'Every passage I have read, arranged by what it is about — traced in the order I highlighted it.',
} = Astro.props;
---
<figure class="reading-map-figure">
<div class="reading-map" data-src={src}></div>
{caption && <figcaption>{caption}</figcaption>}
</figure>
<script>
// Dynamic import so `three` lands in its own chunk rather than the page bundle, and so a
// browser that never gets here (no WebGL, blocked script) pays nothing for it.
//
// ⚠️ Do NOT gate this on IntersectionObserver. `.reading-map:not([data-ready])` is
// `display: none` — that is the fail-closed rule — and a `display: none` element has no
// box, so it never intersects and the observer would never fire. Load-on-visible here
// deadlocks: hidden until mounted, never mounted because hidden. The renderer runs its
// own IntersectionObserver internally to park the animation loop offscreen, which is the
// part that actually matters for cost.
const containers = document.querySelectorAll<HTMLElement>('.reading-map');
if (containers.length) {
try {
const { mount } = await import('../lib/reading-map.js');
for (const el of containers) {
mount(el, {
src: el.dataset.src!,
// Fail closed, like the garden's other widgets: a missing payload or a dead GPU
// leaves a plain page rather than an empty bordered box.
onError: (err: unknown) => console.warn('reading map unavailable:', err),
});
}
} catch (err) {
/* chunk failed to load: the figure stays hidden */
console.warn('reading map unavailable:', err);
}
}
</script>
<style>
.reading-map-figure {
margin: 0 0 2rem;
}
.reading-map-figure figcaption {
margin-top: 0.6rem;
font-size: 0.8rem;
line-height: 1.5;
opacity: 0.72;
}
/* Hide the caption too when the map itself failed to mount. */
.reading-map-figure:not(:has([data-ready])) figcaption {
display: none;
}
</style>
+356
View File
@@ -0,0 +1,356 @@
/**
* The reading map — an orbiting point cloud of everything read, traced by everything highlighted.
*
* Framework-agnostic on purpose: one `mount()` that takes a container and a payload URL. The
* Astro wrapper (`ReadingMap.astro`) is thin, and the standalone preview uses the same module,
* so what gets reviewed is what ships.
*
* Payload contract is `umap/PLAN.md` §2, and two parts of it are load-bearing:
*
* • `trace` is pre-sorted chronologically and MUST NOT be re-sorted. `t` is date-only and
* many highlights share a day, so array order is the only thing carrying the sequence.
* • There is no `has_highlight` flag. The signal set is the distinct `trace[].node` values —
* 262 highlights over 206 chunks, because one passage can be highlighted more than once.
*/
import * as THREE from 'three';
const DRAW_SECONDS = 45; // one full pass of the trace
const HOLD_SECONDS = 4; // beat at the end before looping
const ORBIT_RATE = 0.035; // radians/sec — slow enough to read text over
const POINTER_FINE = window.matchMedia('(pointer: fine)');
/** Read a CSS custom property off the container, with a fallback. Keeps theming in CSS. */
function cssVar(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim();
return v || fallback;
}
/**
* A soft radial dot, drawn once and shared by every point material.
*
* The default `PointsMaterial` splat is a hard-edged square. At the sizes this scene uses that
* reads as grit — thousands of tiny tiles — rather than as a cloud. A gaussian-ish falloff with
* no hard rim is the whole difference between "scatter plot" and "nebula": overlapping sprites
* accumulate into something that looks continuous instead of stippled.
*/
function dotTexture() {
const S = 64;
const c = document.createElement('canvas');
c.width = c.height = S;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.62)');
g.addColorStop(0.72, 'rgba(255,255,255,0.13)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, S, S);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
export async function mount(container, { src, onError } = {}) {
try {
const res = await fetch(src);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return build(container, await res.json());
} catch (err) {
// 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.
onError?.(err);
return null;
}
}
export function build(container, data) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const colors = {
nebula: cssVar(container, '--map-nebula', '#9aa4b2'),
signal: cssVar(container, '--map-signal', '#ea580c'),
trace: cssVar(container, '--map-trace', '#2563eb'),
fog: cssVar(container, '--map-fog', '#fcfcfb'),
};
// --- scene ------------------------------------------------------------------------------
const scene = new THREE.Scene();
// Depth cue. Without it a point cloud is a flat spray of dots that happens to rotate; with
// it, the far side recedes into the page colour and the thing reads as a volume. This is the
// single biggest lever on whether it looks like a cloud, and it costs one line.
scene.fog = new THREE.FogExp2(new THREE.Color(colors.fog), 0.42);
// Closer, with a narrower lens: the cloud is normalised to roughly unit radius, so filling
// the frame means being near it rather than zoomed out looking at a speck.
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0, 2.35);
const sprite = dotTexture();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
container.appendChild(renderer.domElement);
// Everything rotates together, so the trace stays welded to the cloud it runs through.
const world = new THREE.Group();
scene.add(world);
const xyz = Float32Array.from(data.nodes.xyz);
const nodeCount = xyz.length / 3;
// --- the nebula: every chunk, one draw call ---------------------------------------------
const nebulaGeo = new THREE.BufferGeometry();
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
// 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
// the corpus is thick, which is exactly where a book's territory sits.
const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({
color: new THREE.Color(colors.nebula),
map: sprite,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0.3,
depthWrite: false,
fog: true,
}));
world.add(nebula);
// --- the signal: chunks holding a highlight ----------------------------------------------
// Distinct nodes, in first-appearance order, so `signalRow` can map a raycast hit back to
// the trace entries that live there.
const signalNodes = [];
const rowOfNode = new Map();
for (const t of data.trace) {
if (!rowOfNode.has(t.node)) {
rowOfNode.set(t.node, signalNodes.length);
signalNodes.push(t.node);
}
}
const entriesAtNode = new Map();
for (const t of data.trace) {
if (!entriesAtNode.has(t.node)) entriesAtNode.set(t.node, []);
entriesAtNode.get(t.node).push(t);
}
const signalPos = new Float32Array(signalNodes.length * 3);
signalNodes.forEach((n, i) => {
signalPos[i * 3] = xyz[n * 3];
signalPos[i * 3 + 1] = xyz[n * 3 + 1];
signalPos[i * 3 + 2] = xyz[n * 3 + 2];
});
const signalGeo = new THREE.BufferGeometry();
signalGeo.setAttribute('position', new THREE.BufferAttribute(signalPos, 3));
const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({
color: new THREE.Color(colors.signal),
map: sprite,
size: 0.062,
sizeAttenuation: true,
transparent: true,
opacity: 1,
// Highlights are the point of the picture; never let the nebula bury one. They also stay
// out of the fog, so a highlight on the far side is still findable.
depthTest: false,
depthWrite: false,
fog: false,
}));
signal.renderOrder = 2;
world.add(signal);
// --- the trace: highlights in the order they were made -----------------------------------
// Plain THREE.Line rather than Line2. Line2 gives real thickness but its geometry is
// instanced, so `setDrawRange` does not clip it — the progressive draw would need
// instanceCount juggling for a line that is 1px on most displays anyway.
const tracePos = new Float32Array(data.trace.length * 3);
data.trace.forEach((t, i) => {
tracePos[i * 3] = xyz[t.node * 3];
tracePos[i * 3 + 1] = xyz[t.node * 3 + 1];
tracePos[i * 3 + 2] = xyz[t.node * 3 + 2];
});
const traceGeo = new THREE.BufferGeometry();
traceGeo.setAttribute('position', new THREE.BufferAttribute(tracePos, 3));
traceGeo.setDrawRange(0, 1);
const trace = new THREE.Line(traceGeo, new THREE.LineBasicMaterial({
color: new THREE.Color(colors.trace),
transparent: true,
opacity: 0.6,
depthTest: false,
fog: false,
}));
trace.renderOrder = 3;
world.add(trace);
// The leading edge, so the eye has something to follow.
const headGeo = new THREE.BufferGeometry();
headGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
const head = new THREE.Points(headGeo, new THREE.PointsMaterial({
color: new THREE.Color(colors.signal),
map: sprite,
size: 0.17,
sizeAttenuation: true,
transparent: true,
opacity: 1,
depthTest: false,
fog: false,
}));
head.renderOrder = 4;
world.add(head);
// --- overlays ----------------------------------------------------------------------------
const dateEl = document.createElement('div');
dateEl.className = 'reading-map__date';
container.appendChild(dateEl);
const tipEl = document.createElement('figure');
tipEl.className = 'reading-map__tip';
tipEl.hidden = true;
container.appendChild(tipEl);
const fmtDate = (iso) => {
const [y, m] = iso.split('-');
return new Date(Date.UTC(+y, +m - 1, 1))
.toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' });
};
function showTip(node, clientX, clientY) {
const entries = entriesAtNode.get(node);
if (!entries) return;
const book = data.books[data.nodes.book[node]];
const e = entries[0];
const more = entries.length > 1
? `<span class="reading-map__more">+${entries.length - 1} more here</span>` : '';
// The attribution is COALESCE(attributed_to, book_author) — an introduction is credited to
// whoever wrote it, not to whoever wrote the book around it.
const byline = [e.attribution, e.editorial ? 'introduction' : null]
.filter(Boolean).join(' · ');
const voice = e.spoken_by
? `<span class="reading-map__voice">spoken by ${escapeHtml(e.spoken_by)}</span>` : '';
tipEl.innerHTML =
`<blockquote>${escapeHtml(e.text)}</blockquote>` +
`<figcaption><strong>${escapeHtml(book.title)}</strong>` +
`<span>${escapeHtml(byline)}</span>${voice}` +
`<time>${fmtDate(e.t)}</time>${more}</figcaption>`;
tipEl.hidden = false;
const r = container.getBoundingClientRect();
const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
let x = clientX - r.left + 16, y = clientY - r.top + 16;
if (x + w > r.width) x = clientX - r.left - w - 16;
if (y + h > r.height) y = clientY - r.top - h - 16;
tipEl.style.transform = `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)`;
}
// --- picking ------------------------------------------------------------------------------
const raycaster = new THREE.Raycaster();
raycaster.params.Points.threshold = 0.045;
const pointer = new THREE.Vector2();
let hoverNode = null;
let paused = false;
function onMove(ev) {
const r = renderer.domElement.getBoundingClientRect();
pointer.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObject(signal, false)[0];
if (hit) {
hoverNode = signalNodes[hit.index];
paused = true; // hold the orbit still while reading
showTip(hoverNode, ev.clientX, ev.clientY);
renderer.domElement.style.cursor = 'pointer';
} else if (hoverNode !== null) {
hoverNode = null;
paused = false;
tipEl.hidden = true;
renderer.domElement.style.cursor = '';
}
}
if (POINTER_FINE.matches) {
renderer.domElement.addEventListener('pointermove', onMove);
renderer.domElement.addEventListener('pointerleave', () => {
hoverNode = null; paused = false; tipEl.hidden = true;
});
}
// --- resize -------------------------------------------------------------------------------
// ⚠️ 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
// three's 300×150 default, and the scene renders correct geometry into the wrong box. It
// recovers on the next ResizeObserver callback, which makes the bug intermittent rather than
// absent — the worst kind. Reveal, then measure.
container.dataset.ready = 'true';
const resize = () => {
const { clientWidth: w, clientHeight: h } = container;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
// --- loop ---------------------------------------------------------------------------------
// Constant INDEX rate, not constant time rate. 188 of 293 highlights fall in JulyAugust
// 2025 and several months are empty; pacing by wall-clock date would park the head in one
// cluster for most of the loop and then crawl through dead air. The date readout carries
// the unevenness instead, which is the honest place for it.
let raf = 0, last = performance.now(), elapsed = 0, shownIndex = -1;
const total = data.trace.length;
function frame(now) {
raf = requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (!paused) world.rotation.y += ORBIT_RATE * dt;
elapsed += dt;
const cycle = DRAW_SECONDS + HOLD_SECONDS;
if (elapsed > cycle) elapsed -= cycle;
const drawn = reduced
? total
: Math.max(1, Math.min(total, Math.round((elapsed / DRAW_SECONDS) * total)));
traceGeo.setDrawRange(0, drawn);
const h = (drawn - 1) * 3;
head.geometry.attributes.position.setXYZ(0, tracePos[h], tracePos[h + 1], tracePos[h + 2]);
head.geometry.attributes.position.needsUpdate = true;
if (drawn - 1 !== shownIndex) {
shownIndex = drawn - 1;
dateEl.textContent = fmtDate(data.trace[shownIndex].t);
}
renderer.render(scene, camera);
}
// Nothing runs until the banner is actually on screen — an orbiting WebGL scene has no
// business burning a frame budget while the reader is three screens down.
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
else if (!entry.isIntersecting && raf) { cancelAnimationFrame(raf); raf = 0; }
}, { threshold: 0.01 });
io.observe(container);
return {
destroy() {
cancelAnimationFrame(raf);
io.disconnect(); ro.disconnect();
renderer.dispose();
nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose();
sprite.dispose();
container.querySelector('canvas')?.remove();
dateEl.remove(); tipEl.remove();
},
stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
};
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
+2
View File
@@ -1,6 +1,7 @@
--- ---
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import ReadingMap from '../components/ReadingMap.astro';
const entries = await getCollection('garden'); const entries = await getCollection('garden');
const home = entries.find((e) => e.id === 'index'); const home = entries.find((e) => e.id === 'index');
@@ -14,5 +15,6 @@ const title = home?.data.title ?? 'Wesley Ray';
description={home?.data.description} description={home?.data.description}
headings={rendered?.headings ?? []} headings={rendered?.headings ?? []}
> >
<ReadingMap />
{Content && <Content />} {Content && <Content />}
</Base> </Base>
+143
View File
@@ -0,0 +1,143 @@
/*
* Chrome for the reading map.
*
* ⚠️ **Garden-first, and deliberately NOT `prefers-color-scheme`.** garden.c0smere.net is fixed
* dark — `--bg: #0b0d12`, white text, no light mode. Keying this widget off the *viewer's* OS
* preference would hand a light palette to anyone browsing with light-mode set, on a page that
* is black regardless: distant points would fade toward near-white against a near-black ground,
* which reads as smeared dirt rather than as depth. The palette follows the SITE.
*
* Everything inherits `--bg` / `--fg` / `--accent` from the garden when present, with the
* garden's own values as fallbacks so the standalone preview matches what ships.
*
* Three schemes ship as modifier classes on the container. The renderer reads its colours once
* at mount, so switching scheme means re-mounting, not repainting.
*/
.reading-map {
position: relative;
width: 100%;
height: clamp(22rem, 68vh, 44rem);
overflow: hidden;
/* ⚠️ Must be the page background behind the canvas. The canvas is transparent, so distant
points fade toward THIS colour to give depth — set it wrong and the far side of the cloud
fades toward something the reader cannot see. */
--map-fog: var(--bg, #0b0d12);
/* The ground. Low chroma and only a little above the background: it is texture, not subject,
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
--map-nebula: #46536b;
/* --- the chosen scheme: `frost` ---------------------------------------------------------
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
(ΔE 31.9 normal, 25.5 protan) that their distinction does not rest on dots-versus-line. */
--map-signal: #e8eef6;
--map-trace: var(--link-bright, #46c421);
--map-ink: var(--fg, #ffffff);
--map-ink-soft: var(--muted, hsla(0, 0%, 100%, 0.64));
--map-surface: rgba(16, 19, 27, 0.92);
--map-border: var(--hair, hsla(0, 0%, 100%, 0.14));
}
/* --- scheme: brand -------------------------------------------------------------------------
The garden's own green doing both jobs. Signal and trace sit 9.2 ΔE apart, which would be
too close for two chart series told apart by colour alone — here they never are: one is a
field of dots, the other a continuous line. Form carries the distinction, colour carries the
identity. */
.reading-map--brand {
--map-signal: var(--link-bright, #46c421);
--map-trace: var(--link, #2fa50f);
}
/* --- scheme: ember --------------------------------------------------------------------------
Warm subject, cool ground, brand green as the thread. The most "sky at night" of the three.
⚠️ Amber↔green is ΔE 4.2 under protanopia — the two are nearly identical to a protanope, so
this scheme leans entirely on dots-versus-line. Acceptable because nothing here is encoded by
colour: no reader ever has to answer a question by telling these two hues apart. */
.reading-map--ember {
--map-signal: #f59e0b;
--map-trace: var(--link-bright, #46c421);
}
/* --- scheme: frost --------------------------------------------------------------------------
The shipping default, spelled out above on `.reading-map`. Kept as a modifier so the preview
can name it alongside the others and so a page can ask for it explicitly. */
.reading-map--frost {
--map-signal: #e8eef6;
--map-trace: var(--link-bright, #46c421);
}
/* Fails closed: the markup ships hidden and only a successful mount reveals it, so a missing
payload degrades to a plain page rather than an empty bordered box. */
.reading-map:not([data-ready]) { display: none; }
.reading-map canvas { display: block; width: 100%; height: 100%; }
.reading-map__date {
position: absolute;
left: 0.25rem;
bottom: 0.5rem;
font-family: var(--mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--map-ink-soft);
pointer-events: none;
}
.reading-map__tip {
position: absolute;
top: 0;
left: 0;
margin: 0;
max-width: min(28rem, 82%);
padding: 0.85rem 0.95rem;
background: var(--map-surface);
border: 1px solid var(--map-border);
border-radius: 0.25rem;
backdrop-filter: blur(8px);
color: var(--map-ink);
pointer-events: none;
z-index: 2;
}
/* The passage is the one place on this page that is prose rather than instrumentation, and the
garden already has a face for prose. */
.reading-map__tip blockquote {
margin: 0 0 0.6rem;
font-family: var(--serif, Georgia, serif);
font-size: 0.9rem;
line-height: 1.5;
/* 800-character highlights exist, and a tooltip is not where anyone should read one. The
Quotes pages already carry every passage in full. */
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reading-map__tip figcaption {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.6rem;
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.72rem;
line-height: 1.5;
color: var(--map-ink-soft);
border-top: 1px solid var(--map-border);
padding-top: 0.5rem;
}
.reading-map__tip figcaption strong { color: var(--map-ink); font-weight: 700; }
.reading-map__voice { color: var(--map-signal); }
.reading-map__more { opacity: 0.7; }
@media (prefers-reduced-motion: reduce) {
/* The loop already draws the trace complete rather than animating it, so the date readout
would sit on a value that never changes. Retire it. */
.reading-map__date { display: none; }
}