mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Align the first-launch install overlay and the in-app update overlay to apps/desktop/DESIGN.md, reusing in-bundle primitives only (no new deps): - install overlay: Loader2 spinners -> Loader (fourier-flow); emerald check / AlertTriangle -> neutral Codicon check + canonical ErrorIcon; de-boxed the failure block; hairline (--stroke-nous) command/code chips; --ui-* tokens; BrandMark header; flat stage rows (only the active step opaque). - update overlay: drop the redundant DialogContent border (base Dialog already supplies shadow-nous + --stroke-nous); de-box the changelog; hairline + primary-flash manual command block; on-brand BrandMark for "all set".
558 lines
15 KiB
TypeScript
558 lines
15 KiB
TypeScript
import { type ComponentProps, useEffect, useRef } from 'react'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export const LOADER_TYPES = [
|
|
'original-thinking',
|
|
'thinking-five',
|
|
'thinking-nine',
|
|
'rose-orbit',
|
|
'rose-curve',
|
|
'rose-two',
|
|
'rose-three',
|
|
'rose-four',
|
|
'lissajous-drift',
|
|
'lemniscate-bloom',
|
|
'hypotrochoid-loop',
|
|
'three-petal-spiral',
|
|
'four-petal-spiral',
|
|
'five-petal-spiral',
|
|
'six-petal-spiral',
|
|
'butterfly-phase',
|
|
'cardioid-glow',
|
|
'cardioid-heart',
|
|
'heart-wave',
|
|
'spiral-search',
|
|
'fourier-flow'
|
|
] as const
|
|
|
|
export type LoaderType = (typeof LOADER_TYPES)[number]
|
|
|
|
interface Point {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface LoaderCurve {
|
|
durationMs: number
|
|
name: string
|
|
particleCount: number
|
|
point: (progress: number, detailScale: number) => Point
|
|
pulseDurationMs: number
|
|
rotate: boolean
|
|
rotationDurationMs: number
|
|
strokeWidth: number
|
|
trailSpan: number
|
|
}
|
|
|
|
interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
|
label?: string
|
|
pathSteps?: number
|
|
strokeScale?: number
|
|
type?: LoaderType
|
|
}
|
|
|
|
interface BaseCurveOptions extends Pick<
|
|
LoaderCurve,
|
|
'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'
|
|
> {
|
|
point?: LoaderCurve['point']
|
|
rotate?: boolean
|
|
rotationDurationMs?: number
|
|
}
|
|
|
|
const TWO_PI = Math.PI * 2
|
|
|
|
const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
|
|
'original-thinking': thinkingCurve('Original Thinking', 7, {
|
|
durationMs: 4600,
|
|
particleCount: 64,
|
|
pulseDurationMs: 4200,
|
|
rotationDurationMs: 28000,
|
|
trailSpan: 0.38
|
|
}),
|
|
'thinking-five': thinkingCurve('Thinking Five', 5, {
|
|
durationMs: 4600,
|
|
particleCount: 62,
|
|
pulseDurationMs: 4200,
|
|
rotationDurationMs: 28000,
|
|
trailSpan: 0.38
|
|
}),
|
|
'thinking-nine': thinkingCurve('Thinking Nine', 9, {
|
|
durationMs: 4700,
|
|
particleCount: 68,
|
|
pulseDurationMs: 4200,
|
|
rotationDurationMs: 30000,
|
|
trailSpan: 0.39
|
|
}),
|
|
'rose-orbit': {
|
|
...baseCurve('Rose Orbit', {
|
|
durationMs: 5200,
|
|
particleCount: 72,
|
|
pulseDurationMs: 4600,
|
|
rotate: true,
|
|
rotationDurationMs: 28000,
|
|
strokeWidth: 5.2,
|
|
trailSpan: 0.42
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const r = 7 - 2.7 * detailScale * Math.cos(7 * t)
|
|
|
|
return {
|
|
x: 50 + Math.cos(t) * r * 3.9,
|
|
y: 50 + Math.sin(t) * r * 3.9
|
|
}
|
|
}
|
|
},
|
|
'rose-curve': roseCurve('Rose Curve', 5, {
|
|
durationMs: 5400,
|
|
particleCount: 78,
|
|
pulseDurationMs: 4600,
|
|
strokeWidth: 4.5,
|
|
trailSpan: 0.32
|
|
}),
|
|
'rose-two': roseCurve('Rose Two', 2, {
|
|
durationMs: 5200,
|
|
particleCount: 74,
|
|
pulseDurationMs: 4300,
|
|
strokeWidth: 4.6,
|
|
trailSpan: 0.3
|
|
}),
|
|
'rose-three': roseCurve('Rose Three', 3, {
|
|
durationMs: 5300,
|
|
particleCount: 76,
|
|
pulseDurationMs: 4400,
|
|
strokeWidth: 4.6,
|
|
trailSpan: 0.31
|
|
}),
|
|
'rose-four': roseCurve('Rose Four', 4, {
|
|
durationMs: 5400,
|
|
particleCount: 78,
|
|
pulseDurationMs: 4500,
|
|
strokeWidth: 4.6,
|
|
trailSpan: 0.32
|
|
}),
|
|
'lissajous-drift': {
|
|
...baseCurve('Lissajous Drift', {
|
|
durationMs: 6000,
|
|
particleCount: 68,
|
|
pulseDurationMs: 5400,
|
|
strokeWidth: 4.7,
|
|
trailSpan: 0.34
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const amp = 24 + detailScale * 6
|
|
|
|
return {
|
|
x: 50 + Math.sin(3 * t + 1.57) * amp,
|
|
y: 50 + Math.sin(4 * t) * (amp * 0.92)
|
|
}
|
|
}
|
|
},
|
|
'lemniscate-bloom': {
|
|
...baseCurve('Lemniscate Bloom', {
|
|
durationMs: 5600,
|
|
particleCount: 70,
|
|
pulseDurationMs: 5000,
|
|
rotationDurationMs: 34000,
|
|
strokeWidth: 4.8,
|
|
trailSpan: 0.4
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const scale = 20 + detailScale * 7
|
|
const denom = 1 + Math.sin(t) ** 2
|
|
|
|
return {
|
|
x: 50 + (scale * Math.cos(t)) / denom,
|
|
y: 50 + (scale * Math.sin(t) * Math.cos(t)) / denom
|
|
}
|
|
}
|
|
},
|
|
'hypotrochoid-loop': {
|
|
...baseCurve('Hypotrochoid Loop', {
|
|
durationMs: 7600,
|
|
particleCount: 82,
|
|
pulseDurationMs: 6200,
|
|
rotationDurationMs: 42000,
|
|
strokeWidth: 4.6,
|
|
trailSpan: 0.46
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const r = 2.7 + detailScale * 0.45
|
|
const d = 4.8 + detailScale * 1.2
|
|
const x = (8.2 - r) * Math.cos(t) + d * Math.cos(((8.2 - r) / r) * t)
|
|
const y = (8.2 - r) * Math.sin(t) - d * Math.sin(((8.2 - r) / r) * t)
|
|
|
|
return {
|
|
x: 50 + x * 3.05,
|
|
y: 50 + y * 3.05
|
|
}
|
|
}
|
|
},
|
|
'three-petal-spiral': spiralPetalCurve('Three-Petal Spiral', 3, 82),
|
|
'four-petal-spiral': spiralPetalCurve('Four-Petal Spiral', 4, 84),
|
|
'five-petal-spiral': spiralPetalCurve('Five-Petal Spiral', 5, 85),
|
|
'six-petal-spiral': spiralPetalCurve('Six-Petal Spiral', 6, 86),
|
|
'butterfly-phase': {
|
|
...baseCurve('Butterfly Phase', {
|
|
durationMs: 9000,
|
|
particleCount: 88,
|
|
pulseDurationMs: 7000,
|
|
rotationDurationMs: 50000,
|
|
strokeWidth: 4.4,
|
|
trailSpan: 0.32
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * Math.PI * 12
|
|
|
|
const butterfly = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
|
|
|
|
const scale = 4.6 + detailScale * 0.45
|
|
|
|
return {
|
|
x: 50 + Math.sin(t) * butterfly * scale,
|
|
y: 50 + Math.cos(t) * butterfly * scale
|
|
}
|
|
}
|
|
},
|
|
'cardioid-glow': cardioidCurve('Cardioid Glow', {
|
|
a: 8.4,
|
|
particleCount: 72,
|
|
pointFor(t, r, scale) {
|
|
return {
|
|
x: 50 + Math.cos(t) * r * scale,
|
|
y: 50 + Math.sin(t) * r * scale
|
|
}
|
|
},
|
|
rFor(t, a) {
|
|
return a * (1 - Math.cos(t))
|
|
}
|
|
}),
|
|
'cardioid-heart': cardioidCurve('Cardioid Heart', {
|
|
a: 8.8,
|
|
particleCount: 74,
|
|
pointFor(t, r, scale) {
|
|
const baseX = Math.cos(t) * r
|
|
const baseY = Math.sin(t) * r
|
|
|
|
return {
|
|
x: 50 - baseY * scale,
|
|
y: 50 - baseX * scale
|
|
}
|
|
},
|
|
rFor(t, a) {
|
|
return a * (1 + Math.cos(t))
|
|
}
|
|
}),
|
|
'heart-wave': {
|
|
...baseCurve('Heart Wave', {
|
|
durationMs: 8400,
|
|
particleCount: 104,
|
|
pulseDurationMs: 5600,
|
|
rotationDurationMs: 22000,
|
|
strokeWidth: 3.9,
|
|
trailSpan: 0.18
|
|
}),
|
|
point(progress, detailScale) {
|
|
const root = 3.3
|
|
const xLimit = Math.sqrt(root)
|
|
const x = -xLimit + progress * xLimit * 2
|
|
const safeRoot = Math.max(0, root - x * x)
|
|
const wave = 0.9 * Math.sqrt(safeRoot) * Math.sin(6.4 * Math.PI * x)
|
|
const curve = Math.abs(x) ** (2 / 3)
|
|
const y = curve + wave
|
|
|
|
return {
|
|
x: 50 + x * 23.2,
|
|
y: 18 + (1.75 - y) * (24.5 + detailScale * 1.5)
|
|
}
|
|
}
|
|
},
|
|
'spiral-search': {
|
|
...baseCurve('Spiral Search', {
|
|
durationMs: 7800,
|
|
particleCount: 86,
|
|
pulseDurationMs: 6800,
|
|
rotationDurationMs: 44000,
|
|
strokeWidth: 4.3,
|
|
trailSpan: 0.28
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const angle = t * 4
|
|
const radius = 8 + (1 - Math.cos(t)) * (8.5 + detailScale * 2.4)
|
|
|
|
return {
|
|
x: 50 + Math.cos(angle) * radius,
|
|
y: 50 + Math.sin(angle) * radius
|
|
}
|
|
}
|
|
},
|
|
'fourier-flow': {
|
|
...baseCurve('Fourier Flow', {
|
|
durationMs: 2200,
|
|
particleCount: 92,
|
|
pulseDurationMs: 2000,
|
|
rotationDurationMs: 15000,
|
|
strokeWidth: 4.2,
|
|
trailSpan: 0.31
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const mix = 1 + detailScale * 0.16
|
|
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
|
|
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
|
|
|
|
return {
|
|
x: 50 + x,
|
|
y: 50 + y
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function Loader({
|
|
className,
|
|
label = 'Loading',
|
|
pathSteps = 240,
|
|
role = 'status',
|
|
strokeScale = 1,
|
|
type = 'rose-curve',
|
|
...props
|
|
}: LoaderProps) {
|
|
const config = LOADER_CURVES[type]
|
|
const groupRef = useRef<SVGGElement | null>(null)
|
|
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
|
|
const pathRef = useRef<SVGPathElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
let animationFrame = 0
|
|
const startedAt = performance.now()
|
|
const phaseOffset = Math.random()
|
|
particleRefs.current.length = config.particleCount
|
|
|
|
const render = (now: number) => {
|
|
const time = now - startedAt
|
|
const progress = ((time + phaseOffset * config.durationMs) % config.durationMs) / config.durationMs
|
|
const detailScale = detailScaleFor(time, config, phaseOffset)
|
|
const rotation = rotationFor(time, config, phaseOffset)
|
|
|
|
groupRef.current?.setAttribute('transform', `rotate(${rotation} 50 50)`)
|
|
pathRef.current?.setAttribute('d', buildPath(config, detailScale, pathSteps))
|
|
|
|
particleRefs.current.forEach((node, index) => {
|
|
if (!node) {
|
|
return
|
|
}
|
|
|
|
const particle = particleFor(config, index, progress, detailScale, strokeScale)
|
|
node.setAttribute('cx', particle.x.toFixed(2))
|
|
node.setAttribute('cy', particle.y.toFixed(2))
|
|
node.setAttribute('r', particle.radius.toFixed(2))
|
|
node.setAttribute('opacity', particle.opacity.toFixed(3))
|
|
})
|
|
|
|
animationFrame = window.requestAnimationFrame(render)
|
|
}
|
|
|
|
render(performance.now())
|
|
|
|
return () => window.cancelAnimationFrame(animationFrame)
|
|
}, [config, pathSteps, strokeScale])
|
|
|
|
return (
|
|
<div
|
|
{...props}
|
|
aria-label={props['aria-label'] ?? label}
|
|
className={cn('inline-grid size-10 place-items-center text-primary', className)}
|
|
role={role}
|
|
>
|
|
<svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100">
|
|
<g ref={groupRef}>
|
|
<path
|
|
opacity="0.1"
|
|
ref={pathRef}
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={config.strokeWidth * strokeScale}
|
|
/>
|
|
{Array.from({ length: config.particleCount }, (_, index) => (
|
|
<circle
|
|
fill="currentColor"
|
|
key={`${type}-${index}`}
|
|
ref={node => {
|
|
particleRefs.current[index] = node
|
|
}}
|
|
/>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function baseCurve(name: string, options: BaseCurveOptions): LoaderCurve {
|
|
return {
|
|
durationMs: options.durationMs,
|
|
name,
|
|
particleCount: options.particleCount,
|
|
point: options.point ?? (() => ({ x: 50, y: 50 })),
|
|
pulseDurationMs: options.pulseDurationMs,
|
|
rotate: options.rotate ?? false,
|
|
rotationDurationMs: options.rotationDurationMs ?? 36000,
|
|
strokeWidth: options.strokeWidth,
|
|
trailSpan: options.trailSpan
|
|
}
|
|
}
|
|
|
|
function thinkingCurve(
|
|
name: string,
|
|
petalCount: number,
|
|
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'rotationDurationMs' | 'trailSpan'>
|
|
): LoaderCurve {
|
|
return {
|
|
...baseCurve(name, {
|
|
...options,
|
|
rotate: true,
|
|
strokeWidth: 5.5
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const x = 7 * Math.cos(t) - 3 * detailScale * Math.cos(petalCount * t)
|
|
const y = 7 * Math.sin(t) - 3 * detailScale * Math.sin(petalCount * t)
|
|
|
|
return {
|
|
x: 50 + x * 3.9,
|
|
y: 50 + y * 3.9
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function roseCurve(
|
|
name: string,
|
|
k: number,
|
|
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'>
|
|
): LoaderCurve {
|
|
return {
|
|
...baseCurve(name, {
|
|
...options,
|
|
rotate: true,
|
|
rotationDurationMs: 28000
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const a = 9.2 + detailScale * 0.6
|
|
const r = a * (0.72 + detailScale * 0.28) * Math.cos(k * t)
|
|
|
|
return {
|
|
x: 50 + Math.cos(t) * r * 3.25,
|
|
y: 50 + Math.sin(t) * r * 3.25
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function spiralPetalCurve(name: string, spiralR: number, particleCount: number): LoaderCurve {
|
|
return {
|
|
...baseCurve(name, {
|
|
durationMs: 4600,
|
|
particleCount,
|
|
pulseDurationMs: 4200,
|
|
rotate: true,
|
|
rotationDurationMs: 28000,
|
|
strokeWidth: 4.4,
|
|
trailSpan: 0.34
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const spiralr = 1
|
|
const d = 3 + detailScale * 0.25
|
|
const baseX = (spiralR - spiralr) * Math.cos(t) + d * Math.cos(((spiralR - spiralr) / spiralr) * t)
|
|
const baseY = (spiralR - spiralr) * Math.sin(t) - d * Math.sin(((spiralR - spiralr) / spiralr) * t)
|
|
const scale = 2.2 + detailScale * 0.45
|
|
|
|
return {
|
|
x: 50 + baseX * scale,
|
|
y: 50 + baseY * scale
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function cardioidCurve(
|
|
name: string,
|
|
options: {
|
|
a: number
|
|
particleCount: number
|
|
pointFor: (t: number, r: number, scale: number) => Point
|
|
rFor: (t: number, a: number) => number
|
|
}
|
|
): LoaderCurve {
|
|
return {
|
|
...baseCurve(name, {
|
|
durationMs: 6200,
|
|
particleCount: options.particleCount,
|
|
pulseDurationMs: 5200,
|
|
rotationDurationMs: 36000,
|
|
strokeWidth: 4.9,
|
|
trailSpan: 0.36
|
|
}),
|
|
point(progress, detailScale) {
|
|
const t = progress * TWO_PI
|
|
const a = options.a + detailScale * 0.8
|
|
const r = options.rFor(t, a)
|
|
|
|
return options.pointFor(t, r, 2.15)
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildPath(config: LoaderCurve, detailScale: number, steps: number) {
|
|
return Array.from({ length: steps + 1 }, (_, index) => {
|
|
const point = config.point(index / steps, detailScale)
|
|
|
|
return `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`
|
|
}).join(' ')
|
|
}
|
|
|
|
function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
|
const pulseProgress =
|
|
((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
|
|
|
|
const pulseAngle = pulseProgress * TWO_PI
|
|
|
|
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48
|
|
}
|
|
|
|
function normalizeProgress(progress: number) {
|
|
return ((progress % 1) + 1) % 1
|
|
}
|
|
|
|
function particleFor(config: LoaderCurve, index: number, progress: number, detailScale: number, strokeScale: number) {
|
|
const tailOffset = index / (config.particleCount - 1)
|
|
const point = config.point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale)
|
|
const fade = (1 - tailOffset) ** 0.56
|
|
|
|
return {
|
|
opacity: 0.04 + fade * 0.96,
|
|
radius: (0.9 + fade * 2.7) * strokeScale,
|
|
x: point.x,
|
|
y: point.y
|
|
}
|
|
}
|
|
|
|
function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
|
if (!config.rotate) {
|
|
return 0
|
|
}
|
|
|
|
return (
|
|
-(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
|
|
)
|
|
}
|