feat(desktop): memory-graph share dialog + core/zoom & light-mode polish

- Rework share/import into one Dialog (matches rename/create): a single code
  field (copy to share, paste + Load to import) with a hover copy button, a
  Reset link beside the upload icon when viewing an imported map, and plainer
  copy.
- Core orb: scales with the world zoom (~1.25× the inner shell), backdrop wash
  behind it; on focus/hover the scene composites above the orb so the active
  tooltip + lit lines are never covered.
- fitViewport floors zoom at the reference (5-ring) extent, so big maps render
  at a constant scale and pan instead of shrinking every node to fit.
- Light mode: flip inter-ring band shading to read as depth (not a mound),
  fade the core ring in from t=0, drop the timeline star glow.
- Timeline: filled play glyph, crisper constellation, date moved into the legend.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 03:22:46 -05:00
parent bd1d354fc3
commit 3e7ed0c53b
8 changed files with 186 additions and 130 deletions

View file

@ -89,9 +89,18 @@ export function fitViewport(w: number, h: number, outer: number = RING_OUTER): V
return { k: 1, x: w / 2, y: h / 2 }
}
const spanX = (outer + 30) * 2
const spanY = spanX * TILT
const k = clamp(Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / spanY, 2.2), ZOOM_MIN, ZOOM_MAX)
// Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in).
const kFor = (r: number): number => {
const spanX = (r + 30) * 2
return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2)
}
// Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map
// renders at that constant scale and overflows — you pan it — instead of
// shrinking every node to fit. Smaller extents (few rings, or the playback
// core) still fit tightly / zoom in.
const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX)
// Bias the center down a touch — the timeline along the top adds visual weight
// up there, so true-center reads as sitting high.
@ -107,7 +116,8 @@ export function radiusForRecency(rec: number, outer: number = RING_OUTER): numbe
// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS,
// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how
// big they read (billboarded), while a full-map view keeps its honest density.
export const fitScale = (w: number, h: number, rings: Ring[]): number => fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k
export const fitScale = (w: number, h: number, rings: Ring[]): number =>
fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k
// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests.
export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {

View file

@ -80,8 +80,7 @@ const NODE_BIRTH = { down: 0.11, up: 0.075 }
// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus
// a few digits/symbols for the "digital rain / decoding" look.
const SCRAMBLE_CHARS =
'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳'
const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳'
// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render
// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and
@ -477,6 +476,7 @@ export function drawScene(scene: Scene): DrawResult {
if (revealed) {
revealedRings.add(n.outerRingIndex)
}
const isFocus = revealed && n.id === focusId
const isNeighbor = revealed && !!focusSet && focusSet.has(n.id)
const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi
@ -758,7 +758,7 @@ export function drawScramble({
rings: Ring[]
vp: Viewport
}): void {
const { darkTheme, primary } = palette
const { bg, darkTheme, primary } = palette
const projX = (wx: number) => wx * vp.k + vp.x
const projY = (wy: number) => wy * vp.k * TILT + vp.y
@ -766,14 +766,33 @@ export function drawScramble({
const coreX = projX(0)
const coreY = projY(0)
// Fill to the innermost ring (the core shell), not the RING_INNER constant —
// the ring sits in lead-in space, so derive the radius from it directly.
const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94
// Scale with the world (like the rings), but ~1.25× bigger than the bare inner
// shell so the core reads prominently at the rested fit.
const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25
if (coreRx <= 0) {
return
}
// Backdrop wash: a background-colour radial dimming the core ellipse, so on a
// busy map the nodes/links crowding through the centre recede behind the orb.
// Self-masking — bg over empty bg is invisible, so a sparse map shows no disc.
const washR = coreRx * 1.15
ctx.save()
ctx.translate(coreX, coreY)
ctx.scale(1, TILT)
const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR)
// Near-opaque across the core (busy graph effectively vanishes behind the orb)
// with a soft falloff only at the rim so there's no hard disc edge.
wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93))
wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88))
wash.addColorStop(1, rgba(bg, 0))
ctx.fillStyle = wash
ctx.beginPath()
ctx.arc(0, 0, washR, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
// Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the
// glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more,
// smaller glyphs rather than a few giant ones — and stays legible when tiny.

View file

@ -1,11 +1,17 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { Upload } from '@/lib/icons'
interface ShareControlsProps {
// True when the shown map was loaded from a pasted code (not the live scan).
@ -17,20 +23,20 @@ interface ShareControlsProps {
shareCode?: string
}
const SECTION_LABEL = 'text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/55'
// WoW-talent-loadout style sharing: one icon button opens a popover with the
// current map's code (copy/export) and a paste box (import) — drop a string,
// see the build. Lives bottom-right of the map, mirroring the legend.
// Share / import a map as a single code. The textarea shows the current map's
// code (copy it to share); edit/replace it and hit Load to view someone else's.
// One field, one button — a standard Dialog matching rename/create.
export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState('')
const [value, setValue] = useState('')
const [error, setError] = useState<null | string>(null)
const apply = () => {
const code = draft.trim()
const own = (shareCode ?? '').trim()
const code = value.trim()
const canLoad = code !== '' && code !== own
const load = () => {
if (!code) {
setError(t.starmap.importEmpty)
@ -42,97 +48,85 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod
if (err === null) {
setOpen(false)
setDraft('')
}
}
return (
<Popover
onOpenChange={next => {
setOpen(next)
if (!next) {
setError(null)
}
}}
open={open}
>
<PopoverTrigger asChild>
<div className="flex items-center gap-1">
{imported && (
<Button
aria-label={t.starmap.shareTitle}
className="size-7 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground data-[state=open]:bg-(--ui-row-hover-background) data-[state=open]:text-foreground"
size="icon"
title={t.starmap.shareTitle}
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => onResetMap?.()}
size="xs"
variant="text"
>
<Codicon name="send" size="0.8rem" />
{t.starmap.resetToMine}
</Button>
</PopoverTrigger>
)}
<PopoverContent align="end" className="w-72 p-0" side="top" sideOffset={8}>
<div className="space-y-2 px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<span className={SECTION_LABEL}>{t.starmap.share}</span>
{imported && (
<button
className="text-[0.62rem] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onClick={() => {
onResetMap?.()
setOpen(false)
}}
type="button"
>
{t.starmap.resetToMine}
</button>
<Dialog
onOpenChange={next => {
setOpen(next)
setError(null)
if (next) {
setValue(shareCode ?? '')
}
}}
open={open}
>
<DialogTrigger asChild>
<Button
aria-label={t.starmap.shareTitle}
className="text-muted-foreground hover:text-foreground"
size="icon"
title={t.starmap.shareTitle}
variant="ghost"
>
<Upload className="size-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t.starmap.shareTitle}</DialogTitle>
<DialogDescription>{t.starmap.shareHint}</DialogDescription>
</DialogHeader>
{/* One code field: pre-filled with this map's code (copy to share); edit
or paste another and Load. Copy button floats in on hover, like a
thread code block. */}
<div className="group/code relative">
<textarea
aria-label={t.starmap.shareTitle}
className="h-24 w-full resize-none rounded-md bg-foreground/5 p-2.5 pr-9 font-mono text-xs leading-relaxed break-all text-muted-foreground/90 outline-none transition placeholder:text-muted-foreground/50 focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
onChange={e => {
setValue(e.target.value)
setError(null)
}}
placeholder={t.starmap.sharePlaceholder}
spellCheck={false}
value={value}
/>
{code !== '' && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 h-5 gap-0 rounded-md px-1 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/code:opacity-100 hover:opacity-100"
iconClassName="size-3"
label={t.starmap.copy}
showLabel={false}
text={value}
/>
)}
</div>
<div className="flex items-center gap-1.5">
<div className="flex h-7 min-w-0 flex-1 items-center rounded-md bg-foreground/5 px-2">
<span className="truncate font-mono text-[0.62rem] text-muted-foreground/90">{shareCode || '—'}</span>
</div>
<CopyButton
appearance="button"
buttonSize="icon"
className="size-7 shrink-0 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
disabled={!shareCode}
label={t.starmap.copy}
showLabel={false}
text={shareCode ?? ''}
/>
</div>
</div>
{error && <p className="text-[0.7rem] text-destructive">{error}</p>}
<div className="h-px bg-(--ui-stroke-secondary)" />
<div className="space-y-2 px-3 py-2.5">
<span className={SECTION_LABEL}>{t.starmap.importMap}</span>
<div className="flex items-center gap-1.5">
<Input
aria-label={t.starmap.sharePlaceholder}
className="h-7 flex-1 font-mono text-[0.62rem]"
onChange={e => {
setDraft(e.target.value)
setError(null)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
}
}}
placeholder={t.starmap.sharePlaceholder}
value={draft}
/>
<Button className="h-7 shrink-0 px-2.5 text-[0.7rem]" disabled={!draft.trim()} onClick={apply} size="sm" type="button">
{t.starmap.importBtn}
</Button>
</div>
{error && <p className="text-[0.62rem] text-destructive">{error}</p>}
</div>
</PopoverContent>
</Popover>
<Button className="w-full" disabled={!canLoad} onClick={load} type="button">
{t.starmap.importBtn}
</Button>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -473,7 +473,9 @@ export function StarMap({
const val = style.getPropertyValue('--theme-primary').trim()
if (val) {
const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
const bgVal =
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
}
}, [size, themeEpoch])
@ -500,7 +502,10 @@ export function StarMap({
// wasted CPU/GPU (WindowServer compositing) when the window isn't even the one
// you're looking at. Freeze the loop while the window is hidden or unfocused;
// a frozen core next to other work is fine, and it resumes instantly on focus.
const isPaused = () => (typeof document !== 'undefined' && document.hidden) || (typeof document.hasFocus === 'function' && !document.hasFocus())
const isPaused = () =>
(typeof document !== 'undefined' && document.hidden) ||
(typeof document.hasFocus === 'function' && !document.hasFocus())
let paused = isPaused()
const schedule = () => {
@ -586,12 +591,22 @@ export function StarMap({
dirtyRef.current = animating
}
// Composite: live scramble underneath, cached static scene on top.
// Composite order flips on focus/hover. Idle: scene first, sphere on top —
// its backdrop wash dims the busy centre. Focused/hovered: sphere first,
// scene on top — so the active node's tooltip + lit lines lift ABOVE the
// sphere instead of being covered by it.
const focused = selectedIdRef.current ?? hoverRef.current
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(staticCanvas, 0, 0)
if (focused) {
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(staticCanvas, 0, 0)
} else {
ctx.drawImage(staticCanvas, 0, 0)
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
}
}
const frame = (ts: number) => {
@ -745,7 +760,11 @@ export function StarMap({
const resetView = () => {
setPlaying(false)
viewportRef.current = fitViewport(sizeRef.current.w, sizeRef.current.h, ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER)
viewportRef.current = fitViewport(
sizeRef.current.w,
sizeRef.current.h,
ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER
)
selectedRingRef.current = null
invalidate()
setSelectedId(null)
@ -762,7 +781,15 @@ export function StarMap({
// Nodes aren't draggable (static map) — remember which was pressed so a click
// (press without movement) can select it; any drag just pans.
const nodeId = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
dragRef.current = { id: nodeId, mode: 'pan', moved: false, ring: ringHit, sx: e.clientX, sy: e.clientY, vp: viewportRef.current }
dragRef.current = {
id: nodeId,
mode: 'pan',
moved: false,
ring: ringHit,
sx: e.clientX,
sy: e.clientY,
vp: viewportRef.current
}
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
@ -882,7 +909,15 @@ export function StarMap({
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
scrubber receives pointer events instead of dragging the window. */}
<div className="pointer-events-none absolute inset-x-0 top-6 z-20 flex justify-center px-12">
<Timeline axis={timeAxis} memoryColor={memoryColor} onScrub={onScrub} onTogglePlay={onTogglePlay} playing={playing} revealStore={revealStore} ringStops={ringStops} />
<Timeline
axis={timeAxis}
memoryColor={memoryColor}
onScrub={onScrub}
onTogglePlay={onTogglePlay}
playing={playing}
revealStore={revealStore}
ringStops={ringStops}
/>
</div>
{/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */}

View file

@ -1,7 +1,6 @@
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'
@ -113,10 +112,6 @@ 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])
@ -254,7 +249,6 @@ 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: glow ? `0 0 ${star.size + 1}px ${color}` : 'none',
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
@ -268,24 +262,25 @@ export const Timeline = memo(function Timeline({
</div>
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
{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)] ${glow ? 'shadow-[0_0_4px_var(--theme-primary)]' : ''} ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
<div aria-hidden className="absolute inset-0">
{ringStops.map((stop, i) => (
<div
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
</div>
{/* Playhead — a thin white sweep line. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-foreground"
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
/>
</div>

View file

@ -768,6 +768,7 @@ export const en: Translations = {
emptyTitle: 'Nothing learned yet',
emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.',
share: 'Share map',
shareHint: 'Copy the code to share this map, or paste one to load. It only includes the layout, not your memory or skill text.',
shareTitle: 'Import / export map',
sharePlaceholder: 'Paste a map code…',
copy: 'Copy map code',

View file

@ -665,6 +665,7 @@ export interface Translations {
emptyTitle: string
emptyDesc: string
share: string
shareHint: string
shareTitle: string
sharePlaceholder: string
copy: string

View file

@ -952,6 +952,7 @@ export const zh: Translations = {
emptyTitle: '尚无学习内容',
emptyDesc: '当 Hermes 为你的工作构建技能和记忆时,会显示在这里。',
share: '分享图谱',
shareHint: '复制代码以分享此图谱,或粘贴代码以载入。仅包含布局,不含你的记忆或技能内容。',
shareTitle: '导入 / 导出图谱',
sharePlaceholder: '粘贴图谱代码…',
copy: '复制图谱代码',