mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(desktop): memory-graph share dialog + core/zoom & light-mode polish
- Rework share/import into one Dialog (matches rename/create): a single code field (copy to share, paste + Load to import) with a hover copy button, a Reset link beside the upload icon when viewing an imported map, and plainer copy. - Core orb: scales with the world zoom (~1.25× the inner shell), backdrop wash behind it; on focus/hover the scene composites above the orb so the active tooltip + lit lines are never covered. - fitViewport floors zoom at the reference (5-ring) extent, so big maps render at a constant scale and pan instead of shrinking every node to fit. - Light mode: flip inter-ring band shading to read as depth (not a mound), fade the core ring in from t=0, drop the timeline star glow. - Timeline: filled play glyph, crisper constellation, date moved into the legend.
This commit is contained in:
parent
bd1d354fc3
commit
3e7ed0c53b
8 changed files with 186 additions and 130 deletions
|
|
@ -89,9 +89,18 @@ export function fitViewport(w: number, h: number, outer: number = RING_OUTER): V
|
|||
return { k: 1, x: w / 2, y: h / 2 }
|
||||
}
|
||||
|
||||
const spanX = (outer + 30) * 2
|
||||
const spanY = spanX * TILT
|
||||
const k = clamp(Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / spanY, 2.2), ZOOM_MIN, ZOOM_MAX)
|
||||
// Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in).
|
||||
const kFor = (r: number): number => {
|
||||
const spanX = (r + 30) * 2
|
||||
|
||||
return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2)
|
||||
}
|
||||
|
||||
// Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map
|
||||
// renders at that constant scale and overflows — you pan it — instead of
|
||||
// shrinking every node to fit. Smaller extents (few rings, or the playback
|
||||
// core) still fit tightly / zoom in.
|
||||
const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX)
|
||||
|
||||
// Bias the center down a touch — the timeline along the top adds visual weight
|
||||
// up there, so true-center reads as sitting high.
|
||||
|
|
@ -107,7 +116,8 @@ export function radiusForRecency(rec: number, outer: number = RING_OUTER): numbe
|
|||
// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS,
|
||||
// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how
|
||||
// big they read (billboarded), while a full-map view keeps its honest density.
|
||||
export const fitScale = (w: number, h: number, rings: Ring[]): number => fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k
|
||||
export const fitScale = (w: number, h: number, rings: Ring[]): number =>
|
||||
fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k
|
||||
|
||||
// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests.
|
||||
export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
|
||||
|
|
|
|||
|
|
@ -80,8 +80,7 @@ const NODE_BIRTH = { down: 0.11, up: 0.075 }
|
|||
|
||||
// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus
|
||||
// a few digits/symbols for the "digital rain / decoding" look.
|
||||
const SCRAMBLE_CHARS =
|
||||
'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳'
|
||||
const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳'
|
||||
|
||||
// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render
|
||||
// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and
|
||||
|
|
@ -477,6 +476,7 @@ export function drawScene(scene: Scene): DrawResult {
|
|||
if (revealed) {
|
||||
revealedRings.add(n.outerRingIndex)
|
||||
}
|
||||
|
||||
const isFocus = revealed && n.id === focusId
|
||||
const isNeighbor = revealed && !!focusSet && focusSet.has(n.id)
|
||||
const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi
|
||||
|
|
@ -758,7 +758,7 @@ export function drawScramble({
|
|||
rings: Ring[]
|
||||
vp: Viewport
|
||||
}): void {
|
||||
const { darkTheme, primary } = palette
|
||||
const { bg, darkTheme, primary } = palette
|
||||
const projX = (wx: number) => wx * vp.k + vp.x
|
||||
const projY = (wy: number) => wy * vp.k * TILT + vp.y
|
||||
|
||||
|
|
@ -766,14 +766,33 @@ export function drawScramble({
|
|||
|
||||
const coreX = projX(0)
|
||||
const coreY = projY(0)
|
||||
// Fill to the innermost ring (the core shell), not the RING_INNER constant —
|
||||
// the ring sits in lead-in space, so derive the radius from it directly.
|
||||
const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94
|
||||
// Scale with the world (like the rings), but ~1.25× bigger than the bare inner
|
||||
// shell so the core reads prominently at the rested fit.
|
||||
const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25
|
||||
|
||||
if (coreRx <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Backdrop wash: a background-colour radial dimming the core ellipse, so on a
|
||||
// busy map the nodes/links crowding through the centre recede behind the orb.
|
||||
// Self-masking — bg over empty bg is invisible, so a sparse map shows no disc.
|
||||
const washR = coreRx * 1.15
|
||||
ctx.save()
|
||||
ctx.translate(coreX, coreY)
|
||||
ctx.scale(1, TILT)
|
||||
const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR)
|
||||
// Near-opaque across the core (busy graph effectively vanishes behind the orb)
|
||||
// with a soft falloff only at the rim so there's no hard disc edge.
|
||||
wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93))
|
||||
wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88))
|
||||
wash.addColorStop(1, rgba(bg, 0))
|
||||
ctx.fillStyle = wash
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, washR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
|
||||
// Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the
|
||||
// glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more,
|
||||
// smaller glyphs rather than a few giant ones — and stays legible when tiny.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Upload } from '@/lib/icons'
|
||||
|
||||
interface ShareControlsProps {
|
||||
// True when the shown map was loaded from a pasted code (not the live scan).
|
||||
|
|
@ -17,20 +23,20 @@ interface ShareControlsProps {
|
|||
shareCode?: string
|
||||
}
|
||||
|
||||
const SECTION_LABEL = 'text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/55'
|
||||
|
||||
// WoW-talent-loadout style sharing: one icon button opens a popover with the
|
||||
// current map's code (copy/export) and a paste box (import) — drop a string,
|
||||
// see the build. Lives bottom-right of the map, mirroring the legend.
|
||||
// Share / import a map as a single code. The textarea shows the current map's
|
||||
// code (copy it to share); edit/replace it and hit Load to view someone else's.
|
||||
// One field, one button — a standard Dialog matching rename/create.
|
||||
export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [value, setValue] = useState('')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const apply = () => {
|
||||
const code = draft.trim()
|
||||
const own = (shareCode ?? '').trim()
|
||||
const code = value.trim()
|
||||
const canLoad = code !== '' && code !== own
|
||||
|
||||
const load = () => {
|
||||
if (!code) {
|
||||
setError(t.starmap.importEmpty)
|
||||
|
||||
|
|
@ -42,97 +48,85 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod
|
|||
|
||||
if (err === null) {
|
||||
setOpen(false)
|
||||
setDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
setOpen(next)
|
||||
|
||||
if (!next) {
|
||||
setError(null)
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
{imported && (
|
||||
<Button
|
||||
aria-label={t.starmap.shareTitle}
|
||||
className="size-7 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground data-[state=open]:bg-(--ui-row-hover-background) data-[state=open]:text-foreground"
|
||||
size="icon"
|
||||
title={t.starmap.shareTitle}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onResetMap?.()}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
<Codicon name="send" size="0.8rem" />
|
||||
{t.starmap.resetToMine}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
|
||||
<PopoverContent align="end" className="w-72 p-0" side="top" sideOffset={8}>
|
||||
<div className="space-y-2 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={SECTION_LABEL}>{t.starmap.share}</span>
|
||||
{imported && (
|
||||
<button
|
||||
className="text-[0.62rem] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={() => {
|
||||
onResetMap?.()
|
||||
setOpen(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t.starmap.resetToMine}
|
||||
</button>
|
||||
<Dialog
|
||||
onOpenChange={next => {
|
||||
setOpen(next)
|
||||
setError(null)
|
||||
|
||||
if (next) {
|
||||
setValue(shareCode ?? '')
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.starmap.shareTitle}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
size="icon"
|
||||
title={t.starmap.shareTitle}
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.starmap.shareTitle}</DialogTitle>
|
||||
<DialogDescription>{t.starmap.shareHint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* One code field: pre-filled with this map's code (copy to share); edit
|
||||
or paste another and Load. Copy button floats in on hover, like a
|
||||
thread code block. */}
|
||||
<div className="group/code relative">
|
||||
<textarea
|
||||
aria-label={t.starmap.shareTitle}
|
||||
className="h-24 w-full resize-none rounded-md bg-foreground/5 p-2.5 pr-9 font-mono text-xs leading-relaxed break-all text-muted-foreground/90 outline-none transition placeholder:text-muted-foreground/50 focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
|
||||
onChange={e => {
|
||||
setValue(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder={t.starmap.sharePlaceholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
{code !== '' && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-1.5 top-1.5 h-5 gap-0 rounded-md px-1 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/code:opacity-100 hover:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={t.starmap.copy}
|
||||
showLabel={false}
|
||||
text={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-7 min-w-0 flex-1 items-center rounded-md bg-foreground/5 px-2">
|
||||
<span className="truncate font-mono text-[0.62rem] text-muted-foreground/90">{shareCode || '—'}</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
appearance="button"
|
||||
buttonSize="icon"
|
||||
className="size-7 shrink-0 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
disabled={!shareCode}
|
||||
label={t.starmap.copy}
|
||||
showLabel={false}
|
||||
text={shareCode ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-[0.7rem] text-destructive">{error}</p>}
|
||||
|
||||
<div className="h-px bg-(--ui-stroke-secondary)" />
|
||||
|
||||
<div className="space-y-2 px-3 py-2.5">
|
||||
<span className={SECTION_LABEL}>{t.starmap.importMap}</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
aria-label={t.starmap.sharePlaceholder}
|
||||
className="h-7 flex-1 font-mono text-[0.62rem]"
|
||||
onChange={e => {
|
||||
setDraft(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
apply()
|
||||
}
|
||||
}}
|
||||
placeholder={t.starmap.sharePlaceholder}
|
||||
value={draft}
|
||||
/>
|
||||
<Button className="h-7 shrink-0 px-2.5 text-[0.7rem]" disabled={!draft.trim()} onClick={apply} size="sm" type="button">
|
||||
{t.starmap.importBtn}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.62rem] text-destructive">{error}</p>}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button className="w-full" disabled={!canLoad} onClick={load} type="button">
|
||||
{t.starmap.importBtn}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,7 +473,9 @@ export function StarMap({
|
|||
const val = style.getPropertyValue('--theme-primary').trim()
|
||||
|
||||
if (val) {
|
||||
const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
|
||||
const bgVal =
|
||||
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
|
||||
|
||||
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
|
||||
}
|
||||
}, [size, themeEpoch])
|
||||
|
|
@ -500,7 +502,10 @@ export function StarMap({
|
|||
// wasted CPU/GPU (WindowServer compositing) when the window isn't even the one
|
||||
// you're looking at. Freeze the loop while the window is hidden or unfocused;
|
||||
// a frozen core next to other work is fine, and it resumes instantly on focus.
|
||||
const isPaused = () => (typeof document !== 'undefined' && document.hidden) || (typeof document.hasFocus === 'function' && !document.hasFocus())
|
||||
const isPaused = () =>
|
||||
(typeof document !== 'undefined' && document.hidden) ||
|
||||
(typeof document.hasFocus === 'function' && !document.hasFocus())
|
||||
|
||||
let paused = isPaused()
|
||||
|
||||
const schedule = () => {
|
||||
|
|
@ -586,12 +591,22 @@ export function StarMap({
|
|||
dirtyRef.current = animating
|
||||
}
|
||||
|
||||
// Composite: live scramble underneath, cached static scene on top.
|
||||
// Composite order flips on focus/hover. Idle: scene first, sphere on top —
|
||||
// its backdrop wash dims the busy centre. Focused/hovered: sphere first,
|
||||
// scene on top — so the active node's tooltip + lit lines lift ABOVE the
|
||||
// sphere instead of being covered by it.
|
||||
const focused = selectedIdRef.current ?? hoverRef.current
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.drawImage(staticCanvas, 0, 0)
|
||||
|
||||
if (focused) {
|
||||
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.drawImage(staticCanvas, 0, 0)
|
||||
} else {
|
||||
ctx.drawImage(staticCanvas, 0, 0)
|
||||
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
|
||||
}
|
||||
}
|
||||
|
||||
const frame = (ts: number) => {
|
||||
|
|
@ -745,7 +760,11 @@ export function StarMap({
|
|||
|
||||
const resetView = () => {
|
||||
setPlaying(false)
|
||||
viewportRef.current = fitViewport(sizeRef.current.w, sizeRef.current.h, ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER)
|
||||
viewportRef.current = fitViewport(
|
||||
sizeRef.current.w,
|
||||
sizeRef.current.h,
|
||||
ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER
|
||||
)
|
||||
selectedRingRef.current = null
|
||||
invalidate()
|
||||
setSelectedId(null)
|
||||
|
|
@ -762,7 +781,15 @@ export function StarMap({
|
|||
// Nodes aren't draggable (static map) — remember which was pressed so a click
|
||||
// (press without movement) can select it; any drag just pans.
|
||||
const nodeId = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
|
||||
dragRef.current = { id: nodeId, mode: 'pan', moved: false, ring: ringHit, sx: e.clientX, sy: e.clientY, vp: viewportRef.current }
|
||||
dragRef.current = {
|
||||
id: nodeId,
|
||||
mode: 'pan',
|
||||
moved: false,
|
||||
ring: ringHit,
|
||||
sx: e.clientX,
|
||||
sy: e.clientY,
|
||||
vp: viewportRef.current
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
|
|
@ -882,7 +909,15 @@ export function StarMap({
|
|||
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
|
||||
scrubber receives pointer events instead of dragging the window. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-6 z-20 flex justify-center px-12">
|
||||
<Timeline axis={timeAxis} memoryColor={memoryColor} onScrub={onScrub} onTogglePlay={onTogglePlay} playing={playing} revealStore={revealStore} ringStops={ringStops} />
|
||||
<Timeline
|
||||
axis={timeAxis}
|
||||
memoryColor={memoryColor}
|
||||
onScrub={onScrub}
|
||||
onTogglePlay={onTogglePlay}
|
||||
playing={playing}
|
||||
revealStore={revealStore}
|
||||
ringStops={ringStops}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import type { TimeAxis } from './time-axis'
|
||||
|
||||
|
|
@ -113,10 +112,6 @@ export const Timeline = memo(function Timeline({
|
|||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const markerRefs = useRef<HTMLDivElement[]>([])
|
||||
// Star glow halos read as depth on a dark track but smear on a light one, so
|
||||
// the bloom is dark-mode only.
|
||||
const { resolvedMode } = useTheme()
|
||||
const glow = resolvedMode === 'dark'
|
||||
|
||||
const stars = useMemo(() => buildStars(axis), [axis])
|
||||
|
||||
|
|
@ -254,7 +249,6 @@ export const Timeline = memo(function Timeline({
|
|||
'--o': star.opacity,
|
||||
animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
|
||||
backgroundColor: color,
|
||||
boxShadow: glow ? `0 0 ${star.size + 1}px ${color}` : 'none',
|
||||
height: star.size,
|
||||
left: `${star.leftPct}%`,
|
||||
opacity: star.opacity,
|
||||
|
|
@ -268,24 +262,25 @@ export const Timeline = memo(function Timeline({
|
|||
</div>
|
||||
|
||||
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
|
||||
{ringStops.map((stop, i) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${glow ? 'shadow-[0_0_4px_var(--theme-primary)]' : ''} ${INACTIVE_MARKER_CLASS}`}
|
||||
key={i}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
markerRefs.current[i] = el
|
||||
}
|
||||
}}
|
||||
style={{ left: `${stop * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<div aria-hidden className="absolute inset-0">
|
||||
{ringStops.map((stop, i) => (
|
||||
<div
|
||||
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
|
||||
key={i}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
markerRefs.current[i] = el
|
||||
}
|
||||
}}
|
||||
style={{ left: `${stop * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Playhead — a thin white sweep line. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-foreground"
|
||||
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
|
||||
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -768,6 +768,7 @@ export const en: Translations = {
|
|||
emptyTitle: 'Nothing learned yet',
|
||||
emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.',
|
||||
share: 'Share map',
|
||||
shareHint: 'Copy the code to share this map, or paste one to load. It only includes the layout, not your memory or skill text.',
|
||||
shareTitle: 'Import / export map',
|
||||
sharePlaceholder: 'Paste a map code…',
|
||||
copy: 'Copy map code',
|
||||
|
|
|
|||
|
|
@ -665,6 +665,7 @@ export interface Translations {
|
|||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
share: string
|
||||
shareHint: string
|
||||
shareTitle: string
|
||||
sharePlaceholder: string
|
||||
copy: string
|
||||
|
|
|
|||
|
|
@ -952,6 +952,7 @@ export const zh: Translations = {
|
|||
emptyTitle: '尚无学习内容',
|
||||
emptyDesc: '当 Hermes 为你的工作构建技能和记忆时,会显示在这里。',
|
||||
share: '分享图谱',
|
||||
shareHint: '复制代码以分享此图谱,或粘贴代码以载入。仅包含布局,不含你的记忆或技能内容。',
|
||||
shareTitle: '导入 / 导出图谱',
|
||||
sharePlaceholder: '粘贴图谱代码…',
|
||||
copy: '复制图谱代码',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue