feat(desktop): Memory Graph — playable radial timeline of memories + skills

A top-down Memory Graph panel: memories and skills on a radial time axis
(core = oldest, outer rings = newer) with a playable / scrubbable timeline
that builds the map up over time.

- Reveal lives off the React tree (a ref drives the canvas, a nanostore atom
  drives the timeline + legend), so a play-through or scrub never re-renders
  the panel; paint is coalesced to one rAF and playback is abortable, so even
  frantic scrubbing stays responsive.
- Adaptive dated rings: one equal-width ring per POPULATED calendar bucket,
  a "nice-tick" count scaled to the span. Constant (orthographic) core/band
  scale — more data grows the disk outward (more rings), never thinner.
- A bucket's nodes fill the band inside their ring and ignite staggered by
  real timestamp across it (no end-dump), with an EVE-style warp-in; the
  camera steps out band-by-band as rings are reached.
- ASCII "computing" core, theme-aware palette with a distinct memory hue,
  shared trackpad-gesture primitives.
- Shareable WoW-style "loadout" codes on a generic, reusable codec
  (@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url).
- Opens from the statusbar and command palette; i18n across all locales.

Deps: d3-force, fflate (drops unused react-force-graph-2d).
This commit is contained in:
Brooklyn Nicholson 2026-06-30 00:54:21 -05:00
parent 96552c31e3
commit dec44994a5
36 changed files with 4078 additions and 564 deletions

View file

@ -81,8 +81,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"fflate": "^0.8.3",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@ -118,6 +120,7 @@
"@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
"@types/hast": "^3.0.4",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",

1
apps/desktop/scripts/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
share-codes.txt

View file

@ -0,0 +1,171 @@
// Throwaway generator: deterministic fake star-map graphs → real share codes
// (runs the actual encoder, so every string round-trips). Run with `npx tsx`.
import { writeFileSync } from 'node:fs'
import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes'
import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code'
const DAY = 86_400
const END = Math.floor(Date.UTC(2026, 5, 29) / 1000)
// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs.
const rng = (seed: number) => () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
}
const pick = <T>(arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]!
const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const
const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const
const CREATED = [null, 'agent', 'agent', 'user'] as const
const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({
category: pick(CATEGORIES, r()),
createdBy: pick(CREATED, r()),
id,
kind: 'skill',
label,
pinned: r() > 0.85,
state: pick(STATES, r()),
timestamp: ts,
useCount: Math.floor(r() ** 3 * 120)
})
const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({
category: 'memory',
createdBy: 'memory',
id: `memory:${source}:${i}`,
kind: 'memory',
label,
memorySource: source,
pinned: false,
state: 'active',
timestamp: ts,
useCount: 0
})
const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title })
// ── 1. Tiny + quirky ──────────────────────────────────────────────────────────
function tiny(): StarmapGraph {
const r = rng(7)
const nodes: StarmapNode[] = [
skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r),
skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r),
skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r),
memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY),
memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY)
]
const edges: StarmapEdge[] = [
{ source: 'memory:memory:1', target: 'git-blame-zen' },
{ source: 'rubber-duck', target: 'git-blame-zen' }
]
const memory = [
card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY),
card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY)
]
return { clusters: [], edges, memory, nodes, stats: {} }
}
// ── 2. Mid-size, mixed signal ────────────────────────────────────────────────
function mid(): StarmapGraph {
const r = rng(42)
const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking']
const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r))
const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode']
memTitles.forEach((title, i) => {
const ts = END - Math.floor(r() * 120) * DAY
nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts))
})
const edges: StarmapEdge[] = []
for (let i = 0; i < 9; i += 1) {
edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` })
}
const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY))
return { clusters: [], edges, memory, nodes, stats: {} }
}
// ── 3. Dense web, partly undated (ordinal fallback) ──────────────────────────
function web(): StarmapGraph {
const r = rng(1337)
const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) =>
// Half the skills carry no timestamp → exercises the ordinal recency path.
skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r)
)
const edges: StarmapEdge[] = []
for (let i = 0; i < 44; i += 1) {
edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` })
}
return { clusters: [], edges, memory: [], nodes, stats: {} }
}
// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ───────────────
function beast(): StarmapGraph {
const r = rng(2024)
const start = END - 730 * DAY
const span = END - start
const nodes: StarmapNode[] = []
const memory: StarmapMemoryCard[] = []
// Bursts → an interesting waveform instead of a flat smear.
const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span)
for (let i = 0; i < 240; i += 1) {
const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end
nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r))
}
for (let i = 0; i < 150; i += 1) {
const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12)
const source = r() > 0.5 ? 'memory' : 'profile'
nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts))
memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts))
}
const edges: StarmapEdge[] = []
for (let i = 0; i < 380; i += 1) {
const a = Math.floor(r() * 240)
const b = Math.floor(r() * 240)
if (a !== b) {
edges.push({ source: `b${a}`, target: `b${b}` })
}
}
return { clusters: [], edges, memory, nodes, stats: {} }
}
const graphs: [string, StarmapGraph][] = [
['tiny + quirky', tiny()],
['mid · mixed signal', mid()],
['dense web · half undated', web()],
['the beast · ~2 years', beast()]
]
const lines: string[] = []
for (const [name, g] of graphs) {
const code = encodeShareCode(g)
const back = decodeShareCode(code) // round-trip assert — throws if invalid
// v2 is viz-only: nodes + edge topology survive; memory prose is dropped.
const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length
console.log(`${ok ? 'ok ' : 'BAD'} ${name}${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`)
lines.push(`# ${name}${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '')
}
writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n'))

View file

@ -36,6 +36,7 @@ import {
RefreshCw,
Settings,
Settings2,
Starmap,
Sun,
Terminal,
Users,
@ -68,7 +69,8 @@ import {
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE
SKILLS_ROUTE,
STARMAP_ROUTE
} from '../routes'
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
@ -383,7 +385,14 @@ export function CommandPalette() {
run: go(CRON_ROUTE)
},
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) },
{
icon: Starmap,
id: 'nav-starmap',
keywords: ['star map', 'memory', 'memories', 'skills', 'graph', 'learning', 'constellation'],
label: t.starmap.title,
run: go(STARMAP_ROUTE)
}
]
},
...branchGroup,

View file

@ -158,6 +158,7 @@ const AgentsView = lazy(async () => ({ default: (await import('./agents')).Agent
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('./cron')).CronView }))
const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView }))
const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView }))
const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
@ -262,6 +263,7 @@ export function DesktopController() {
openCommandCenterSection,
profilesOpen,
settingsOpen,
starmapOpen,
toggleCommandCenter
} = useOverlayRouting()
@ -1117,9 +1119,7 @@ export function DesktopController() {
// layer) so pane resize handles still paint above it. Terminals own their state
// (incl. a snapshotted cwd) independent of the session, so switching sessions
// never rebuilds or closes them; toggling the pane never rebuilds the shells.
const mainOverlays = (
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
)
const mainOverlays = <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
const overlays = (
<>
@ -1201,6 +1201,12 @@ export function DesktopController() {
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{starmapOpen && (
<Suspense fallback={null}>
<StarmapView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)

View file

@ -8,6 +8,7 @@ export const ARTIFACTS_ROUTE = '/artifacts'
export const CRON_ROUTE = '/cron'
export const PROFILES_ROUTE = '/profiles'
export const AGENTS_ROUTE = '/agents'
export const STARMAP_ROUTE = '/starmap'
export type AppView =
| 'agents'
@ -19,6 +20,7 @@ export type AppView =
| 'profiles'
| 'settings'
| 'skills'
| 'starmap'
export type AppRouteId =
| 'agents'
@ -30,6 +32,7 @@ export type AppRouteId =
| 'profiles'
| 'settings'
| 'skills'
| 'starmap'
export interface AppRoute {
id: AppRouteId
@ -46,7 +49,8 @@ export const APP_ROUTES = [
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'cron', path: CRON_ROUTE, view: 'cron' },
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' },
{ id: 'starmap', path: STARMAP_ROUTE, view: 'starmap' }
] as const satisfies readonly AppRoute[]
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
@ -55,7 +59,14 @@ const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => rout
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings'])
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings',
'starmap'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)

View file

@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
import {
AGENTS_ROUTE,
appViewForPath,
COMMAND_CENTER_ROUTE,
isOverlayView,
NEW_CHAT_ROUTE,
STARMAP_ROUTE
} from '@/app/routes'
const SECTIONS = ['sessions', 'system', 'usage'] as const
@ -14,6 +21,7 @@ export function useOverlayRouting() {
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const agentsOpen = currentView === 'agents'
const starmapOpen = currentView === 'starmap'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const chatOpen = currentView === 'chat'
@ -53,6 +61,7 @@ export function useOverlayRouting() {
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate])
const openStarmap = useCallback(() => navigate(STARMAP_ROUTE), [navigate])
return {
agentsOpen,
@ -64,8 +73,10 @@ export function useOverlayRouting() {
currentView,
openAgents,
openCommandCenterSection,
openStarmap,
profilesOpen,
settingsOpen,
starmapOpen,
toggleCommandCenter
}
}

View file

@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
@ -369,11 +369,7 @@ export function useStatusbarItems({
menuAlign: 'end',
menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0',
menuContent: (
<ContextUsagePanel
currentUsage={currentUsage}
requestGateway={requestGateway}
sessionId={activeSessionId}
/>
<ContextUsagePanel currentUsage={currentUsage} requestGateway={requestGateway} sessionId={activeSessionId} />
),
title: copy.openContextUsage,
variant: 'menu'

View file

@ -0,0 +1,126 @@
import { BLACK, MODE_DEFAULTS } from './constants'
import { clamp } from './geometry'
import type { Palette, Rgb } from './types'
// Theme tokens come through `color-mix()`/oklch, so getComputedStyle returns a
// non-rgb() string. Rasterize through a 1x1 canvas to get real sRGB bytes —
// naive string parsing of oklab()/color(srgb …) silently yields black.
let _probe: CanvasRenderingContext2D | null = null
export function resolveRgb(color: string): Rgb {
if (!_probe) {
const c = document.createElement('canvas')
c.width = 1
c.height = 1
_probe = c.getContext('2d', { willReadFrequently: true })
}
if (!_probe) {
return { b: 184, g: 163, r: 148 }
}
_probe.clearRect(0, 0, 1, 1)
_probe.fillStyle = '#888888'
_probe.fillStyle = color
_probe.fillRect(0, 0, 1, 1)
const d = _probe.getImageData(0, 0, 1, 1).data
return { b: d[2], g: d[1], r: d[0] }
}
export function rgba(c: Rgb, a: number): string {
return `rgba(${c.r},${c.g},${c.b},${a})`
}
export function mixRgb(a: Rgb, b: Rgb, t: number): Rgb {
const p = clamp(t, 0, 1)
return {
b: Math.round(a.b + (b.b - a.b) * p),
g: Math.round(a.g + (b.g - a.g) * p),
r: Math.round(a.r + (b.r - a.r) * p)
}
}
export function darken(c: Rgb, amount: number): Rgb {
return mixRgb(c, BLACK, amount)
}
export function luminance(r: number, g: number, b: number): number {
return (0.2126 * r + 0.7152 * g + 0.114 * b) / 255
}
function rgbToHsl(c: Rgb): [number, number, number] {
const r = c.r / 255
const g = c.g / 255
const b = c.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
let h = 0
let s = 0
if (d) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
}
return [h, s, l]
}
function hslToRgb(h: number, s: number, l: number): Rgb {
const hue = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] =
hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x]
return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) }
}
// Complementary ink: rotate the source hue (the theme primary) and keep it vivid
// so memories read as a distinct color from skills, in any theme.
function complementaryInk(c: Rgb): Rgb {
const [h, s, l] = rgbToHsl(c)
return hslToRgb(h + 165, Math.max(s, 0.5), clamp(l, 0.5, 0.7))
}
// Memory ink: the complementary hue muted toward the overlay background so it
// reads as a distinct-but-quiet color (fake alpha), not a loud full-sat pop.
export function memoryInkFor(primary: Rgb, bg: Rgb): Rgb {
return mixRgb(complementaryInk(primary), bg, 0.45)
}
// Resolve the theme-derived palette once per theme change — the resolveRgb probe
// does a getImageData readback, so this stays out of the per-frame path. Node
// groups borrow restrained tint from the theme; structure stays foreground ink.
export function computePalette(canvas: HTMLCanvasElement): Palette {
const style = getComputedStyle(canvas)
const fg = resolveRgb(style.color)
const darkTheme = luminance(fg.r, fg.g, fg.b) > 0.55
const base: Rgb = darkTheme ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 }
const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color)
const bg = resolveRgb(
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff')
)
return {
// Band tint derives from the theme primary so rings read consistently in
// both modes (foreground ink would go white on dark / black on light).
bandInk: mixRgb(primary, base, darkTheme ? 0.3 : 0),
base,
bg,
c: MODE_DEFAULTS[darkTheme ? 'dark' : 'light'],
chipBg: darkTheme ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.85)',
darkTheme,
inkInv: darkTheme ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)',
memoryInk: memoryInkFor(primary, bg),
primary,
skillInk: mixRgb(primary, base, darkTheme ? 0.12 : 0.18)
}
}

View file

@ -0,0 +1,62 @@
import type { StarmapNode } from '@/types/hermes'
import type { GraphParams, Rgb, RingParams, Shape } from './types'
// ── Disk geometry ────────────────────────────────────────────────────────────
export const RING_INNER = 58
export const RING_OUTER = 340
export const ZOOM_MIN = 0.3
export const ZOOM_MAX = 5
export const FIT_PADDING = 80
export const TILT = 1 // vertical squash → "looking down at a tilted disk"
export const RING_STEPS = 4
export const WHITE: Rgb = { b: 255, g: 255, r: 255 }
export const BLACK: Rgb = { b: 0, g: 0, r: 0 }
// Fixed recency (age) gradient — old content quiet, recent content bright.
export const AGE_GRADIENT = { mid: 0.52, midInk: 0.74, newInk: 0.95, oldInk: 0.42, reach: 1 }
// Node glyph per kind — pure path geometry (the seam a future sprite/instanced
// renderer would bake from).
export const NODE_SHAPE: Record<StarmapNode['kind'], Shape> = { memory: 'diamond', skill: 'circle' }
// Darken the orb body so a bright primary doesn't swallow the sheen (the
// highlight is computed from the original ink, so it still reads).
export const ORB_DARKEN = 0.3
// Sheen forced this high when the orb ink is near-white (a white body needs a
// pure-white core to read as a sphere at all).
export const WHITEISH_SHEEN = 0.95
// Flat wash alpha for a lit (hovered/selected) date's band. The focused ring
// outline derives from this (×2).
export const LIT_BAND_ALPHA = 0.04
export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = {
dark: {
lineAlpha: 0.12,
lineDash: 1.5,
lineDashed: true,
lineWidth: 0.5,
ringAlpha: 0.1,
ringDash: 4,
ringDashed: false,
ringWidth: 1.5
},
light: {
lineAlpha: 0.18,
lineDash: 1.5,
lineDashed: true,
lineWidth: 0.5,
ringAlpha: 0.06,
ringDash: 4,
ringDashed: false,
ringWidth: 2
}
}
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 }
}

View file

@ -0,0 +1,117 @@
import type { StarmapNode } from '@/types/hermes'
import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import type { Shape, Viewport } from './types'
export function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v))
}
// FNV-1a — stable per-id seed for layout angle / starfield.
export function hash(input: string): number {
let h = 2166136261
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
export function nodeRadius(n: StarmapNode): number {
if (n.kind === 'memory') {
return 4.4
}
const base = n.state === 'archived' || n.state === 'stale' ? 2.4 : 3
return base + Math.sqrt(Math.max(0, n.useCount)) * 0.55 + (n.pinned ? 0.8 : 0)
}
// Smoothstep recency → ink alpha along the age gradient.
export function recencyInk(rec: number): number {
const reach = Math.max(0.01, AGE_GRADIENT.reach)
const mid = clamp(AGE_GRADIENT.mid, 0.01, 0.99)
const t = clamp(rec / reach, 0, 1)
if (t <= mid) {
const p = t / mid
return AGE_GRADIENT.oldInk + (AGE_GRADIENT.midInk - AGE_GRADIENT.oldInk) * (p * p * (3 - 2 * p))
}
const p = (t - mid) / (1 - mid)
return AGE_GRADIENT.midInk + (AGE_GRADIENT.newInk - AGE_GRADIENT.midInk) * (p * p * (3 - 2 * p))
}
// Trace a centred geometric shape of radius r into the current path.
export function shapePath(ctx: CanvasRenderingContext2D, shape: Shape, x: number, y: number, r: number): void {
ctx.beginPath()
if (shape === 'square') {
ctx.rect(x - r, y - r, r * 2, r * 2)
return
}
if (shape === 'circle') {
ctx.arc(x, y, r, 0, Math.PI * 2)
return
}
const pts = shape === 'diamond' ? 4 : shape === 'triangle' ? 3 : 6
// Diamond/triangle point up; hexagon is flat-topped.
const rot = shape === 'hexagon' ? Math.PI / 6 : -Math.PI / 2
for (let i = 0; i < pts; i += 1) {
const a = rot + (i / pts) * Math.PI * 2
const px = x + Math.cos(a) * r
const py = y + Math.sin(a) * r
if (i === 0) {
ctx.moveTo(px, py)
} else {
ctx.lineTo(px, py)
}
}
ctx.closePath()
}
// Center the tilted disk in the viewport at a fit zoom. `outer` is the radius to
// fit (defaults to the full disk); the scrubber passes the revealed extent so the
// camera tightens at the core and zooms out as the rings grow.
export function fitViewport(w: number, h: number, outer: number = RING_OUTER): Viewport {
if (w <= 0 || h <= 0) {
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)
// Bias the center down a touch — the timeline along the top adds visual weight
// up there, so true-center reads as sitting high.
return { k, x: w / 2, y: h / 2 + h * 0.05 }
}
// Target radius for a node at recency `rec` (oldest at the core), scaled to a
// disk of the given outer radius.
export function radiusForRecency(rec: number, outer: number = RING_OUTER): number {
return RING_INNER + rec * (outer - RING_INNER)
}
// 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 {
const dx = bx - ax
const dy = by - ay
const len = dx * dx + dy * dy
const t = len ? clamp(((px - ax) * dx + (py - ay) * dy) / len, 0, 1) : 0
const cx = ax + dx * t
const cy = ay + dy * t
return (px - cx) ** 2 + (py - cy) ** 2
}

View file

@ -0,0 +1,53 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { useI18n } from '@/i18n'
import { $starmapError, $starmapGraph, $starmapLoading, loadStarmapGraph } from '@/store/starmap'
import type { StarmapGraph } from '@/types/hermes'
import { Panel, PanelEmpty } from '../overlays/panel'
import { StarMap } from './star-map'
// Star map overlay: a top-down map of what Hermes has learned for a profile,
// over a radial time axis. Data is fetched on demand into the $starmap* atoms;
// the map itself lives in ./star-map. The chrome is owned by the map itself
// (timeline scrubber + legend float over the canvas), so there's no panel
// header here.
export function StarmapView({ onClose }: { onClose: () => void }) {
const { t } = useI18n()
const graph = useStore($starmapGraph)
const loading = useStore($starmapLoading)
const error = useStore($starmapError)
// A pasted share code populates the map with someone else's (or an exported)
// graph, overriding the live profile scan. Cleared by "back to my map" and
// whenever a fresh profile graph loads in.
const [imported, setImported] = useState<StarmapGraph | null>(null)
useEffect(() => {
void loadStarmapGraph()
}, [])
// Drop a stale import when the underlying profile graph changes out from under it.
useEffect(() => {
setImported(null)
}, [graph])
const shown = imported ?? graph
return (
<Panel closeLabel={t.starmap.close} onClose={onClose}>
{error ? (
<PanelEmpty description={error} icon="warning" title={t.starmap.loadFailed} />
) : !shown && loading ? (
<PageLoader aria-label={t.starmap.loading} className="min-h-0 flex-1" />
) : shown && shown.nodes.length === 0 && !imported ? (
<PanelEmpty description={t.starmap.emptyDesc} icon="lightbulb" title={t.starmap.emptyTitle} />
) : shown ? (
<StarMap graph={shown} imported={imported !== null} onImport={setImported} onResetMap={() => setImported(null)} />
) : null}
</Panel>
)
}

View file

@ -0,0 +1,721 @@
import { darken, luminance, mixRgb, rgba } from './color'
import {
LIT_BAND_ALPHA,
NODE_SHAPE,
ORB_DARKEN,
RING_INNER,
RING_PARAMS,
TILT,
WHITE,
WHITEISH_SHEEN
} from './constants'
import { clamp, nodeRadius, recencyInk, shapePath } from './geometry'
import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text'
import type {
FadeBuckets,
MemoryCard,
Palette,
Rect,
Rgb,
Ring,
RingLabelRect,
SimLink,
SimNode,
Viewport
} from './types'
export interface Scene {
adjacency: Map<string, Set<string>>
byId: Map<string, SimNode>
ctx: CanvasRenderingContext2D
dpr: number
fades: FadeBuckets
focusId: null | string
hoverId: null | string
hoverLink: null | string
hoverRing: null | number
links: SimLink[]
memById: Map<string, MemoryCard>
nodes: SimNode[]
palette: Palette
// Time scrubber: only paint nodes/links whose recency has been reached. 1 =
// everything (the default, idle state); lower values "build up" the map.
reveal: number
rings: Ring[]
selectedRing: null | number
size: { h: number; w: number }
// Scrub jumps: snap every ease to its target this frame (no birth/fade replay).
snapMotion?: boolean
vp: Viewport
}
export interface DrawResult {
animating: boolean
ringLabelRects: RingLabelRect[]
}
// Smoothstep — eases the birth animations (position grow-out) in and out.
const ease = (t: number): number => {
const u = t < 0 ? 0 : t > 1 ? 1 : t
return u * u * (3 - 2 * u)
}
// EVE-style warp arrival for node births: the star streaks outward fast, then
// decelerates hard (exponential ease-out) and drops onto its ring — like a ship
// dropping out of warp. WARP_FROM is how deep toward the core it launches from.
const WARP_FROM = 0.32
const warpIn = (t: number): number => {
const u = t < 0 ? 0 : t > 1 ? 1 : t
return u >= 1 ? 1 : 1 - 2 ** (-9 * u)
}
// Layered birth speeds for the scrubber's parallax: rings expand slowly and
// grandly in the background, stars pop in quicker up front — both well below the
// default hover/focus speeds so the build-up reads as a cinematic settle.
const RING_BIRTH = { down: 0.055, up: 0.032 }
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:.=*+<>Ξ╳'
// Fill the current path as a lit sphere: an offset radial gradient from a hot
// core → darkened body → translucent rim, so a flat circle reads with volume.
// `strength` is how white the core is; `bodyDarken` darkens the body (0 for
// active/hover nodes so they pop full bright). Near-white inks skip the darken
// and force a near-full sheen so the white core still reads.
function sphereFill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
ink: Rgb,
strength: number,
bodyDarken: number
): void {
const mx = Math.max(ink.r, ink.g, ink.b)
const mn = Math.min(ink.r, ink.g, ink.b)
const sat = mx ? (mx - mn) / mx : 0
const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat)
const eff = strength + (WHITEISH_SHEEN - strength) * whiteness
const hi = mixRgb(ink, WHITE, 0.7 * eff)
const body = darken(ink, bodyDarken * (1 - whiteness))
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.05, x, y, r * 1.15)
g.addColorStop(0, rgba(hi, 1))
g.addColorStop(0.5, rgba(body, 1))
g.addColorStop(1, rgba(body, 0.85))
ctx.fillStyle = g
ctx.fill()
}
const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
// Paint a full frame of the star map. Pure given its inputs (draws to the
// canvas + advances the fade buckets); returns whether it's still animating and
// the ring-label hit rects for pointer picking.
export function drawScene(scene: Scene): DrawResult {
const {
adjacency,
byId,
ctx,
dpr,
fades,
focusId,
hoverId,
hoverLink,
hoverRing,
links,
memById,
nodes,
palette,
reveal,
rings,
selectedRing,
size,
snapMotion = false,
vp
} = scene
// Small epsilon so a node exactly at the playhead counts as revealed.
const seen = (rec: number) => rec <= reveal + 1e-3
// Recency for styling is RELATIVE to the newest revealed node — the current
// "present" — not the bare playhead. So a lone frontier node still reads as
// fresh (bright/full size) even with empty space between it and the scrubber.
// At reveal = 1 the frontier is the newest node, collapsing back to raw recency.
let frontier = 0
for (const fn of nodes) {
if (fn.rec <= reveal + 1e-3 && fn.rec > frontier) {
frontier = fn.rec
}
}
const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1)
const { h, w } = size
const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, primary, skillInk } = palette
const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light']
let animating = false
const ringLabelRects: RingLabelRect[] = []
// Eased opacity per element: snaps up when newly highlighted, eases otherwise.
// `rates` overrides the default in/out lerp speed (the slow births pass their
// own gentler pair so the build-up reads as a graceful settle, not a flash).
const fadeAlpha = (
bucket: Map<string, number>,
key: string,
target: number,
snapUp = false,
rates?: { down: number; up: number }
) => {
const targetAlpha = clamp(target, 0, 1)
const prev = bucket.get(key)
// Scrub: jump straight to the target so a fast drag doesn't replay easing.
if (snapMotion) {
bucket.set(key, targetAlpha)
return targetAlpha
}
if (prev == null || (snapUp && targetAlpha > prev)) {
bucket.set(key, targetAlpha)
return targetAlpha
}
const up = rates?.up ?? 0.22
const down = rates?.down ?? 0.32
const rate = targetAlpha > prev ? up : down
const next = prev + (targetAlpha - prev) * rate
if (Math.abs(next - targetAlpha) < 0.01) {
bucket.set(key, targetAlpha)
return targetAlpha
}
animating = true
bucket.set(key, next)
return next
}
const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})`
const projX = (wx: number) => wx * vp.k + vp.x
const projY = (wy: number) => wy * vp.k * TILT + vp.y
// Two composable layers: node highlight (selected ?? hovered) in full ink, and
// a selection-only ring/date filter that only shifts alpha.
const focusSet = focusId ? (adjacency.get(focusId) ?? new Set<string>()) : null
const ringIdx = selectedRing
const ring = ringIdx != null ? (rings[ringIdx] ?? null) : null
// A selected ring owns the band it caps: previous ring → this ring. Ring 0 is
// visual-only/unlabeled, so the first selectable date naturally owns shell 0→1.
const ringLo = ring && ringIdx != null ? (rings[ringIdx - 1]?.ratio ?? 0) - 1e-3 : 0
const ringHi = ring ? ring.ratio + 1e-3 : 1
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
ctx.globalAlpha = 1
// Tilted world transform for the disk structure.
ctx.setTransform(vp.k * dpr, 0, 0, vp.k * TILT * dpr, vp.x * dpr, vp.y * dpr)
// The "lit" date = hovered (preview) or selected (locked) — drives the band
// flatten only; the ring outline reacts to selection.
const litRingIdx = hoverRing ?? ringIdx
// A ring is "laid" one band AHEAD of the playhead — it appears the moment the
// scrubber enters the band beneath it (its inner neighbor's date), so the date
// gridline that caps a region is always drawn before any node in that region.
// Ring 0 is just the visual core; the first real shell still needs non-zero
// playback progress so replay starts empty instead of showing it pre-laid.
const ringSeen = (i: number) => {
const threshold = rings[i - 1]?.ratio ?? 0
return i === 0 || (threshold <= 0 ? reveal > 1e-3 : reveal + 1e-3 >= threshold)
}
// Per-ring "grow out" progress (advanced once per frame, reused by bands /
// outlines / labels): a revealed ring eases its radius from its inner neighbor
// outward to its resting radius, so it expands into place instead of popping.
const ringAppear = rings.map((rg, i) =>
ease(fadeAlpha(fades.appear, `ring:${i}`, ringSeen(i) ? 1 : 0, false, RING_BIRTH))
)
// Direction-based origin (the sign of the reveal): a ring growing IN expands
// outward from its inner neighbour — never from the dead centre — while a ring
// fading OUT collapses all the way to the core. ringSeen is the direction tell:
// true = revealing/at rest, false = receding.
const ringDrawR = rings.map((rg, i) => {
const startR = ringSeen(i) ? (rings[i - 1]?.r ?? rg.r) : RING_INNER
return startR + (rg.r - startR) * (ringAppear[i] ?? 1)
})
// Opacity envelope that stays near-full through most of the grow/shrink and
// only fades in the final stretch — so the radius TRAVEL is visible (the ring
// shrinks back into place) instead of just dimming out where it stands.
const ringVis = ringAppear.map(a => clamp(a / 0.55, 0, 1))
// Inter-ring bands: a theme-tinted wash sliver at the outer edge; the lit
// date's band flattens to an even wash.
if (bandAlpha > 0 || litRingIdx != null) {
for (let i = 0; i < rings.length - 1; i += 1) {
const lit = litRingIdx != null && i + 1 === litRingIdx
if (!lit && bandAlpha <= 0) {
continue
}
// The band tracks its outer ring's grow-in.
if ((ringAppear[i + 1] ?? 1) <= 0.01) {
continue
}
const inner = ringDrawR[i] ?? 0
const outer = ringDrawR[i + 1] ?? 0
if (lit) {
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))
ctx.fillStyle = grad
}
ctx.beginPath()
ctx.arc(0, 0, outer, 0, Math.PI * 2)
ctx.arc(0, 0, inner, 0, Math.PI * 2, true)
ctx.fill()
}
}
// Ring outline: brightens only on selection — the selected ring + its inner
// neighbor (the two bounding the lit band).
ctx.lineWidth = c.ringWidth / vp.k
ctx.setLineDash(c.ringDashed ? [c.ringDash / vp.k, c.ringDash / vp.k] : [])
rings.forEach((rg, i) => {
const emphasized = ringIdx != null && (i === ringIdx || i === ringIdx - 1)
// Reveal in/out rides the smooth (slow) ringAppear envelope so a ring fades
// 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)
if (ringAlphaNow < 0.004) {
return
}
ctx.strokeStyle = shade(ringAlphaNow)
ctx.beginPath()
ctx.arc(0, 0, ringDrawR[i] ?? rg.r, 0, Math.PI * 2)
ctx.stroke()
})
ctx.setLineDash([])
// Screen space for the core, jump routes, and glyphs (crisp, easy to trim).
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Ring 0 is intentionally empty: computeRecency's lead-in keeps the oldest
// real data out in the first shell. Fill that gap with a tilted ASCII
// scramble — a decoding-glyph field laid on the disk plane (rows squashed by
// TILT, circular falloff) so the empty core reads as "computing", not missing.
// It animates continuously, so the draw loop is kept hot (animating = true).
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
const cell = clamp(coreRx * 0.2, 6, 11)
// Aspect-correct on the tilt: rows are spaced by the full glyph height (square
// cells, no vertical squish), but the field is clipped to the disk's ELLIPSE
// (vertical extent = coreRx * TILT), so it sits on the tilted plane while the
// glyphs themselves stay un-squished. Fewer rows fit vertically — that's it.
const coreRy = coreRx * TILT
const half = Math.max(3, Math.round(coreRx / cell))
const now = performance.now()
ctx.save()
ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let r = -half; r <= half; r += 1) {
// Per-row flow: half the rows drift left, half right, each at its own speed.
// The drift is a continuous pixel scroll (not a per-cell swap), and each
// glyph's identity is tied to its slot index — so a character visibly slides
// across instead of the whole row flickering in place. Combined with the
// TILT squash + opposite directions, the field reads as a turning surface.
const rowSeed = (r * 19349663) >>> 0 || 1
const dir = rowSeed & 1 ? 1 : -1
const speed = 8 + (rowSeed % 16) // px/sec
const scroll = (now / 1000) * speed * dir
const ny = (r * cell) / coreRy
// Latitude dimming: rows away from the equator fade, selling the sphere read.
const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny))
const kMin = Math.floor((-coreRx - scroll) / cell) - 1
const kMax = Math.ceil((coreRx - scroll) / cell) + 1
for (let k = kMin; k <= kMax; k += 1) {
const sx = k * cell + scroll // screen-space x relative to the core center
const nx = sx / coreRx
const d2 = nx * nx + ny * ny
if (d2 > 1) {
continue
}
const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0
const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0'
// 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)
if (a < 0.02) {
continue
}
ctx.fillStyle = rgba(primary, a)
ctx.fillText(ch, coreX + sx, coreY + r * cell)
}
}
ctx.restore()
ctx.globalAlpha = 1
animating = true
// Jump routes — a focused node's links stop at its selection ring.
const focusNode = focusId ? (byId.get(focusId) ?? null) : null
const focusRingR = focusNode ? (nodeRadius(focusNode) + focusNode.rec) * vp.k + 4 : 0
for (const link of links) {
const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source))
const t = typeof link.target === 'object' ? link.target : byId.get(String(link.target))
if (!s || !t) {
continue
}
// A jump route only exists once both of its endpoints have ignited.
const revealed = seen(s.rec) && seen(t.rec)
const lit =
revealed &&
!!focusId &&
(s.id === focusId || t.id === focusId || (!!focusSet && focusSet.has(s.id) && focusSet.has(t.id)))
let x1 = projX(s.x)
let y1 = projY(s.y)
let x2 = projX(t.x)
let y2 = projY(t.y)
if (s.id === focusId) {
const d = Math.hypot(x2 - x1, y2 - y1) || 1
x1 += ((x2 - x1) / d) * focusRingR
y1 += ((y2 - y1) / d) * focusRingR
}
if (t.id === focusId) {
const d = Math.hypot(x1 - x2, y1 - y2) || 1
x2 += ((x1 - x2) / d) * focusRingR
y2 += ((y1 - y2) / d) * focusRingR
}
const key = `${s.id}->${t.id}`
const ambient = recencyInk(erec((s.rec + t.rec) / 2)) * c.lineAlpha
// Hovering a line fades it in a bit (×2, capped — never full white).
const targetAlpha = !revealed
? 0
: lit
? 1
: key === hoverLink
? clamp(ambient * 2, 0, 0.7)
: focusId || ring
? 0.025
: ambient
const linkAlpha = fadeAlpha(fades.links, key, targetAlpha, lit)
if (linkAlpha < 0.004) {
continue
}
ctx.strokeStyle = shade(linkAlpha)
ctx.setLineDash(lit || !c.lineDashed ? [] : [c.lineDash, c.lineDash])
ctx.lineWidth = lit ? 1.5 : c.lineWidth
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
ctx.setLineDash([])
// Nodes: the node layer paints pure ink (focused node + neighbors); the date
// filter is alpha-only, so the two states compose.
for (const n of nodes) {
// The land comes first: a node waits for the ring that CAPS its region (its
// outer date gridline) to grow in before it ignites — so the ring is always
// drawn before any star inside it, not after.
const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5
const revealed = seen(n.rec) && landLaid
const isFocus = revealed && n.id === focusId
const isNeighbor = revealed && !!focusSet && focusSet.has(n.id)
const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi
const nodeHigh = isFocus || isNeighbor
const er = erec(n.rec)
const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66
const r = nodeRadius(n) * vp.k * ageScale
const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er)
const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing)
// Birth fade + warp rise are coupled (slow rates) so a star grows in instead
// of flashing. Focus snaps (no drift).
const rawBorn = fadeAlpha(fades.appear, n.id, revealed ? 1 : 0, nodeHigh || inRing, NODE_BIRTH)
const born = ease(rawBorn)
const vis = alpha * born
if (vis < 0.004) {
continue
}
// Warp-in: streak outward from WARP_FROM·radius and decelerate hard onto the
// ring (origin = disk core), echoing an EVE ship dropping out of warp.
const posScale = WARP_FROM + (1 - WARP_FROM) * warpIn(rawBorn)
const sx = projX(n.x * posScale)
const sy = projY(n.y * posScale)
ctx.globalAlpha = vis
const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk
const shape = NODE_SHAPE[n.kind]
shapePath(ctx, shape, sx, sy, r)
if (shape === 'circle') {
// Highlighted orbs pop full bright; others darken so the sheen reads.
sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN)
} else {
ctx.fillStyle = rgba(nodeInk, 1)
ctx.fill()
}
if (isFocus) {
ctx.globalAlpha = 1
ctx.strokeStyle = rgba(nodeInk, 1)
ctx.lineWidth = 1.4
shapePath(ctx, shape, sx, sy, r + 4)
ctx.stroke()
}
}
ctx.globalAlpha = 1
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Ring date labels (top of each ellipse) — hoverable to focus the ring. Many
// adaptive rings can crowd the top, so labels thin out: skip any that would
// land within LABEL_GAP of the last one drawn (the gridline still shows).
ctx.font = '10px ui-sans-serif, system-ui, sans-serif'
ctx.textAlign = 'center'
const LABEL_GAP = 15
let lastLabelY = Number.POSITIVE_INFINITY
rings.forEach((rg, i) => {
if (!rg.label) {
return
}
const sx = projX(0)
// Track the growing radius so the date rides the ring as it expands out.
const sy = projY(-(ringDrawR[i] ?? rg.r))
if (sy < 8 || sy > h - 8 || lastLabelY - sy < LABEL_GAP) {
return
}
lastLabelY = sy
const tw = ctx.measureText(rg.label).width
const boxW = tw + 6
const isThis = ringIdx === i || hoverRing === i
const faded = (focusId != null || ringIdx != null) && !isThis
// The date rides the same smooth ringAppear envelope, so it recedes as
// gently as it appears; the bucket carries only the snappy focus/selection dim.
const emphasisAlpha = faded ? 0.33 : 1
const labelAlpha = fadeAlpha(fades.labels, String(i), emphasisAlpha, isThis) * (ringVis[i] ?? 1)
if (labelAlpha < 0.01) {
return
}
ctx.globalAlpha = labelAlpha
ctx.fillStyle = rgba(bg, 1)
ctx.fillRect(sx - boxW / 2, sy - 6, boxW, 13)
ctx.fillStyle = shade(isThis ? 1 : 0.2)
ctx.fillText(rg.label, sx, sy + 3)
ctx.globalAlpha = 1
// Hidden labels (mid fade-out / not yet reached) drop out of hit-testing.
ringLabelRects.push({ h: 18, i, w: boxW + 6, x: sx - boxW / 2 - 3, y: sy - 10 })
})
// Tooltip on focus — measured first so its rect joins the avoidance set and
// neighbor labels route around it.
const tipNode = focusId ? byId.get(focusId) : null
const tip = tipNode && seen(tipNode.rec) ? tipNode : null
let tipRect: null | Rect = null
if (tip) {
const PADX = 6
const PADY = 4
const BADGE_H = 14
const ROW_GAP = 3
const LINE_H = 16
const ITEM_GAP = 8
const badgeFont = '9px ui-sans-serif, system-ui, sans-serif'
const monoFont = '9px ui-monospace, SFMono-Regular, Menlo, monospace'
const titleFont = '600 11px ui-sans-serif, system-ui, sans-serif'
const footerFont = '9px ui-sans-serif, system-ui, sans-serif'
const FOOTER_H = 13
// The date (index 0) stays sans; the rest of the tags are monospace.
const badgeFontFor = (i: number) => (i === 0 ? badgeFont : monoFont)
const badges = metaBadges(tip)
const use = countLabel(tip)
const titleText = tip.kind === 'memory' ? memById.get(tip.id)?.body.split('\n')[0]?.trim() || tip.label : tip.label
const badgeW = badges.map((b, i) => {
ctx.font = badgeFontFor(i)
return ctx.measureText(b).width
})
const rowW = badgeW.reduce((a, b) => a + b, 0) + ITEM_GAP * Math.max(0, badges.length - 1)
ctx.font = monoFont
const useW = use ? ctx.measureText(use).width : 0
const metaW = rowW + (use ? ITEM_GAP + useW : 0)
ctx.font = titleFont
const maxTitleW = Math.min(380, w - 16) - PADX * 2
const titleLines = wrapText(ctx, titleText, maxTitleW)
const titleW = Math.max(0, ...titleLines.map(l => ctx.measureText(l).width))
const titleBgW = titleW + PADX * 2
const titleBgH = titleLines.length * LINE_H + PADY * 2
const footerText = nodeFooter(tip)
ctx.font = footerFont
const footerW = footerText ? ctx.measureText(footerText).width : 0
const totalW = Math.max(metaW, footerW, titleBgW)
const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0)
const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4))
const by = clamp(projY(tip.y) - (nodeRadius(tip) * vp.k + 8) - totalH, 4, Math.max(4, h - totalH - 4))
tipRect = { h: totalH, w: totalW, x: bx, y: by }
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
const badgeMidY = by + BADGE_H / 2
// Metadata row, flush at the left edge.
ctx.fillStyle = shade(0.7)
let cx = bx
badges.forEach((label, i) => {
ctx.font = badgeFontFor(i)
ctx.fillText(label, cx, badgeMidY)
cx += badgeW[i] + ITEM_GAP
})
if (use) {
ctx.font = monoFont
ctx.fillStyle = shade(0.5)
ctx.fillText(use, cx, badgeMidY)
}
// Title: inverted (fg/bg flipped) so the focused tooltip pops.
const ty = by + BADGE_H + ROW_GAP
ctx.fillStyle = shade(1)
ctx.fillRect(bx, ty, titleBgW, titleBgH)
ctx.font = titleFont
ctx.fillStyle = inkInv
titleLines.forEach((line, i) => {
ctx.fillText(line, bx + PADX, ty + PADY + LINE_H * i + LINE_H / 2)
})
if (footerText) {
ctx.font = footerFont
ctx.fillStyle = shade(0.45)
ctx.fillText(footerText, bx, ty + titleBgH + ROW_GAP + FOOTER_H / 2)
}
ctx.textBaseline = 'alphabetic'
}
// Neighbor constellation labels — greedy placement that clamps to the overlay
// and dodges placed labels (date labels + tooltip) so nothing overlaps/clips.
ctx.font = '11px ui-sans-serif, system-ui, sans-serif'
ctx.textAlign = 'center'
const LBL_M = 6
const LBL_H = 15
const placed: Rect[] = ringLabelRects.map(r => ({ h: r.h, w: r.w, x: r.x, y: r.y }))
if (tipRect) {
placed.push(tipRect)
}
for (const id of focusSet ?? []) {
if (id === hoverId) {
continue
}
const n = byId.get(id)
if (!n || !seen(n.rec)) {
continue
}
const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32))
const bw = ctx.measureText(label).width + 8
const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M))
const top = projY(n.y) - (nodeRadius(n) * vp.k + 7) - LBL_H + 4
const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M))
const step = LBL_H + 3
let y: null | number = null
// Prefer above the node, then fan outward; skip if nothing stays clear (a
// label on the tooltip reads worse than no label).
for (let k = 0; k <= 7 && y == null; k += 1) {
for (const dy of k === 0 ? [0] : [-k * step, k * step]) {
const cand = { h: LBL_H, w: bw, x, y: clampY(top + dy) }
if (!placed.some(p => rectsOverlap(cand, p))) {
y = cand.y
break
}
}
}
if (y == null) {
continue
}
placed.push({ h: LBL_H, w: bw, x, y })
ctx.fillStyle = chipBg
ctx.fillRect(x, y, bw, LBL_H)
ctx.fillStyle = shade(0.85)
ctx.fillText(label, x + bw / 2, y + 11)
}
return { animating, ringLabelRects }
}

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import type { StarmapGraph } from '@/types/hermes'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
function sampleGraph(): StarmapGraph {
return {
clusters: [],
edges: [
{ source: 'skill-a', target: 'skill-b' },
{ source: 'skill-b', target: 'memory:profile:0' }
],
memory: [
{ body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' },
{ body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' }
],
nodes: [
{ category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 },
{ category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 },
{ category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 }
],
stats: {}
}
}
// Decoded edges compared by node POSITION (ids are synthesized), so topology is
// the invariant, not the literal id strings.
const topology = (g: StarmapGraph): [number, number][] => {
const idx = new Map(g.nodes.map((n, i) => [n.id, i]))
return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!])
}
describe('share-code', () => {
// The viz contract: everything the star map RENDERS survives — kinds, radius
// inputs, time position, edge topology — while text is dropped (it's a loadout,
// not a backup).
it('preserves the visualization', () => {
const g = sampleGraph()
const decoded = decodeShareCode(encodeShareCode(g))
const span = 1_700_000_000 - 1_699_900_000
const tol = Math.ceil(span / 4095) + 1
expect(decoded.nodes).toHaveLength(g.nodes.length)
decoded.nodes.forEach((d, i) => {
const o = g.nodes[i]!
expect(d.kind).toBe(o.kind)
expect(d.label).toBe(o.label)
expect(d.useCount).toBe(o.useCount)
expect(d.state).toBe(o.state)
expect(d.pinned).toBe(o.pinned)
expect(d.category).toBe(o.category)
expect(d.memorySource).toBe(o.memorySource)
expect(d.createdBy).toBe(o.createdBy)
if (o.timestamp == null) {
expect(d.timestamp).toBeNull()
} else {
expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol)
}
})
expect(topology(decoded)).toEqual(topology(g))
})
it('drops memory prose (loadout is viz-only)', () => {
expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0)
})
it('rebuilds clusters from node categories', () => {
const decoded = decodeShareCode(encodeShareCode(sampleGraph()))
expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2)
})
it('produces a short, opaque, prefixed code', () => {
const code = encodeShareCode(sampleGraph())
expect(code.startsWith('HML')).toBe(true)
expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/)
// Strictly smaller than the naive JSON it replaces — the whole point.
expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length)
})
it('stays compact on a large graph (no string bloat)', () => {
const nodes = Array.from({ length: 500 }, (_, i) => ({
category: `cat-${i % 8}`,
createdBy: 'agent' as const,
id: `s${i}`,
kind: 'skill' as const,
label: `A fairly verbose skill label number ${i}`,
pinned: false,
state: 'active',
timestamp: 1_700_000_000 + i * 3600,
useCount: i % 50
}))
const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} }
const code = encodeShareCode(graph)
// Deflate keeps even verbose, repetitive labels far below the naive JSON.
expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5)
})
it('handles an empty graph', () => {
const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} }))
expect(decoded.nodes).toHaveLength(0)
expect(decoded.edges).toHaveLength(0)
})
it('drops edges whose endpoints are missing', () => {
const g = sampleGraph()
g.edges.push({ source: 'skill-a', target: 'does-not-exist' })
expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2)
})
it('rejects garbage with a ShareCodeError', () => {
expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError)
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
})
it('rejects a corrupted (bit-flipped) code', () => {
const code = encodeShareCode(sampleGraph())
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
const i = Math.floor(code.length / 2)
const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1)
expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError)
})
it('tolerates whitespace, including internal wraps', () => {
const code = encodeShareCode(sampleGraph())
const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t`
expect(() => decodeShareCode(wrapped)).not.toThrow()
expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length)
})
})

View file

@ -0,0 +1,186 @@
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
// ── Star-map share code ───────────────────────────────────────────────────────
//
// The body schema for a star map, riding the generic loadout codec (@/lib/loadout
// owns the bitstream, DEFLATE, version+checksum frame, and base64url). We encode
// what the map RENDERS — each node's kind, its time POSITION (12-bit quantized,
// not an absolute epoch), radius inputs (useCount/state/pinned), and an interned
// label + category — plus edges as fixed-width node indices. Memory prose is
// dropped; labels are trimmed. DEFLATE then makes the repetitive label/category
// text almost free. A 60-skill map is a few hundred chars.
const VERSION = 3
const PREFIX = 'HML' // "Hermes Memory Loadout" — namespaces our codes like WoW's leading bytes.
const MAX_LABEL = 64 // trim runaway memory titles so one card can't bloat the code.
const trim = (s: string): string => (s.length > MAX_LABEL ? s.slice(0, MAX_LABEL) : s)
const KINDS = ['skill', 'memory'] as const
const STATES = ['active', 'archived', 'disabled', 'draft'] as const
const MEM_SOURCES = ['none', 'memory', 'profile'] as const
const CREATED_BY = ['none', 'agent', 'user'] as const
const REC_BITS = 12 // time position resolution: 1/4096 of the span — sub-pixel here.
const REC_MAX = (1 << REC_BITS) - 1
const finiteTs = (v?: null | number): null | number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null
function writeNode(w: BitWriter, n: StarmapNode, dict: Dict, minTs: number, span: number): void {
w.uint(idxOf(KINDS, n.kind), 1)
w.varint(dict.id(trim(n.label || '')))
w.varint(dict.id(n.category || ''))
w.varint(Math.max(0, n.useCount | 0))
w.uint(idxOf(STATES, n.state), 2)
w.uint(idxOf(MEM_SOURCES, n.memorySource ?? 'none'), 2)
w.uint(idxOf(CREATED_BY, n.createdBy ?? 'none'), 2)
w.bit(n.pinned)
// Time as a 12-bit POSITION within [minTs, maxTs] — not an absolute epoch.
const ts = finiteTs(n.timestamp)
if (ts === null) {
w.bit(0)
} else {
w.bit(1)
w.uint(span > 0 ? Math.round(((ts - minTs) / span) * REC_MAX) : 0, REC_BITS)
}
}
function readNode(r: BitReader, dict: string[], i: number, minTs: number, span: number): StarmapNode {
const kind = KINDS[r.uint(1)] ?? 'skill'
const label = dict[r.varint()] ?? ''
const category = dict[r.varint()] ?? ''
const useCount = r.varint()
const state = STATES[r.uint(2)] ?? 'active'
const memSrc = MEM_SOURCES[r.uint(2)] ?? 'none'
const createdBy = CREATED_BY[r.uint(2)] ?? 'none'
const pinned = r.bit() === 1
const timestamp = r.bit() === 1 ? minTs + (span > 0 ? Math.round((r.uint(REC_BITS) / REC_MAX) * span) : 0) : null
// Ids are synthesized (they're never displayed); memory ids mirror the scan's
// `memory:<source>:<index>` shape so the rest of the UI is none the wiser.
const isMemory = kind === 'memory'
const source = memSrc === 'none' ? 'memory' : memSrc
return {
category,
createdBy: createdBy === 'none' ? null : createdBy,
id: isMemory ? `memory:${source}:${i}` : `s${i}`,
kind,
label,
memorySource: isMemory ? source : undefined,
pinned,
state,
timestamp,
useCount
}
}
function writeGraph(w: BitWriter, graph: StarmapGraph): void {
const dict = new Dict()
// Intern labels + categories; deflate later squeezes the inevitable repetition.
for (const n of graph.nodes) {
dict.id(trim(n.label || ''))
dict.id(n.category || '')
}
const stamps = graph.nodes.map(n => finiteTs(n.timestamp)).filter((v): v is number => v !== null)
const minTs = stamps.length ? Math.min(...stamps) : 0
const maxTs = stamps.length ? Math.max(...stamps) : 0
const span = maxTs - minTs
w.varint(minTs)
w.varint(maxTs)
w.varint(dict.list.length)
for (const s of dict.list) {
w.str(s)
}
w.varint(graph.nodes.length)
for (const n of graph.nodes) {
writeNode(w, n, dict, minTs, span)
}
// Edges reference nodes by position; drop any whose endpoints aren't both nodes.
const order = new Map(graph.nodes.map((n, i) => [n.id, i]))
const edges = graph.edges.filter(e => order.has(e.source) && order.has(e.target))
const bits = indexBits(graph.nodes.length)
w.varint(edges.length)
for (const e of edges) {
w.uint(order.get(e.source)!, bits)
w.uint(order.get(e.target)!, bits)
}
}
function readGraph(r: BitReader): StarmapGraph {
const minTs = r.varint()
const maxTs = r.varint()
const span = maxTs - minTs
const dictLen = r.varint()
const dict: string[] = []
for (let i = 0; i < dictLen; i += 1) {
dict.push(r.str())
}
const nodeCount = r.varint()
const nodes: StarmapNode[] = []
for (let i = 0; i < nodeCount; i += 1) {
nodes.push(readNode(r, dict, i, minTs, span))
}
const bits = indexBits(nodeCount)
const edgeCount = r.varint()
const edges: StarmapEdge[] = []
for (let i = 0; i < edgeCount; i += 1) {
const src = nodes[r.uint(bits)]
const dst = nodes[r.uint(bits)]
if (src && dst) {
edges.push({ source: src.id, target: dst.id })
}
}
const counts = new Map<string, number>()
for (const n of nodes) {
counts.set(n.category, (counts.get(n.category) ?? 0) + 1)
}
const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count)
// Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map
// apart from a freshly-scanned one.
return { clusters, edges, memory: [], nodes, stats: { imported: true } }
}
export class ShareCodeError extends LoadoutError {}
const codec = createLoadout<StarmapGraph>({
error: ShareCodeError,
noun: 'map code',
prefix: PREFIX,
read: readGraph,
version: VERSION,
write: writeGraph
})
// Serialize a star-map graph to a short, opaque, clipboard-safe loadout string.
export function encodeShareCode(graph: StarmapGraph): string {
return codec.encode(graph)
}
// Parse a loadout string back into a (viz-complete, text-synthesized) graph.
export function decodeShareCode(code: string): StarmapGraph {
return codec.decode(code)
}

View file

@ -0,0 +1,138 @@
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 { useI18n } from '@/i18n'
interface ShareControlsProps {
// True when the shown map was loaded from a pasted code (not the live scan).
imported?: boolean
// Decode + apply a pasted code. Returns an error string to show inline, or null.
onImport?: (code: string) => null | string
onResetMap?: () => void
// The current map serialized as a WoW-style share code (the copy target).
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.
export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState('')
const [error, setError] = useState<null | string>(null)
const apply = () => {
const code = draft.trim()
if (!code) {
setError(t.starmap.importEmpty)
return
}
const err = onImport?.(code) ?? null
setError(err)
if (err === null) {
setOpen(false)
setDraft('')
}
}
return (
<Popover
onOpenChange={next => {
setOpen(next)
if (!next) {
setError(null)
}
}}
open={open}
>
<PopoverTrigger asChild>
<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"
>
<Codicon name="send" size="0.8rem" />
</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>
)}
</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>
<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>
)
}

View file

@ -0,0 +1,267 @@
import { forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, type Simulation } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { RING_STEPS } from './constants'
import { clamp, hash, nodeRadius, radiusForRecency } from './geometry'
import { formatDate } from './text'
import { computeRecency, recForRatio } from './time-axis'
import type { Ring, SimLink, SimNode } from './types'
export interface BuiltSim {
byId: Map<string, SimNode>
links: SimLink[]
nodes: SimNode[]
rings: Ring[]
sim: Simulation<SimNode, SimLink>
}
const DAY = 86_400
// Constant ring SCALE: the core radius and the per-ring band are pinned to the
// canonical 5-ring layout, so the empty core and every band are ALWAYS that
// size on the disk — more data grows the disk OUTWARD (more rings) instead of
// stretching a fixed disk thinner. The camera caps its zoom at the 5-ring
// extent (see fitViewport), so this world size is also a constant screen size.
const RING_CORE = radiusForRecency(recForRatio(0))
const RING_BAND = (radiusForRecency(recForRatio(1)) - RING_CORE) / RING_STEPS
const ringRadius = (i: number): number => RING_CORE + i * RING_BAND
// Place a node INSIDE its ring's band (the annulus the ring caps), biased toward
// mid-band so it reads as "within the ring", only occasionally grazing an edge —
// never sitting on the outline like a bead.
const placeRadius = (i: number, id: string): number => {
const outer = ringRadius(i)
const inner = i > 0 ? ringRadius(i - 1) : RING_CORE - RING_BAND * 0.5
const h = (hash(id) % 1000) / 1000
return outer - (0.15 + 0.7 * h) * (outer - inner)
}
interface Unit {
kind: 'day' | 'month'
step: number
}
// "Nice" calendar intervals, fine → coarse, WITH intermediate rungs (2-day,
// bi-weekly, 2/3/6-month) so the bucketer can land NEAR the target count instead
// of jumping straight from weekly (≈9) to monthly (≈3) and missing the ~5 sweet spot.
const UNITS: Unit[] = [
{ kind: 'day', step: 1 },
{ kind: 'day', step: 2 },
{ kind: 'day', step: 7 },
{ kind: 'day', step: 14 },
{ kind: 'month', step: 1 },
{ kind: 'month', step: 2 },
{ kind: 'month', step: 3 },
{ kind: 'month', step: 6 },
{ kind: 'month', step: 12 }
]
// Floor a timestamp to the start of its calendar bucket — the key nodes group by.
function bucketStart(ts: number, { kind, step }: Unit): number {
if (kind === 'day') {
const period = step * DAY
return Math.floor(ts / period) * period
}
const d = new Date(ts * 1000)
d.setUTCHours(0, 0, 0, 0)
// Floor to a step-month boundary in ABSOLUTE months so steps align across
// years (3-month → Jan/Apr/Jul/Oct, 12-month → Jan).
const absMonth = Math.floor((d.getUTCFullYear() * 12 + d.getUTCMonth()) / step) * step
d.setUTCFullYear(Math.floor(absMonth / 12), absMonth % 12, 1)
return Math.floor(d.getTime() / 1000)
}
const populatedStarts = (stamps: number[], u: Unit): number[] => [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b)
// "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that
// grows ~log2 with the span, then snap to the calendar interval whose POPULATED
// count lands nearest it (ties + overshoot break toward fewer/finer). The floor
// is 5 — fewer than that and the play-through "steps" between rings get big and
// abrupt; ~5+ evenly-paced rings give the smooth Spore-style build-up.
function chooseUnit(stamps: number[], spanDays: number): Unit {
const target = clamp(Math.round(4 + Math.log2(Math.max(1, spanDays / 60))), 5, 12)
let best = UNITS[0]!
let bestScore = Infinity
for (const u of UNITS) {
const count = populatedStarts(stamps, u).length
if (!count) {
continue
}
const score = Math.abs(count - target) + (count > target ? 0.5 : 0)
if (score < bestScore) {
bestScore = score
best = u
}
}
return best
}
function bucketLabel(ts: number, { kind, step }: Unit): string {
if (kind === 'day') {
return formatDate(ts)
}
try {
const d = new Date(ts * 1000)
return step >= 12 ? String(d.getUTCFullYear()) : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' })
} catch {
return formatDate(ts)
}
}
interface Layout {
// bucket/cap ring a node belongs to (the ring it ignites behind)
index: (n: StarmapNode) => number
// reveal coordinate (01) a node ignites at — staggered within its band
rec: (n: StarmapNode) => number
rings: Ring[]
// world radius a node is drawn at (inside its band)
tr: (n: StarmapNode) => number
}
// Even, unlabeled-ish fallback when there's no usable time span (undated graph
// or one instant): keep the legacy continuous mapping so nothing regresses.
function evenLayout(recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({
label: timed && minTs !== null && maxTs !== null ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) : null,
r: ringRadius(i),
ratio: recForRatio(i / RING_STEPS)
}))
const capRing = (rec: number): number => {
for (let i = 0; i < rings.length; i += 1) {
if ((rings[i]?.ratio ?? 1) >= rec - 1e-3) {
return i
}
}
return rings.length - 1
}
return {
index: n => capRing(recById.get(n.id) ?? 0),
rec: n => recById.get(n.id) ?? 0,
rings,
tr: n => radiusForRecency(recById.get(n.id) ?? 0)
}
}
// One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the
// band INSIDE their ring (fanned by angle) and ignite staggered across it.
function buildLayout(graph: StarmapGraph, recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite)
if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) {
return evenLayout(recById, minTs, maxTs, timed)
}
const span = maxTs - minTs
const unit = chooseUnit(stamps, span / DAY)
const starts = populatedStarts(stamps, unit)
if (starts.length < 2) {
return evenLayout(recById, minTs, maxTs, timed)
}
const indexOfStart = new Map(starts.map((s, i) => [s, i]))
// Reveal pacing is per-BUCKET (uniform), matching the equal-width bands: each
// ring is one even step. (Radius is already index-based.) Using raw time here
// decouples a ring's ignite moment from its position — a bursty gap makes a
// ring appear bands ahead of the nodes that belong to it. Labels stay real dates.
const last = Math.max(1, starts.length - 1)
const rings: Ring[] = starts.map((s, i) => ({ label: bucketLabel(s, unit), r: ringRadius(i), ratio: recForRatio(i / last) }))
// A node's bucket is its ring; undated nodes (rare, in an otherwise-timed
// graph) fall to the newest ring so they still appear.
const indexFor = (n: StarmapNode): number => {
const ts = Number(n.timestamp)
return Number.isFinite(ts) ? (indexOfStart.get(bucketStart(ts, unit)) ?? starts.length - 1) : starts.length - 1
}
// Node POSITION fills the band inside its bucket ring (placeRadius); its IGNITE
// time is staggered ACROSS that band, ordered by real timestamp, so a busy
// bucket trickles in over its whole slice instead of every node popping at
// once (the "everything floods in at the end" bug).
const buckets: StarmapNode[][] = starts.map(() => [])
for (const n of graph.nodes) {
buckets[indexFor(n)]!.push(n)
}
const tsOf = (n: StarmapNode): number => (Number.isFinite(Number(n.timestamp)) ? Number(n.timestamp) : Infinity)
const recByNode = new Map<string, number>()
buckets.forEach((bucket, i) => {
bucket.sort((a, b) => (tsOf(a) === tsOf(b) ? a.id.localeCompare(b.id) : tsOf(a) - tsOf(b)))
const hi = rings[i]!.ratio
const lo = i > 0 ? rings[i - 1]!.ratio : 0
const m = bucket.length
// f ∈ (0,1]: first node lands just inside the band, last node ON the ring.
bucket.forEach((n, k) => recByNode.set(n.id, lo + ((k + 1) / m) * (hi - lo)))
})
return {
index: indexFor,
rec: n => recByNode.get(n.id) ?? 0,
rings,
tr: n => placeRadius(indexFor(n), n.id)
}
}
// Build the radial time simulation: a node's distance from the core encodes its
// timestamp bucket (radial force dominates; charge/collide only spread nodes
// around their date ring). Rings are dated, equal-width gridlines.
export function buildSimulation(graph: StarmapGraph, onTick: () => void): BuiltSim {
const { maxTs, minTs, rec: recById, timed } = computeRecency(graph.nodes)
const { index, rec: recOf, rings, tr: trOf } = buildLayout(graph, recById, minTs, maxTs, timed)
const nodes: SimNode[] = graph.nodes.map(n => {
const rec = recOf(n)
const tr = trOf(n)
const angle = ((hash(n.id) % 3600) / 3600) * Math.PI * 2
return { ...n, outerRingIndex: index(n), rec, tr, vx: 0, vy: 0, x: Math.cos(angle) * tr, y: Math.sin(angle) * tr }
})
const byId = new Map(nodes.map(n => [n.id, n]))
const links: SimLink[] = graph.edges
.filter(e => byId.has(e.source) && byId.has(e.target))
.map(e => ({ source: e.source, target: e.target }))
const sim = forceSimulation(nodes)
.alphaDecay(0.05)
.velocityDecay(0.62)
.force('charge', forceManyBody<SimNode>().strength(-12))
.force(
'link',
forceLink<SimNode, SimLink>(links)
.id(n => n.id)
.distance(26)
.strength(0.06)
)
.force(
'collide',
forceCollide<SimNode>()
.radius(n => nodeRadius(n) + 2)
.iterations(2)
)
.force('radial', forceRadial<SimNode>(n => (n as SimNode).tr, 0, 0).strength(0.92))
.on('tick', onTick)
return { byId, links, nodes, rings, sim }
}

View file

@ -0,0 +1,875 @@
import { type Simulation } from 'd3-force'
import { atom, type WritableAtom } from 'nanostores'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
import type { StarmapGraph } from '@/types/hermes'
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import { clamp, distToSegmentSq, fitViewport, nodeRadius } from './geometry'
import { drawScene } from './render'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
import { ShareControls } from './share-controls'
import { buildSimulation } from './simulation'
import { formatDate } from './text'
import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis'
import { Timeline } from './timeline'
import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types'
// How long a full play-through sweep takes (ms), reveal 0 → 1. Longer = the
// build-up breathes; the eased middle no longer rushes past in a blink.
const SWEEP_MS = 15000
// How far to relax the ease toward a flat linear march. The bare smoothstep
// spikes to 1.5× linear speed mid-sweep, which reads as a "snap" through the
// middle; blending it back toward linear flattens that peak (≈1.3× at GENTLE
// = 0.45) so playback glides instead of lurching, while still keeping a soft
// ease-in / ease-out at the very start and end.
const GENTLE = 0.45
// Cinematic timing: cubic smoothstep (gentle ease-in / ease-out) relaxed toward
// linear by GENTLE, so the middle never rushes. Monotonic on [0,1], so the
// numeric inverse below stays valid.
function cineEase(t: number): number {
const u = t < 0 ? 0 : t > 1 ? 1 : t
const smooth = u * u * (3 - 2 * u)
return GENTLE * u + (1 - GENTLE) * smooth
}
// Numeric inverse (monotonic) so a resume maps the current reveal back to clock
// progress without a closed-form solution.
function invCineEase(y: number): number {
let lo = 0
let hi = 1
for (let i = 0; i < 24; i += 1) {
const mid = (lo + hi) / 2
if (cineEase(mid) < y) {
lo = mid
} else {
hi = mid
}
}
return (lo + hi) / 2
}
function revealText(axis: TimeAxis, reveal: number): string {
const date = dateAtReveal(axis, reveal)
return date !== null ? formatDate(date) : `${Math.round(reveal * axis.size)} / ${axis.size}`
}
function RevealLabel({ axis, revealStore }: { axis: TimeAxis; revealStore: WritableAtom<number> }) {
const labelRef = useRef<HTMLSpanElement | null>(null)
const sync = useCallback(
(reveal: number) => {
const el = labelRef.current
if (el) {
el.textContent = revealText(axis, reveal)
}
},
[axis]
)
useEffect(() => revealStore.subscribe(sync), [revealStore, sync])
useEffect(() => {
sync(revealStore.get())
}, [revealStore, sync])
return (
<span className="tabular-nums text-foreground/75" ref={labelRef}>
{revealText(axis, revealStore.get())}
</span>
)
}
// A tilted, top-down star map of what Hermes has learned. Time is RADIAL: oldest
// at the core, newest on the outer rings. This component owns the refs, effects
// and pointer wiring; layout lives in simulation.ts and painting in render.ts.
export function StarMap({
graph,
imported = false,
onImport,
onResetMap
}: {
graph: StarmapGraph
imported?: boolean
onImport?: (graph: StarmapGraph) => void
onResetMap?: () => void
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
const simRef = useRef<null | Simulation<SimNode, SimLink>>(null)
const nodesRef = useRef<SimNode[]>([])
const linksRef = useRef<SimLink[]>([])
const byIdRef = useRef(new Map<string, SimNode>())
const adjacencyRef = useRef(new Map<string, Set<string>>())
const memByIdRef = useRef(new Map<string, MemoryCard>())
const ringsRef = useRef<Ring[]>([])
const ringLabelRectsRef = useRef<RingLabelRect[]>([])
const fadeRef = useRef<FadeBuckets>({
appear: new Map(),
labels: new Map(),
links: new Map(),
nodes: new Map(),
rings: new Map()
})
const doubleTapRef = useRef(createDoubleTapDetector())
const paletteRef = useRef<null | Palette>(null)
const themeDirtyRef = useRef(true)
const invalidateRef = useRef<() => void>(() => {})
const viewportRef = useRef<Viewport>({ k: 1, x: 0, y: 0 })
const hoverRef = useRef<null | string>(null)
const hoveredLinkRef = useRef<null | string>(null)
const hoveredRingRef = useRef<null | number>(null)
const selectedRingRef = useRef<null | number>(null)
const selectedIdRef = useRef<null | string>(null)
const sizeRef = useRef({ h: 0, w: 0 })
const dprRef = useRef(1)
const dirtyRef = useRef(true)
// Scrub = direct manipulation (snap the fades to the pointer); Play = the
// cinematic birth/fade easing. One frame's worth of state, never re-rendered.
const snapMotionRef = useRef(false)
const dragRef = useRef<{
id: null | string
mode: 'none' | 'pan'
moved: boolean
ring: null | number
sx: number
sy: number
vp: Viewport
}>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } })
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)
// 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)')
// Time scrubber: reveal 1 = the whole map (idle default); lower values hide
// not-yet-reached nodes so playing/scrubbing "builds it up". revealRef feeds
// the canvas loop and revealStore feeds the timeline + legend label — so a
// play-through / scrub never re-renders StarMap (the perf win). `playing`
// stays React state since it flips rarely and drives the play effect + button.
const revealStore = useMemo(() => atom(1), [])
const [playing, setPlaying] = useState(false)
// Reveal positions where each dated ring spawns (its inner neighbor's ratio —
// ringSeen reveals one band ahead), surfaced as markers on the timeline.
const [ringStops, setRingStops] = useState<number[]>([])
const revealRef = useRef(1)
// Spore-style zoom: the camera fits the *leading ring's* radius, a step
// function of reveal. It holds steady while a band fills, then eases out to the
// next shell when a new ring is reached — growth in discrete jumps, not a
// constant creep. This ref is the camera's current (eased) fit radius.
const camRadiusRef = useRef(RING_OUTER)
const timeAxis = useMemo(() => buildTimeAxis(graph, 72), [graph])
// The current map as a WoW-style share code, recomputed only when the graph
// changes (encode walks every node/edge/card, so don't redo it per render).
const shareCode = useMemo(() => encodeShareCode(graph), [graph])
// Decode a pasted code and hand the resulting graph up to the StarmapView,
// which swaps it in for the live profile scan. Returns an error string for the
// Timeline to surface inline, or null on success.
const importCode = useCallback(
(code: string): null | string => {
try {
const next = decodeShareCode(code)
onImport?.(next)
return null
} catch (err) {
return err instanceof ShareCodeError ? err.message : 'Could not read that map code.'
}
},
[onImport]
)
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
const invalidate = useCallback(() => invalidateRef.current(), [])
// Single writer for the scrubber position: feeds the canvas (ref), the
// timeline + legend label (store subscribers), and wakes the paint loop —
// no React re-render, so playback/scrubbing stays off the render path.
const setRevealValue = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
revealRef.current = next
revealStore.set(next)
invalidate()
},
[invalidate, revealStore]
)
// Drop every in-flight ease so the next frame snaps to its targets.
const resetFades = useCallback(() => {
for (const bucket of Object.values(fadeRef.current)) {
bucket.clear()
}
}, [])
const memById = useMemo(() => {
const m = new Map<string, MemoryCard>()
graph.memory.forEach((card, i) => m.set(`memory:${card.source}:${i}`, card))
return m
}, [graph.memory])
const adjacency = useMemo(() => {
const m = new Map<string, Set<string>>()
for (const n of graph.nodes) {
m.set(n.id, new Set())
}
for (const e of graph.edges) {
m.get(e.source)?.add(e.target)
m.get(e.target)?.add(e.source)
}
return m
}, [graph.edges, graph.nodes])
// Track the wrapper size.
useEffect(() => {
const el = wrapRef.current
if (!el) {
return
}
const sync = () => setSize({ h: el.clientHeight, w: el.clientWidth })
const ro = new ResizeObserver(sync)
ro.observe(el)
sync()
return () => ro.disconnect()
}, [])
// (Re)build the radial simulation whenever the graph or size changes.
useEffect(() => {
sizeRef.current = size
if (size.w === 0 || size.h === 0) {
return
}
const { byId, links, nodes, rings, sim } = buildSimulation(graph, invalidate)
simRef.current = sim
nodesRef.current = nodes
linksRef.current = links
byIdRef.current = byId
ringsRef.current = rings
// Markers fire when a ring spawns: ringSeen(i) flips at rings[i-1].ratio.
setRingStops(rings.map((rg, i) => (rg.label != null ? (rings[i - 1]?.ratio ?? 0) : -1)).filter(v => v >= 0))
resetFades()
// Fit the actual disk (outermost ring), so a 3-ring map frames like a 12-ring
// one — count changes the disk size, not the framing.
viewportRef.current = fitViewport(size.w, size.h, rings[rings.length - 1]?.r ?? RING_OUTER)
invalidate()
if (selectedIdRef.current && !byId.has(selectedIdRef.current)) {
selectedIdRef.current = null
setSelectedId(null)
}
return () => {
sim.stop()
if (simRef.current === sim) {
simRef.current = null
}
}
}, [graph, invalidate, resetFades, size])
useEffect(() => {
adjacencyRef.current = adjacency
memByIdRef.current = memById
invalidate()
}, [adjacency, invalidate, memById])
// The empty-core ASCII scramble uses the bundled JetBrains Mono face. Canvas
// text doesn't reflow when a webfont loads, so repaint once it's ready.
useEffect(() => {
document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {})
}, [invalidate])
useEffect(() => {
selectedIdRef.current = selectedId
invalidate()
}, [invalidate, selectedId])
// A fresh graph resets the scrubber to "fully built" (the idle default).
useEffect(() => {
camRadiusRef.current = RING_OUTER
snapMotionRef.current = false
setRevealValue(1)
setPlaying(false)
}, [graph, setRevealValue])
// The stepped fit radius for a reveal: fit ONE ring BEYOND the leading shell
// (the first not-yet-passed ring) so the ring currently igniting its nodes
// sits comfortably inside the frame with a band of headroom, instead of jammed
// against the edge. Steps only when reveal crosses a boundary — the Spore step.
const targetRadius = useCallback((rev: number): number => {
const rings = ringsRef.current
if (!rings.length) {
return RING_OUTER
}
const lead = rings.findIndex(rg => rg.ratio > rev + 1e-3)
const i = lead === -1 ? rings.length - 1 : lead
const band = (rings[1]?.r ?? RING_OUTER) - (rings[0]?.r ?? 0)
// Small headroom (a third of a band) so the igniting ring isn't jammed at the
// frame edge during playback, without zooming the resting view out.
return rings[i]!.r + band * 0.35
}, [])
const applyFit = useCallback((radius: number) => {
const { h, w } = sizeRef.current
if (w > 0 && h > 0) {
viewportRef.current = fitViewport(w, h, radius)
}
}, [])
// Snap the camera to a reveal's stepped target (scrubbing / reset — no glide).
const fitForReveal = useCallback(
(rev: number) => {
camRadiusRef.current = targetRadius(rev)
applyFit(camRadiusRef.current)
},
[applyFit, targetRadius]
)
// Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once).
useEffect(() => {
if (!playing) {
return
}
let raf = 0
let start = 0
const step = (now: number) => {
if (!start) {
// Anchor (in clock-space) so a resume continues from the current reveal.
start = now - invCineEase(revealRef.current) * SWEEP_MS
}
const progress = Math.min(1, (now - start) / SWEEP_MS)
const next = cineEase(progress)
// Ease the camera toward the leading ring's radius (a step target): it
// holds while a band fills, then pushes out when the next shell is reached.
const target = targetRadius(next)
camRadiusRef.current += (target - camRadiusRef.current) * 0.1
applyFit(camRadiusRef.current)
setRevealValue(next)
// End once the reveal is complete AND the camera has settled on the final
// shell, so the last push-out finishes instead of cutting off.
if (progress >= 1 && Math.abs(target - camRadiusRef.current) < 0.5) {
camRadiusRef.current = target
applyFit(target)
setPlaying(false)
return
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [applyFit, playing, setRevealValue, targetRadius])
const onTogglePlay = useCallback(() => {
if (playing) {
setPlaying(false)
return
}
// Leaving scrub: play eases (cinematic) rather than holding the snapped view.
snapMotionRef.current = false
// Replay from the start when parked at the end. Snap straight to the empty
// state (no fade-out) before playing in.
if (revealRef.current >= 1) {
resetFades()
fitForReveal(0)
setRevealValue(0)
}
setPlaying(true)
}, [fitForReveal, playing, resetFades, setRevealValue])
const onScrub = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
setPlaying(false)
// Scrub is direct manipulation: snap fades + camera to the pointer so a
// fast drag jumps there instead of replaying the birth-in from a stale spot.
snapMotionRef.current = true
fitForReveal(next)
setRevealValue(next)
},
[fitForReveal, setRevealValue]
)
// Spacebar toggles playback (unless typing, or the play button itself is
// focused — that already handles Space natively, so skip to avoid a double).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code !== 'Space' && e.key !== ' ') {
return
}
const el = document.activeElement
const tag = el?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || (el as HTMLElement | null)?.isContentEditable) {
return
}
e.preventDefault()
onTogglePlay()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onTogglePlay])
// Recompute the legend's memory swatch from the live --theme-primary (matches
// the canvas), re-running on theme change and once the canvas is mounted.
useEffect(() => {
const el = canvasRef.current ?? wrapRef.current
if (!el) {
return
}
const style = getComputedStyle(el)
const val = style.getPropertyValue('--theme-primary').trim()
if (val) {
const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
}
}, [size, themeVersion])
// Repaint + repalette when the theme/mode changes (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])
// Event-driven render loop: no frames while idle. Anything that changes the
// view calls invalidate(); a draw that's still animating reschedules itself.
useEffect(() => {
let raf = 0
// Continuous self-animation (the core scramble) only needs ~30fps; cap it so
// the idle loop isn't a 60fps full-scene redraw. Interaction bypasses the cap.
const ANIM_MS = 1000 / 30
let lastAnimTs = 0
let force = true
// The scramble keeps the loop perpetually "animating", so a fully-built,
// untouched map still repaints 30×/s for as long as the panel is open. That's
// 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())
let paused = isPaused()
const schedule = () => {
if (!paused && !raf) {
raf = requestAnimationFrame(frame)
}
}
const draw = (): boolean => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return false
}
if (themeDirtyRef.current || !paletteRef.current) {
paletteRef.current = computePalette(canvas)
themeDirtyRef.current = false
}
const { animating, ringLabelRects } = drawScene({
adjacency: adjacencyRef.current,
byId: byIdRef.current,
ctx,
dpr: dprRef.current,
fades: fadeRef.current,
focusId: selectedIdRef.current ?? hoverRef.current,
hoverId: hoverRef.current,
hoverLink: hoveredLinkRef.current,
hoverRing: hoveredRingRef.current,
links: linksRef.current,
memById: memByIdRef.current,
nodes: nodesRef.current,
palette: paletteRef.current,
reveal: revealRef.current,
rings: ringsRef.current,
selectedRing: selectedRingRef.current,
size: sizeRef.current,
snapMotion: snapMotionRef.current,
vp: viewportRef.current
})
// One-shot: a scrub snaps this frame; hover/focus afterward eases as usual
// (buckets are already at target, so the next eased frames don't move).
snapMotionRef.current = false
ringLabelRectsRef.current = ringLabelRects
return animating
}
const frame = (ts: number) => {
raf = 0
if (!dirtyRef.current) {
return
}
// Throttle animation-only frames; an interaction (force) always draws now.
if (!force && ts - lastAnimTs < ANIM_MS) {
schedule()
return
}
force = false
lastAnimTs = ts
dirtyRef.current = draw()
if (dirtyRef.current) {
schedule()
}
}
invalidateRef.current = () => {
dirtyRef.current = true
force = true
schedule()
}
// Suspend the loop when the window drops out of view/focus; wake + force a
// fresh frame the moment it returns so the resume is seamless.
const onActivity = () => {
const next = isPaused()
if (next === paused) {
return
}
paused = next
if (paused) {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
} else {
dirtyRef.current = true
force = true
schedule()
}
}
document.addEventListener('visibilitychange', onActivity)
window.addEventListener('blur', onActivity)
window.addEventListener('focus', onActivity)
schedule()
return () => {
cancelAnimationFrame(raf)
document.removeEventListener('visibilitychange', onActivity)
window.removeEventListener('blur', onActivity)
window.removeEventListener('focus', onActivity)
invalidateRef.current = () => {}
}
}, [])
// Size the backing canvas (DPR-aware).
useEffect(() => {
sizeRef.current = size
dprRef.current = Math.min(2, window.devicePixelRatio || 1)
const canvas = canvasRef.current
if (canvas && size.w > 0 && size.h > 0) {
canvas.width = Math.round(size.w * dprRef.current)
canvas.height = Math.round(size.h * dprRef.current)
canvas.style.width = `${size.w}px`
canvas.style.height = `${size.h}px`
}
invalidate()
}, [invalidate, size])
// ── Pointer interactions (invert the tilted projection for hit-testing) ─────
const pickNode = (cssX: number, cssY: number): null | SimNode => {
const vp = viewportRef.current
const wx = (cssX - vp.x) / vp.k
const wy = (cssY - vp.y) / (vp.k * TILT)
let best: null | SimNode = null
let bestD = Infinity
for (const n of nodesRef.current) {
const r = nodeRadius(n) + 6
const d = (n.x - wx) ** 2 + (n.y - wy) ** 2
if (d < r * r && d < bestD) {
bestD = d
best = n
}
}
return best
}
// Nearest link within ~5px of the cursor (screen space), or null.
const pickLink = (cssX: number, cssY: number): null | string => {
const vp = viewportRef.current
let best: null | string = null
let bestD = 25
for (const link of linksRef.current) {
const s = typeof link.source === 'object' ? link.source : byIdRef.current.get(String(link.source))
const t = typeof link.target === 'object' ? link.target : byIdRef.current.get(String(link.target))
if (!s || !t) {
continue
}
const d = distToSegmentSq(
cssX,
cssY,
s.x * vp.k + vp.x,
s.y * vp.k * TILT + vp.y,
t.x * vp.k + vp.x,
t.y * vp.k * TILT + vp.y
)
if (d < bestD) {
bestD = d
best = `${s.id}->${t.id}`
}
}
return best
}
const pickRingLabel = (cssX: number, cssY: number): null | number => {
for (const r of ringLabelRectsRef.current) {
if (cssX >= r.x && cssX <= r.x + r.w && cssY >= r.y && cssY <= r.y + r.h) {
return r.i
}
}
return null
}
const localXY = (e: React.MouseEvent): { x: number; y: number } => {
const rect = canvasRef.current?.getBoundingClientRect()
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) }
}
const resetView = () => {
setPlaying(false)
viewportRef.current = fitViewport(sizeRef.current.w, sizeRef.current.h, ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER)
selectedRingRef.current = null
invalidate()
setSelectedId(null)
}
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button !== 0) {
return
}
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
hoveredRingRef.current = null
// 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 }
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
if (drag.mode === 'none') {
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
const id = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
// Links are the last fallback (only when not over a node/date).
const linkKey = ringHit == null && id == null ? pickLink(x, y) : null
if (id !== hoverRef.current || ringHit !== hoveredRingRef.current || linkKey !== hoveredLinkRef.current) {
hoverRef.current = id
hoveredRingRef.current = ringHit
hoveredLinkRef.current = linkKey
invalidate()
}
return
}
const dx = e.clientX - drag.sx
const dy = e.clientY - drag.sy
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
drag.moved = true
}
if (drag.mode === 'pan') {
// Taking manual control of the camera ends an auto-fit play-through.
if (drag.moved) {
setPlaying(false)
}
viewportRef.current = { ...drag.vp, x: drag.vp.x + dx, y: drag.vp.y + dy }
invalidate()
}
}
const endDrag = () => {
const drag = dragRef.current
// A click (press without movement) toggles a ring date, a node, or clears.
if (drag.mode === 'pan' && !drag.moved) {
// Double tap (trackpad tap-to-click may never emit a dblclick) resets view.
if (doubleTapRef.current()) {
resetView()
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
return
}
// Independent toggles: a date and a node can both be selected.
if (drag.ring != null) {
selectedRingRef.current = selectedRingRef.current === drag.ring ? null : drag.ring
} else if (drag.id) {
setSelectedId(prev => (prev === drag.id ? null : drag.id))
} else {
selectedRingRef.current = null
setSelectedId(null)
}
invalidate()
}
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
}
const onMouseLeave = () => {
hoverRef.current = null
hoveredRingRef.current = null
hoveredLinkRef.current = null
invalidate()
endDrag()
}
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) {
return
}
// macOS smart zoom (two-finger double-tap) → reset (see lib/trackpad-gestures).
if (isSmartZoomWheel(e)) {
resetView()
return
}
// Manual zoom takes over the camera from any auto-fit play-through.
setPlaying(false)
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const vp = viewportRef.current
const k = clamp(vp.k * (e.deltaY > 0 ? 0.9 : 1.1), ZOOM_MIN, ZOOM_MAX)
viewportRef.current = { k, x: px - ((px - vp.x) / vp.k) * k, y: py - ((py - vp.y) / vp.k) * k }
invalidate()
}
return (
<div className="relative min-h-0 flex-1 overflow-hidden" ref={wrapRef}>
<canvas
className="block touch-none select-none text-foreground"
onDoubleClick={resetView}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onWheel={onWheel}
ref={canvasRef}
/>
{/* Timeline scrubber centered along the top, clear of the close button.
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} />
</div>
{/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */}
<div className="pointer-events-auto absolute bottom-2 right-2 z-20 [-webkit-app-region:no-drag]">
<ShareControls imported={imported} onImport={importCode} onResetMap={onResetMap} shareCode={shareCode} />
</div>
{/* Legend — bottom-left, one entry per line like a conventional key. */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 text-[0.62rem] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rounded-full bg-[var(--theme-primary)]/80" /> skill
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} /> memory
</span>
<span className="text-[0.58rem] text-muted-foreground/65">core = oldest · outer = newer</span>
<RevealLabel axis={timeAxis} revealStore={revealStore} />
</div>
</div>
)
}

View file

@ -0,0 +1,89 @@
import type { StarmapNode } from '@/types/hermes'
export function formatDate(ts?: null | number): string {
if (!ts) {
return 'unknown'
}
try {
return new Date(ts * 1000).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
} catch {
return 'unknown'
}
}
// Tag-style badge items for the hover tooltip — date first. Use-count is NOT a
// badge (rendered separately, right-aligned) so it's excluded here.
export function metaBadges(n: StarmapNode): string[] {
const out: string[] = [formatDate(n.timestamp)]
if (n.kind === 'memory') {
out.push(n.memorySource === 'profile' ? 'profile memory' : 'memory')
} else {
out.push(n.category)
if (n.createdBy === 'agent') {
out.push('learned')
}
if (n.pinned) {
out.push('pinned')
}
}
return out.filter(Boolean)
}
// Bare "xN" use-count, last in the badge row. Null when never used.
export function countLabel(n: StarmapNode): null | string {
return n.kind === 'skill' && n.useCount > 0 ? `x${n.useCount}` : null
}
// Footer-row content for the tooltip. Reserved primitive — returns nothing for
// now (skills have no UUID; their id is just the name). Wire real detail here
// later and the tooltip lays it out automatically.
export function nodeFooter(node: StarmapNode): null | string {
void node
return null
}
// Greedy word-wrap for the tooltip title so long memory lines don't blow out.
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const words = text.split(/\s+/).filter(Boolean)
const lines: string[] = []
let line = ''
for (const word of words) {
const next = line ? `${line} ${word}` : word
if (!line || ctx.measureText(next).width <= maxW) {
line = next
} else {
lines.push(line)
line = word
}
}
if (line) {
lines.push(line)
}
return lines
}
// Trim to fit maxW, appending an ellipsis (keeps floating labels compact so they
// don't span the overlay).
export function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) {
return text
}
let s = text
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) {
s = s.slice(0, -1)
}
return `${s.trimEnd()}`
}

View file

@ -0,0 +1,105 @@
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { clamp } from './geometry'
// Empty lead-in: push the oldest node off 0 so the timeline opens on a beat of
// emptiness (you watch the first node grow in). Radial position is otherwise a
// truthful linear map of time, so rings line up with the nodes they date.
export const LEAD_IN = 0.06
export const recForRatio = (ratio: number): number => LEAD_IN + (1 - LEAD_IN) * clamp(ratio, 0, 1)
export interface Recency {
maxTs: null | number
minTs: null | number
// id → recency ratio (0 oldest … 1 newest). Timed by timestamp when the span
// is real, else ordinal so an undated graph still "builds up" in a stable order.
rec: Map<string, number>
timed: boolean
}
// Shared recency model for both the radial layout (simulation.ts) and the
// timeline scrubber, so a node's ring distance and its ignite time agree.
export function computeRecency(nodes: StarmapNode[]): Recency {
const known = nodes
.map(n => (typeof n.timestamp === 'number' && Number.isFinite(n.timestamp) ? Number(n.timestamp) : null))
.filter((v): v is number => v !== null)
const minTs = known.length ? Math.min(...known) : null
const maxTs = known.length ? Math.max(...known) : null
const timed = minTs !== null && maxTs !== null && maxTs > minTs
const ordered = [...nodes].sort((a, b) => {
const at = typeof a.timestamp === 'number' ? a.timestamp : Infinity
const bt = typeof b.timestamp === 'number' ? b.timestamp : Infinity
return at === bt ? a.id.localeCompare(b.id) : at - bt
})
const ordRatio = new Map(ordered.map((n, i) => [n.id, ordered.length > 1 ? i / (ordered.length - 1) : 0]))
const rec = new Map<string, number>()
// Radius is a truthful linear map of time (ordinal only as a fallback for the
// undated). Co-timed nodes share a radius and fan out by ANGLE in the sim — so
// a burst reads as a populated ring, and the dated rings stay accurate.
for (const n of nodes) {
const ratio =
timed && typeof n.timestamp === 'number' && minTs !== null && maxTs !== null
? (Number(n.timestamp) - minTs) / (maxTs - minTs)
: (ordRatio.get(n.id) ?? 0)
rec.set(n.id, recForRatio(ratio))
}
return { maxTs, minTs, rec, timed }
}
export interface TimeBucket {
memory: number
skill: number
total: number
}
export interface TimeAxis {
buckets: TimeBucket[]
maxTotal: number
maxTs: null | number
minTs: null | number
// Total node count — the denominator for the "n / total" label when undated.
size: number
timed: boolean
}
// Bucket nodes across recency [0,1] into a fixed-width histogram — the little
// bars the scrubber rides over. Skill/memory kept separate so the bars can show
// the same two-tone split as the map glyphs.
export function buildTimeAxis(graph: StarmapGraph, bucketCount = 48): TimeAxis {
const { maxTs, minTs, rec, timed } = computeRecency(graph.nodes)
const n = Math.max(1, bucketCount)
const buckets: TimeBucket[] = Array.from({ length: n }, () => ({ memory: 0, skill: 0, total: 0 }))
for (const node of graph.nodes) {
const r = rec.get(node.id) ?? 0
const idx = clamp(Math.floor(r * n), 0, n - 1)
const b = buckets[idx]!
b.total += 1
if (node.kind === 'memory') {
b.memory += 1
} else {
b.skill += 1
}
}
const maxTotal = buckets.reduce((m, b) => Math.max(m, b.total), 0)
return { buckets, maxTotal, maxTs, minTs, size: graph.nodes.length, timed }
}
// Wall-clock date at a reveal ratio (linear in time when the graph is dated).
export function dateAtReveal(axis: TimeAxis, reveal: number): null | number {
if (!axis.timed || axis.minTs === null || axis.maxTs === null) {
return null
}
return Math.round(axis.minTs + clamp(reveal, 0, 1) * (axis.maxTs - axis.minTs))
}

View file

@ -0,0 +1,281 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { TimeAxis } from './time-axis'
interface TimelineProps {
axis: TimeAxis
// Colour for memory stars — matches the map's memory glyph.
memoryColor?: string
onScrub: (reveal: number) => void
onTogglePlay: () => void
playing: boolean
revealStore: RevealSignal
// Reveal positions (01) where rings spawn — drawn as anchor ticks.
ringStops?: number[]
}
interface RevealSignal {
get: () => number
subscribe: (listener: (value: number) => void) => () => void
}
interface Star {
delay: number
duration: number
kind: 'memory' | 'skill'
leftPct: number
opacity: number
size: number
topPct: number
}
const ACTIVE_MARKER_CLASS = 'opacity-100'
const INACTIVE_MARKER_CLASS = 'opacity-30'
// Busiest bucket gets this many stars; quieter ones scale down proportionally.
const MAX_STARS_PER_BUCKET = 7
// Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders.
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a += 0x6d2b79f5
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Scatter each time bucket's activity into stars: count ∝ events, split between
// skill- and memory-coloured stars, jittered within the bucket's horizontal slot
// and across the track height. A starmap timeline for a starmap.
function buildStars(axis: TimeAxis): Star[] {
const n = Math.max(1, axis.buckets.length)
const stars: Star[] = []
axis.buckets.forEach((b, i) => {
if (b.total === 0) {
return
}
const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0
const count = Math.max(1, Math.round(intensity * MAX_STARS_PER_BUCKET))
const skillCount = Math.round((b.skill / b.total) * count)
const r = rng(i * 9973 + 7)
const slot = 1 / n
for (let s = 0; s < count; s++) {
const jitter = (r() - 0.5) * slot * 0.9
const center = (i + 0.5) / n
stars.push({
delay: r() * 3,
duration: 2.4 + r() * 2.6,
kind: s < skillCount ? 'skill' : 'memory',
leftPct: Math.max(0, Math.min(1, center + jitter)) * 100,
// Brighter, slightly larger stars are rarer.
opacity: 0.5 + r() * 0.5,
size: 1 + Math.round(r() * r() * 2.2),
topPct: 12 + r() * 76
})
}
})
return stars
}
// Playback scrubber as a constellation: dim stars are the unrevealed future; a
// scanner sweep ignites them (bright + twinkling) left→right as the reveal
// advances. The bright layer is clipped by the reveal CSS var, so the rAF sweep
// in StarMap drives it with zero per-frame JS.
export const Timeline = memo(function Timeline({
axis,
memoryColor = 'var(--theme-secondary)',
onScrub,
onTogglePlay,
playing,
revealStore,
ringStops = []
}: TimelineProps) {
const trackRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const markerRefs = useRef<HTMLDivElement[]>([])
const stars = useMemo(() => buildStars(axis), [axis])
const syncReveal = useCallback(
(value: number) => {
const reveal = Math.max(0, Math.min(1, value))
const track = trackRef.current
if (track) {
track.style.setProperty('--starmap-reveal', String(reveal))
track.setAttribute('aria-valuenow', String(Math.round(reveal * 100)))
}
ringStops.forEach((stop, i) => {
const el = markerRefs.current[i]
if (!el) {
return
}
const active = stop <= reveal
el.classList.toggle(ACTIVE_MARKER_CLASS, active)
el.classList.toggle(INACTIVE_MARKER_CLASS, !active)
})
},
[ringStops]
)
useEffect(() => revealStore.subscribe(syncReveal), [revealStore, syncReveal])
useEffect(() => {
markerRefs.current.length = ringStops.length
syncReveal(revealStore.get())
}, [revealStore, ringStops.length, syncReveal])
const ratioAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) {
return revealStore.get()
}
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
onScrub(ratioAt(e.clientX))
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (draggingRef.current) {
onScrub(ratioAt(e.clientX))
}
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = false
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const colorFor = (kind: Star['kind']) => (kind === 'skill' ? 'var(--theme-primary)' : memoryColor)
return (
<div className="pointer-events-auto flex w-[28rem] max-w-full items-center gap-3 [-webkit-app-region:no-drag]">
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
<button
aria-label={playing ? 'Pause' : 'Play timeline'}
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
onClick={onTogglePlay}
type="button"
>
<Codicon name={playing ? 'debug-pause' : 'triangle-right'} size={playing ? '0.8rem' : '0.95rem'} />
</button>
<div
aria-label="Timeline scrubber"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(revealStore.get() * 100)}
className="relative h-7 min-w-0 flex-1 cursor-pointer select-none touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={trackRef}
role="slider"
style={{ '--starmap-reveal': revealStore.get() } as React.CSSProperties}
tabIndex={0}
>
{/* Dashed midline — a faint horizontal axis the stars ride over. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-foreground/5"
/>
{/* Dim constellation — the unrevealed future. */}
<div aria-hidden className="absolute inset-0">
{stars.map((star, i) => (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={{
backgroundColor: colorFor(star.kind),
height: star.size,
left: `${star.leftPct}%`,
opacity: 0.16,
top: `${star.topPct}%`,
width: star.size
}}
/>
))}
</div>
{/* Ignited constellation — bright + twinkling, clipped to the reveal. */}
<div
aria-hidden
className="absolute inset-0"
style={{ clipPath: 'inset(0 calc((1 - var(--starmap-reveal, 1)) * 100%) 0 0)' }}
>
{stars.map((star, i) => {
const color = colorFor(star.kind)
return (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={
{
'--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}`,
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
top: `${star.topPct}%`,
width: star.size
} as React.CSSProperties
}
/>
)
})}
</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)] shadow-[0_0_4px_var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
{/* 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"
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
/>
</div>
</div>
)
})

View file

@ -0,0 +1,97 @@
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
export type MemoryCard = StarmapGraph['memory'][number]
export type Shape = 'circle' | 'diamond' | 'hexagon' | 'square' | 'triangle'
export interface Viewport {
k: number
x: number
y: number
}
export interface Rgb {
b: number
g: number
r: number
}
export interface Rect {
h: number
w: number
x: number
y: number
}
export interface SimNode extends StarmapNode, SimulationNodeDatum {
outerRingIndex: number // first ring that caps this node's recency band
rec: number // recency 0 (oldest) → 1 (newest)
tr: number // time-anchored target radius
x: number
y: number
}
export interface SimLink extends SimulationLinkDatum<SimNode> {
source: SimNode | string
target: SimNode | string
}
// Per-mode line/ring style.
export interface GraphParams {
lineAlpha: number
lineDash: number
lineDashed: boolean
lineWidth: number
ringAlpha: number
ringDash: number
ringDashed: boolean
ringWidth: number
}
// Per-mode ring/orb params (band wash, light-sliver size, ring outline alpha, orb sheen).
export interface RingParams {
bandAlpha: number
lightSize: number
ringAlpha: number
sheen: number
}
export interface Palette {
bandInk: Rgb
base: Rgb
bg: Rgb
c: GraphParams
chipBg: string
darkTheme: boolean
inkInv: string
memoryInk: Rgb
primary: Rgb
skillInk: Rgb
}
export interface Ring {
label: null | string
r: number
ratio: number
}
export interface RingLabelRect {
h: number
i: number
w: number
x: number
y: number
}
export interface FadeBuckets {
// Per-element "birth" progress 0→1 used to ease position (nodes rise outward
// into place, rings grow out) as the scrubber reveals them. Separate from the
// alpha buckets so it stays monotonic and isn't perturbed by focus/selection.
appear: Map<string, number>
labels: Map<string, number>
links: Map<string, number>
nodes: Map<string, number>
rings: Map<string, number>
}

View file

@ -7,6 +7,8 @@ import {
useState
} from 'react'
import { isSmartZoomWheel } from '@/lib/trackpad-gestures'
interface Transform {
scale: number
x: number
@ -43,6 +45,14 @@ export function useZoomPan() {
const onWheel = useCallback(
(event: ReactWheelEvent) => {
event.preventDefault()
// macOS smart zoom (two-finger double-tap) → reset, not zoom-in.
if (isSmartZoomWheel(event)) {
setTransform({ scale: 1, x: 0, y: 0 })
return
}
const rect = event.currentTarget.getBoundingClientRect()
const cx = event.clientX - rect.left - rect.width / 2
const cy = event.clientY - rect.top - rect.height / 2

View file

@ -41,6 +41,7 @@ import type {
SessionMessagesResponse,
SessionSearchResponse,
SkillInfo,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
@ -113,6 +114,7 @@ export type {
SessionSearchResult,
SkillInfo,
StaleAuxAssignment,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
@ -489,6 +491,15 @@ export function getSkills(): Promise<SkillInfo[]> {
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return window.hermesDesktop.api<StarmapGraph>({
...profileScoped(),
// Backend REST contract — stays /api/learning even though the UI feature is
// now "star map". Renaming this would break against an un-upgraded backend.
path: '/api/learning/graph'
})
}
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),

View file

@ -181,6 +181,7 @@ export const en: Translations = {
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings',
openStarmap: 'Open memory graph',
openKeybinds: 'Keyboard shortcuts'
},
@ -752,6 +753,32 @@ export const en: Translations = {
failedToUpdate: name => `Failed to update ${name}`
},
starmap: {
title: 'Memory Graph',
subtitle: (nodes, clusters) => `${nodes} skills across ${clusters} categories`,
close: 'Close memory graph',
refresh: 'Refresh',
memory: 'Memory',
filterAll: 'All',
filterUsed: 'Used',
filterLearned: 'Learned',
viewGraph: 'Graph',
loadFailed: 'Could not load memory graph',
loading: 'Loading…',
emptyTitle: 'Nothing learned yet',
emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.',
share: 'Share map',
shareTitle: 'Import / export map',
sharePlaceholder: 'Paste a map code…',
copy: 'Copy map code',
copied: 'Copied!',
importMap: 'Import a map',
importBtn: 'Load',
importEmpty: 'Paste a map code to load it.',
importSuccess: nodes => `Loaded a map with ${nodes} ${nodes === 1 ? 'node' : 'nodes'}.`,
importedBadge: 'imported map',
resetToMine: 'Back to my map'
},
agents: {
close: 'Close agents',
title: 'Spawn tree',
@ -1845,6 +1872,8 @@ export const en: Translations = {
running: count => `${count} running`,
cron: 'Cron',
openCron: 'Open cron jobs',
starmap: 'Memory Graph',
openStarmap: 'Open memory graph',
turnRunning: 'Running',
currentTurnElapsed: 'Current turn elapsed',
contextUsage: 'Context usage',

View file

@ -181,7 +181,8 @@ export const ja = defineLocale({
showRightSidebar: '右サイドバーを表示',
muteHaptics: '触覚フィードバックをオフ',
unmuteHaptics: '触覚フィードバックをオン',
openSettings: '設定を開く'
openSettings: '設定を開く',
openStarmap: 'メモリグラフを開く'
},
language: {
@ -864,6 +865,21 @@ export const ja = defineLocale({
failedToUpdate: name => `${name} の更新に失敗しました`
},
starmap: {
title: 'メモリグラフ',
subtitle: (nodes, clusters) => `${clusters} カテゴリの ${nodes} スキル`,
close: 'メモリグラフを閉じる',
refresh: '更新',
memory: 'メモリ',
filterAll: 'すべて',
filterUsed: '使用済み',
filterLearned: '学習済み',
viewGraph: 'グラフ',
loadFailed: 'メモリグラフを読み込めませんでした',
loading: '読み込み中…',
emptyTitle: 'まだ学習はありません',
emptyDesc: 'Hermes がスキルやメモリを蓄積すると、ここに表示されます。'
},
agents: {
close: 'エージェントを閉じる',
title: 'スポーンツリー',
@ -1965,6 +1981,8 @@ export const ja = defineLocale({
running: count => `${count} 実行中`,
cron: 'Cron',
openCron: 'Cron ジョブを開く',
starmap: 'メモリグラフ',
openStarmap: 'メモリグラフを開く',
turnRunning: '実行中',
currentTurnElapsed: '現在のターン経過時間',
contextUsage: 'コンテキスト使用状況',

View file

@ -223,6 +223,7 @@ export interface Translations {
muteHaptics: string
unmuteHaptics: string
openSettings: string
openStarmap: string
openKeybinds: string
}
@ -649,6 +650,32 @@ export interface Translations {
failedToUpdate: (name: string) => string
}
starmap: {
title: string
subtitle: (nodes: number, clusters: number) => string
close: string
refresh: string
memory: string
filterAll: string
filterUsed: string
filterLearned: string
viewGraph: string
loadFailed: string
loading: string
emptyTitle: string
emptyDesc: string
share: string
shareTitle: string
sharePlaceholder: string
copy: string
copied: string
importMap: string
importBtn: string
importEmpty: string
importSuccess: (nodes: number) => string
importedBadge: string
resetToMine: string
}
agents: {
close: string
title: string
@ -1498,6 +1525,8 @@ export interface Translations {
running: (count: number) => string
cron: string
openCron: string
starmap: string
openStarmap: string
turnRunning: string
currentTurnElapsed: string
contextUsage: string

View file

@ -175,7 +175,8 @@ export const zhHant = defineLocale({
showRightSidebar: '顯示右側邊欄',
muteHaptics: '靜音觸感回饋',
unmuteHaptics: '開啟觸感回饋',
openSettings: '開啟設定'
openSettings: '開啟設定',
openStarmap: '開啟記憶圖譜'
},
language: {
@ -836,6 +837,21 @@ export const zhHant = defineLocale({
failedToUpdate: name => `更新 ${name} 失敗`
},
starmap: {
title: '記憶圖譜',
subtitle: (nodes, clusters) => `${clusters} 個類別中的 ${nodes} 個技能`,
close: '關閉記憶圖譜',
refresh: '重新整理',
memory: '記憶',
filterAll: '全部',
filterUsed: '已使用',
filterLearned: '已學習',
viewGraph: '圖譜',
loadFailed: '無法載入記憶圖譜',
loading: '載入中…',
emptyTitle: '尚無學習內容',
emptyDesc: '當 Hermes 為你的工作建立技能與記憶時,會顯示在這裡。'
},
agents: {
close: '關閉代理',
title: '派生樹',
@ -1904,6 +1920,8 @@ export const zhHant = defineLocale({
running: count => `${count} 個執行中`,
cron: '排程',
openCron: '開啟排程工作',
starmap: '記憶圖譜',
openStarmap: '開啟記憶圖譜',
turnRunning: '執行中',
currentTurnElapsed: '目前回合已用時間',
contextUsage: '上下文使用量',

View file

@ -176,6 +176,7 @@ export const zh: Translations = {
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置',
openStarmap: '打开记忆图谱',
openKeybinds: '键盘快捷键'
},
@ -936,6 +937,32 @@ export const zh: Translations = {
failedToUpdate: name => `更新 ${name} 失败`
},
starmap: {
title: '记忆图谱',
subtitle: (nodes, clusters) => `${clusters} 个类别中的 ${nodes} 个技能`,
close: '关闭记忆图谱',
refresh: '刷新',
memory: '记忆',
filterAll: '全部',
filterUsed: '已使用',
filterLearned: '已学习',
viewGraph: '图谱',
loadFailed: '无法加载记忆图谱',
loading: '加载中…',
emptyTitle: '尚无学习内容',
emptyDesc: '当 Hermes 为你的工作构建技能和记忆时,会显示在这里。',
share: '分享图谱',
shareTitle: '导入 / 导出图谱',
sharePlaceholder: '粘贴图谱代码…',
copy: '复制图谱代码',
copied: '已复制!',
importMap: '导入图谱',
importBtn: '加载',
importEmpty: '粘贴图谱代码以加载。',
importSuccess: nodes => `已加载包含 ${nodes} 个节点的图谱。`,
importedBadge: '导入的图谱',
resetToMine: '返回我的图谱'
},
agents: {
close: '关闭代理',
title: '派生树',
@ -2016,6 +2043,8 @@ export const zh: Translations = {
running: count => `${count} 个运行中`,
cron: '排程',
openCron: '打开排程任务',
starmap: '记忆图谱',
openStarmap: '打开记忆图谱',
turnRunning: '运行中',
currentTurnElapsed: '当前回合已用时间',
contextUsage: '上下文用量',

View file

@ -91,6 +91,7 @@ import {
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconSquare as Square,
IconChartDots3 as Starmap,
IconSteeringWheel as SteeringWheel,
IconPlayerStopFilled as StopFilled,
IconSun as Sun,
@ -204,6 +205,7 @@ export {
Settings2,
SlidersHorizontal,
Square,
Starmap,
SteeringWheel,
StopFilled,
Sun,

View file

@ -0,0 +1,277 @@
import { deflateSync, inflateSync } from 'fflate'
// ── Loadout codec ─────────────────────────────────────────────────────────────
//
// A generic, WoW-talent-loadout-style binary share codec: pack *bits and
// indices* (not JSON), DEFLATE the body, frame it with a version + checksum, and
// emit a short, opaque, clipboard-safe base64url string under a namespacing
// prefix. Domain code supplies only the body schema (`write`/`read` over the
// BitWriter/BitReader); everything else — compression, integrity, framing,
// whitespace tolerance, typed errors — lives here so a new shareable thing
// (e.g. an enabled-skills set) is just a new `createLoadout({ … })`.
// ── Little-endian bit writer (WoW's WriteBits, low bit first) ────────────────
export class BitWriter {
private bits: number[] = []
bit(v: 0 | 1 | boolean): void {
this.bits.push(v ? 1 : 0)
}
uint(value: number, width: number): void {
let v = value >>> 0
for (let i = 0; i < width; i += 1) {
this.bits.push(v & 1)
v >>>= 1
}
}
// LEB128-style varint: 7 payload bits per group, high "continue" bit set while
// more groups follow.
varint(value: number): void {
let v = Math.max(0, Math.floor(value))
do {
const group = v & 0x7f
v = Math.floor(v / 128)
this.bit(v > 0 ? 1 : 0)
this.uint(group, 7)
} while (v > 0)
}
str(s: string): void {
const bytes = new TextEncoder().encode(s)
this.varint(bytes.length)
for (const b of bytes) {
this.uint(b, 8)
}
}
bytes(): Uint8Array {
const out = new Uint8Array(Math.ceil(this.bits.length / 8))
for (let i = 0; i < this.bits.length; i += 1) {
if (this.bits[i]) {
out[i >> 3]! |= 1 << (i & 7)
}
}
return out
}
}
export class BitReader {
private pos = 0
constructor(private readonly buf: Uint8Array) {}
bit(): number {
if (this.pos >= this.buf.length * 8) {
throw new RangeError('loadout truncated')
}
const i = this.pos++
return (this.buf[i >> 3]! >> (i & 7)) & 1
}
uint(width: number): number {
let v = 0
for (let i = 0; i < width; i += 1) {
v |= this.bit() << i
}
return v >>> 0
}
varint(): number {
let v = 0
let shift = 0
for (;;) {
const cont = this.bit()
v += this.uint(7) * 2 ** shift
shift += 7
if (!cont) {
return v
}
}
}
str(): string {
const len = this.varint()
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i += 1) {
bytes[i] = this.uint(8)
}
return new TextDecoder().decode(bytes)
}
}
// Interns repeated strings (labels, categories, …) so each record spends one
// varint id instead of the full string; DEFLATE then squeezes the dictionary.
export class Dict {
private readonly index = new Map<string, number>()
readonly list: string[] = []
id(s: string): number {
const hit = this.index.get(s)
if (hit !== undefined) {
return hit
}
const id = this.list.length
this.index.set(s, id)
this.list.push(s)
return id
}
}
// Index of `value` in a fixed enum table, clamped to 0 so an unknown value
// decodes to the table's first (default) member instead of throwing.
export const idxOf = <T extends readonly string[]>(table: T, value: string): number => {
const i = table.indexOf(value as T[number])
return i < 0 ? 0 : i
}
// Bits needed to address `n` items positionally (fixed-width back-references).
export const indexBits = (n: number): number => (n <= 1 ? 1 : Math.ceil(Math.log2(n)))
// ── base64url over the raw bytes (URL- and clipboard-safe, no padding) ────────
function toBase64Url(buf: Uint8Array): string {
let bin = ''
for (const b of buf) {
bin += String.fromCharCode(b)
}
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function fromBase64Url(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i += 1) {
out[i] = bin.charCodeAt(i)
}
return out
}
// FNV-1a over the body bytes, low 16 bits — a tamper/corruption gate, not crypto.
function checksum16(buf: Uint8Array): number {
let h = 0x811c9dc5
for (const b of buf) {
h ^= b
h = Math.imul(h, 0x01000193)
}
return (h >>> 0) & 0xffff
}
export class LoadoutError extends Error {}
export interface Loadout<T> {
decode(code: string): T
encode(value: T): string
}
export interface LoadoutSpec<T> {
/** Namespacing prefix (like WoW's leading bytes), e.g. 'HML'. */
prefix: string
/** Bumped whenever the body schema changes incompatibly. */
version: number
/** Write the domain body; framing/compression/checksum are added around it. */
write: (w: BitWriter, value: T) => void
/** Read the domain body back. May throw — it's wrapped as a LoadoutError. */
read: (r: BitReader) => T
/** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */
noun?: string
/** Error subclass to throw, so callers can `instanceof` their own type. */
error?: new (message: string) => LoadoutError
}
const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum
// Build an encode/decode pair for a domain value. The body schema is the only
// thing a caller writes; everything else (deflate, version+checksum frame,
// base64url, whitespace tolerance, typed errors) is shared.
export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
const Err = spec.error ?? LoadoutError
const noun = spec.noun ?? 'code'
const Noun = noun.charAt(0).toUpperCase() + noun.slice(1)
const encode = (value: T): string => {
const body = new BitWriter()
spec.write(body, value)
const payload = deflateSync(body.bytes(), { level: 9 })
const head = new BitWriter()
head.uint(spec.version, 8)
head.uint(checksum16(payload), 16)
const headBytes = head.bytes()
const framed = new Uint8Array(headBytes.length + payload.length)
framed.set(headBytes, 0)
framed.set(payload, headBytes.length)
return spec.prefix + toBase64Url(framed)
}
const decode = (code: string): T => {
// Strip ALL whitespace, not just the ends — a pasted code often picks up soft
// wraps / newlines, and base64 decoding chokes on any of it.
const cleaned = code.replace(/\s+/g, '')
const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned
if (!raw) {
throw new Err(`That doesn't look like a ${noun}.`)
}
let framed: Uint8Array
try {
framed = fromBase64Url(raw)
} catch {
throw new Err(`That doesn't look like a ${noun}.`)
}
if (framed.length <= HEAD_BYTES) {
throw new Err(`${Noun} is too short to be valid.`)
}
const head = new BitReader(framed.subarray(0, HEAD_BYTES))
const version = head.uint(8)
const storedSum = head.uint(16)
if (version !== spec.version) {
throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`)
}
const payload = framed.subarray(HEAD_BYTES)
if (checksum16(payload) !== storedSum) {
throw new Err(`${Noun} looks corrupted (checksum mismatch).`)
}
try {
return spec.read(new BitReader(inflateSync(payload)))
} catch (err) {
throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`)
}
}
return { decode, encode }
}

View file

@ -0,0 +1,50 @@
// Trackpad / pointer gesture primitives shared across canvas + DOM surfaces.
//
// macOS quirk (Chromium/Electron): both pinch-zoom and "smart zoom" arrive as
// `wheel` events with `ctrlKey` synthetically set — there is no dedicated DOM
// event for either. They're disambiguated by their deltas:
// - pinch-to-zoom: ctrlKey + a non-zero delta
// - smart zoom: ctrlKey + zero deltas (the two-finger double-tap)
// Plain two-finger scroll has ctrlKey === false. Centralising this here keeps
// every zoom/pan surface from re-deriving the same OS trivia (and getting it
// wrong, which makes smart-zoom read as a zoom-in).
export interface WheelLike {
ctrlKey: boolean
deltaX: number
deltaY: number
}
/** macOS "smart zoom" (two-finger double-tap): a ctrl-wheel with no delta. */
export function isSmartZoomWheel(e: WheelLike): boolean {
return e.ctrlKey && e.deltaX === 0 && e.deltaY === 0
}
/** Pinch-to-zoom (or ctrl + mouse wheel): a ctrl-wheel carrying a delta. */
export function isPinchZoomWheel(e: WheelLike): boolean {
return e.ctrlKey && (e.deltaX !== 0 || e.deltaY !== 0)
}
export const DOUBLE_TAP_MS = 300
/**
* Stateful double-tap detector for surfaces where a real `dblclick` may never
* fire (e.g. a trackpad with tap-to-click off). Call it once per discrete tap;
* it returns true when two taps land within `thresholdMs` of each other, then
* resets so a third tap starts a fresh pair.
*/
export function createDoubleTapDetector(thresholdMs: number = DOUBLE_TAP_MS): (now?: number) => boolean {
let last = 0
return (now: number = Date.now()): boolean => {
if (now - last < thresholdMs) {
last = 0
return true
}
last = now
return false
}
}

View file

@ -0,0 +1,46 @@
import { atom } from 'nanostores'
import { getStarmapGraph } from '@/hermes'
import type { StarmapGraph } from '@/types/hermes'
// On-demand cache for the star map. The graph scan touches the skills catalog +
// usage ledger + memory files, so we fetch it only when the panel opens (and on
// an explicit refresh), never on a turn boundary.
export const $starmapGraph = atom<StarmapGraph | null>(null)
export const $starmapLoading = atom(false)
export const $starmapError = atom<null | string>(null)
let inflight: Promise<void> | null = null
export async function loadStarmapGraph(force = false): Promise<void> {
if (inflight) {
return inflight
}
if ($starmapGraph.get() && !force) {
return
}
$starmapLoading.set(true)
$starmapError.set(null)
inflight = (async () => {
try {
$starmapGraph.set(await getStarmapGraph())
} catch (err) {
$starmapError.set(err instanceof Error ? err.message : String(err))
} finally {
$starmapLoading.set(false)
inflight = null
}
})()
return inflight
}
/** Drop the cache so the next open refetches against the now-active profile. */
export function resetStarmapGraph(): void {
inflight = null
$starmapGraph.set(null)
$starmapError.set(null)
}

View file

@ -429,6 +429,47 @@ export interface UsageStats {
total: number
}
/** One graph node in the star map (learned skill or memory chunk). */
export interface StarmapNode {
id: string
label: string
kind: 'memory' | 'skill'
memorySource?: 'memory' | 'profile'
timestamp?: null | number
category: string
useCount: number
state: string
createdBy: null | string
pinned: boolean
}
/** A declared `related_skills` link; both endpoints are guaranteed to be nodes. */
export interface StarmapEdge {
source: string
target: string
}
export interface StarmapCluster {
category: string
count: number
}
/** Freeform memory rendered as a card — never a graph node. */
export interface StarmapMemoryCard {
source: 'memory' | 'profile'
timestamp?: null | number
title: string
body: string
}
export interface StarmapGraph {
nodes: StarmapNode[]
edges: StarmapEdge[]
clusters: StarmapCluster[]
memory: StarmapMemoryCard[]
stats: Record<string, unknown>
}
export interface ContextUsageCategory {
color: string
id: string

View file

@ -2,6 +2,28 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import fs from 'fs'
// `hgui` symlinks a worktree's node_modules to the main checkout. Vite realpaths
// those before enforcing server.fs.allow, so codicon/font assets resolve outside
// the worktree root and 404. Whitelist the real node_modules locations.
const real = (p: string): string | null => {
try {
return fs.realpathSync(p)
} catch {
return null
}
}
const fsAllow = [
...new Set(
[
path.resolve(__dirname, '../..'),
real(path.resolve(__dirname, 'node_modules')),
real(path.resolve(__dirname, '../../node_modules'))
].filter((p): p is string => p !== null)
)
]
export default defineConfig({
base: './',
@ -47,7 +69,10 @@ export default defineConfig({
server: {
host: '127.0.0.1',
port: 5174,
strictPort: true
strictPort: true,
fs: {
allow: fsAllow
}
},
preview: {
host: '127.0.0.1',

556
package-lock.json generated
View file

@ -95,8 +95,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"fflate": "^0.8.3",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@ -132,6 +134,7 @@
"@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
"@types/hast": "^3.0.4",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
@ -1783,72 +1786,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/windows-sign/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@ -1887,448 +1824,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -8435,15 +7930,6 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@ -10918,6 +10404,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -15552,36 +15044,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",