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:
parent
c0b308e1fe
commit
b6e57e215b
7 changed files with 91 additions and 50 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
32
apps/desktop/src/hooks/use-theme-epoch.ts
Normal file
32
apps/desktop/src/hooks/use-theme-epoch.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue