refactor(desktop): share theme-repaint observer; memory-graph depth polish

Extract the copy-pasted "re-resolve on theme repaint" MutationObserver into a
shared hooks/use-theme-epoch (useThemeEpoch + onThemeRepaint) and consume it
from the star map, image-gen placeholder, and useIsDark instead of each hand-
rolling its own root observer. Keeps the post-paint read the canvas probes need
(useTheme() would read stale CSS — child effects run before applyTheme).

Also: light-mode band depth (inner wash), travelling-glow core scramble, and
dark-only timeline bloom.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 02:06:29 -05:00
parent c0b308e1fe
commit b6e57e215b
7 changed files with 91 additions and 50 deletions

View file

@ -58,5 +58,5 @@ export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = {
export const RING_PARAMS: Record<'dark' | 'light', RingParams> = {
dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.03, sheen: 0.12 },
light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.04, sheen: 0.1 }
light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.028, sheen: 0.1 }
}

View file

@ -335,9 +335,22 @@ export function drawScene(scene: Scene): DrawResult {
ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA)
} else {
const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer)
grad.addColorStop(0, rgba(bandInk, 0))
grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0))
grad.addColorStop(1, rgba(bandInk, bandAlpha))
if (darkTheme) {
// Dark: a light wash on each band's OUTER rim — reads as light catching
// a raised edge → depth.
grad.addColorStop(0, rgba(bandInk, 0))
grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0))
grad.addColorStop(1, rgba(bandInk, bandAlpha))
} else {
// Light: flip it — the (darker) wash sits on the INNER edge and fades
// outward, so each shell reads as recessed toward the core (depth),
// not a raised mound.
grad.addColorStop(0, rgba(bandInk, bandAlpha))
grad.addColorStop(clamp(lightSize, 0.01, 0.99), rgba(bandInk, 0))
grad.addColorStop(1, rgba(bandInk, 0))
}
ctx.fillStyle = grad
}
@ -358,7 +371,10 @@ export function drawScene(scene: Scene): DrawResult {
// out as gracefully as it grew in; the alpha bucket only carries the snappy
// selection emphasis.
const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha
const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1)
// The core ring (i 0) fades in from reveal 0 so the scramble orb starts
// un-enclosed (no outline boxing it in) and the shell appears as it plays.
const coreFade = i === 0 ? clamp(reveal / 0.08, 0, 1) : 1
const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) * coreFade
if (ringAlphaNow < 0.004) {
return
@ -769,6 +785,7 @@ export function drawScramble({
const coreRy = coreRx * TILT
const half = Math.max(3, Math.round(coreRx / cell))
const now = performance.now()
const t = now / 1000 // seconds, for the travelling-glow highlight
ctx.save()
ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace`
@ -805,11 +822,14 @@ export function drawScramble({
// Mostly flat brightness, fading only near the rim (reduced gradient).
const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1)
const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100)
// Fake depth: a stable per-slot value pops a subset of glyphs forward, so
// some characters read as nearer/brighter and drift across in front.
const depth = ((seed >>> 11) % 100) / 100
const pop = depth > 0.92 ? 2.6 : depth > 0.78 ? 1.6 : 1
const a = clamp((darkTheme ? 0.25 : 0.33) * edge * flick * rowDim * pop, 0, 0.85)
// Travelling glow: two crossing sine waves (drifting in time) light a
// lattice of bright spots that ripple ACROSS the orb — so the highlight
// moves and twinkles instead of being a fixed random set. A per-glyph
// phase keeps neighbours from pulsing in lockstep.
const phase = (seed & 7) * 0.35
const glow = Math.sin(nx * 4.5 + t * 1.3 + phase) * Math.sin(ny * 4.5 - t * 0.9 + phase)
const pop = 1 + clamp((glow - 0.25) / 0.75, 0, 1) * 2.6
const a = clamp((darkTheme ? 0.22 : 0.3) * edge * flick * rowDim * pop, 0, 0.9)
if (a < 0.02) {
continue

View file

@ -2,6 +2,7 @@ import { type Simulation } from 'd3-force'
import { atom, type WritableAtom } from 'nanostores'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
import type { StarmapGraph } from '@/types/hermes'
@ -153,8 +154,9 @@ export function StarMap({
const [selectedId, setSelectedId] = useState<null | string>(null)
const [size, setSize] = useState({ h: 0, w: 0 })
// Bumped on theme change so the legend's memory swatch recomputes its color.
const [themeVersion, setThemeVersion] = useState(0)
// Increments on every theme repaint (shared hook) so the legend swatch and the
// canvas palette re-resolve against the freshly-painted CSS custom properties.
const themeEpoch = useThemeEpoch()
// Memory's swatch color — the same complementary-of-primary the canvas uses,
// so the legend matches the rendered diamonds exactly.
const [memoryColor, setMemoryColor] = useState('var(--theme-secondary)')
@ -474,23 +476,14 @@ export function StarMap({
const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
}
}, [size, themeVersion])
}, [size, themeEpoch])
// Repaint + repalette when the theme/mode changes (class + inline vars on <html>).
// Repaint + repalette when the theme/mode repaints (the shared observer fires
// after applyTheme rewrites the class + inline vars on <html>).
useEffect(() => {
const mo = new MutationObserver(() => {
themeDirtyRef.current = true
setThemeVersion(v => v + 1)
invalidate()
})
mo.observe(document.documentElement, {
attributeFilter: ['class', 'style', 'data-hermes-mode', 'data-hermes-theme'],
attributes: true
})
return () => mo.disconnect()
}, [invalidate])
themeDirtyRef.current = true
invalidate()
}, [invalidate, themeEpoch])
// Render loop. The core scramble animates continuously, so the loop runs while
// the window is focused — but each frame is cheap (live scramble + a blit of the

View file

@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useTheme } from '@/themes/context'
import type { TimeAxis } from './time-axis'
@ -112,6 +113,10 @@ export const Timeline = memo(function Timeline({
const trackRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const markerRefs = useRef<HTMLDivElement[]>([])
// Star glow halos read as depth on a dark track but smear on a light one, so
// the bloom is dark-mode only.
const { resolvedMode } = useTheme()
const glow = resolvedMode === 'dark'
const stars = useMemo(() => buildStars(axis), [axis])
@ -249,7 +254,7 @@ export const Timeline = memo(function Timeline({
'--o': star.opacity,
animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
backgroundColor: color,
boxShadow: `0 0 ${star.size + 1}px ${color}`,
boxShadow: glow ? `0 0 ${star.size + 1}px ${color}` : 'none',
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
@ -266,7 +271,7 @@ export const Timeline = memo(function Timeline({
{ringStops.map((stop, i) => (
<div
aria-hidden
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] shadow-[0_0_4px_var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${glow ? 'shadow-[0_0_4px_var(--theme-primary)]' : ''} ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {

View file

@ -1,24 +1,18 @@
import { useEffect, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
const isDarkNow = () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
// Tracks the app's dark/light mode off the `dark` class on <html> (set by
// themes/context.tsx). Embeds that theme their own content (tweets) read this.
// Rides the shared theme-repaint observer; setState bails on an unchanged
// boolean, so style-only repaints don't re-render.
export function useIsDark(): boolean {
const [dark, setDark] = useState(
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
const epoch = useThemeEpoch()
const [dark, setDark] = useState(isDarkNow)
useEffect(() => {
if (typeof document === 'undefined') {
return
}
const root = document.documentElement
const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))
observer.observe(root, { attributeFilter: ['class'], attributes: true })
return () => observer.disconnect()
}, [])
useEffect(() => setDark(isDarkNow()), [epoch])
return dark
}

View file

@ -1,6 +1,7 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { onThemeRepaint } from '@/hooks/use-theme-epoch'
type Rgb = { r: number; g: number; b: number }
@ -278,14 +279,10 @@ export const DiffusionCanvas: FC = () => {
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
// rewrites inline custom props on the root) instead of per animation frame.
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-hermes-mode']
})
const unsubscribe = onThemeRepaint(sync)
return () => {
observer.disconnect()
unsubscribe()
probe.remove()
}
}, [])

View file

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
// Theme repaints (themes/context.tsx) toggle `.dark` + rewrite inline custom
// props/data-hermes-* on <html>. Canvas/probe consumers that rasterize the
// *computed* color-mix()/oklch tokens must re-resolve AFTER the paint — useTheme()
// can't, since a child's effect runs before the provider's applyTheme. A
// MutationObserver fires post-mutation, so the next getComputedStyle is fresh.
// One observer, fanned out to every listener.
const ATTRS = ['class', 'style', 'data-hermes-mode', 'data-hermes-theme']
const listeners = new Set<() => void>()
let observer: MutationObserver | null = null
/** Subscribe to theme repaints imperatively (ref/canvas, no re-render). */
export function onThemeRepaint(fn: () => void): () => void {
if (!observer && typeof document !== 'undefined') {
observer = new MutationObserver(() => listeners.forEach(l => l()))
observer.observe(document.documentElement, { attributeFilter: ATTRS, attributes: true })
}
listeners.add(fn)
return () => void listeners.delete(fn)
}
/** A counter that ticks on every theme repaint — depend on it to re-resolve colors. */
export function useThemeEpoch(): number {
const [epoch, setEpoch] = useState(0)
useEffect(() => onThemeRepaint(() => setEpoch(e => e + 1)), [])
return epoch
}