diff --git a/apps/desktop/src/app/starmap/constants.ts b/apps/desktop/src/app/starmap/constants.ts index 748391a48..02f44ab49 100644 --- a/apps/desktop/src/app/starmap/constants.ts +++ b/apps/desktop/src/app/starmap/constants.ts @@ -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 } } diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts index 8d1be2014..e529b72a5 100644 --- a/apps/desktop/src/app/starmap/render.ts +++ b/apps/desktop/src/app/starmap/render.ts @@ -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 diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index 068c95fc4..7a5e597d5 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -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) 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 ). + // Repaint + repalette when the theme/mode repaints (the shared observer fires + // after applyTheme rewrites the class + inline vars on ). 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 diff --git a/apps/desktop/src/app/starmap/timeline.tsx b/apps/desktop/src/app/starmap/timeline.tsx index 405eb79c9..8cb62e470 100644 --- a/apps/desktop/src/app/starmap/timeline.tsx +++ b/apps/desktop/src/app/starmap/timeline.tsx @@ -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(null) const draggingRef = useRef(false) const markerRefs = useRef([]) + // 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) => (
{ if (el) { diff --git a/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts b/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts index 178a0c0fd..f136439e3 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts +++ b/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts @@ -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 (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 } diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx index b29434efe..268068473 100644 --- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx +++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx @@ -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() } }, []) diff --git a/apps/desktop/src/hooks/use-theme-epoch.ts b/apps/desktop/src/hooks/use-theme-epoch.ts new file mode 100644 index 000000000..710144ef1 --- /dev/null +++ b/apps/desktop/src/hooks/use-theme-epoch.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +// Theme repaints (themes/context.tsx) toggle `.dark` + rewrite inline custom +// props/data-hermes-* on . 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 +}