mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-19 08:30:48 +08:00
Compare commits
5 Commits
opencode-p
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea6b9a44f | ||
|
|
e2152ce72d | ||
|
|
4b7b8a47a6 | ||
|
|
3328ce7691 | ||
|
|
92b8a12d98 |
BIN
apps/bootstrap-installer/public/nous-girl.jpg
Normal file
BIN
apps/bootstrap-installer/public/nous-girl.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
13
apps/bootstrap-installer/src/components/brand-mark.tsx
Normal file
13
apps/bootstrap-installer/src/components/brand-mark.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
|
||||
// Ported from apps/desktop's BrandMark; asset lives in this app's public/.
|
||||
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)} {...props}>
|
||||
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { cn } from '../lib/utils'
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap shadow-none transition-all duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -25,23 +25,24 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
'bg-transparent text-(--ui-text-primary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)] hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
|
||||
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
|
||||
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
|
||||
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'px-2.5 py-1 has-[>svg]:px-2',
|
||||
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
|
||||
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
'icon-lg': 'size-10 rounded-[4px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
36
apps/bootstrap-installer/src/components/hackery-button.tsx
Normal file
36
apps/bootstrap-installer/src/components/hackery-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* HackeryButton — the onboarding "Begin" CTA, ported standalone.
|
||||
*
|
||||
* Bracketed [ LABEL ], mono/uppercase, primary accent on a --stroke-nous hairline.
|
||||
* Lifted from apps/desktop's desktop-onboarding-overlay.tsx (sans the exit-scramble
|
||||
* choreography, which is overlay-specific). Self-contained: cn + lucide only.
|
||||
*/
|
||||
export function HackeryButton({
|
||||
className,
|
||||
label,
|
||||
loading,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'button'>, 'children'> & { label: React.ReactNode; loading?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'group inline-flex cursor-pointer items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
136
apps/bootstrap-installer/src/components/loader.tsx
Normal file
136
apps/bootstrap-installer/src/components/loader.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type ComponentProps, useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Loader — the desktop's "Fourier Flow" curve, ported standalone.
|
||||
*
|
||||
* The shim can't import apps/desktop's 559-line multi-curve <Loader> (cross-app
|
||||
* coupling + bundle bloat that defeats the point of a lightweight installer), so
|
||||
* this is just the one curve the installer uses. Math + tuning lifted verbatim
|
||||
* from apps/desktop/src/components/ui/loader.tsx ('fourier-flow'); rotation is
|
||||
* dropped because that curve never rotates. Keep the constants in sync if the
|
||||
* desktop's curve is retuned.
|
||||
*/
|
||||
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
const CURVE = {
|
||||
durationMs: 2200,
|
||||
particleCount: 92,
|
||||
pulseDurationMs: 2000,
|
||||
strokeWidth: 4.2,
|
||||
trailSpan: 0.31,
|
||||
point(progress: number, detailScale: number) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (progress: number) => ((progress % 1) + 1) % 1
|
||||
|
||||
function detailScaleFor(time: number, phaseOffset: number) {
|
||||
const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs
|
||||
|
||||
return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48
|
||||
}
|
||||
|
||||
function buildPath(detailScale: number, steps: number) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const { x, y } = CURVE.point(i / steps, detailScale)
|
||||
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function particleFor(index: number, progress: number, detailScale: number, strokeScale: number) {
|
||||
const tail = index / (CURVE.particleCount - 1)
|
||||
const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale)
|
||||
const fade = (1 - tail) ** 0.56
|
||||
|
||||
return { x, y, opacity: 0.04 + fade * 0.96, radius: (0.9 + fade * 2.7) * strokeScale }
|
||||
}
|
||||
|
||||
interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
label?: string
|
||||
pathSteps?: number
|
||||
strokeScale?: number
|
||||
}
|
||||
|
||||
export function Loader({
|
||||
className,
|
||||
label = 'Loading',
|
||||
pathSteps = 240,
|
||||
role = 'status',
|
||||
strokeScale = 1,
|
||||
...props
|
||||
}: LoaderProps) {
|
||||
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
|
||||
const pathRef = useRef<SVGPathElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const startedAt = performance.now()
|
||||
const phaseOffset = Math.random()
|
||||
particleRefs.current.length = CURVE.particleCount
|
||||
|
||||
const render = (now: number) => {
|
||||
const time = now - startedAt
|
||||
const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs
|
||||
const detailScale = detailScaleFor(time, phaseOffset)
|
||||
|
||||
pathRef.current?.setAttribute('d', buildPath(detailScale, pathSteps))
|
||||
|
||||
particleRefs.current.forEach((node, index) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const p = particleFor(index, progress, detailScale, strokeScale)
|
||||
node.setAttribute('cx', p.x.toFixed(2))
|
||||
node.setAttribute('cy', p.y.toFixed(2))
|
||||
node.setAttribute('r', p.radius.toFixed(2))
|
||||
node.setAttribute('opacity', p.opacity.toFixed(3))
|
||||
})
|
||||
|
||||
frame = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render(performance.now())
|
||||
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [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">
|
||||
<path
|
||||
opacity="0.1"
|
||||
ref={pathRef}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={CURVE.strokeWidth * strokeScale}
|
||||
/>
|
||||
{Array.from({ length: CURVE.particleCount }, (_, index) => (
|
||||
<circle
|
||||
fill="currentColor"
|
||||
key={index}
|
||||
ref={node => {
|
||||
particleRefs.current[index] = node
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ interface FailureProps {
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two clear
|
||||
* affordances: Retry (primary) and Open log folder (secondary).
|
||||
* The actual error message lives below in muted text. Two affordances on
|
||||
* shared Button tokens: Retry (primary) and Open logs (quiet text link).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
@@ -55,22 +55,13 @@ export default function Failure({ bootstrap }: FailureProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void (isUpdate ? startUpdate() : startInstall())}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
|
||||
<RefreshCw />
|
||||
{isUpdate ? 'Retry update' : 'Retry install'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => void openLogDir()}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Open log folder
|
||||
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
|
||||
<FileText />
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
cancelInstall,
|
||||
$mode,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
||||
import { Check, X, ChevronRight, FileText } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Loader } from '../components/loader'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
@@ -21,6 +24,7 @@ interface ProgressProps {
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const mode = useStore($mode)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -30,46 +34,46 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
const currentStage =
|
||||
bootstrap.currentStage != null
|
||||
? bootstrap.stages[bootstrap.currentStage]
|
||||
: null
|
||||
const isUpdate = mode === 'update'
|
||||
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'
|
||||
const description = isUpdate
|
||||
? 'Hermes is updating to the latest version — this only takes a moment.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'
|
||||
const pct = Math.round(progress.fraction * 100)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
{bootstrap.status === 'running' && (
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{bootstrap.status === 'running'
|
||||
? currentStage
|
||||
? currentStage.info.title
|
||||
: 'Preparing\u2026'
|
||||
: bootstrap.status === 'completed'
|
||||
? 'Done'
|
||||
: 'Installing'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{progress.done} of {progress.total} steps
|
||||
</div>
|
||||
</div>
|
||||
{/* Top progress bar — plain HTML, derived from --primary so it
|
||||
tracks the theme accent. */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
{/* Header: brand + title + description, matching the desktop install overlay. */}
|
||||
<div className="flex flex-shrink-0 items-start gap-4 px-6 pt-6 pb-4">
|
||||
<BrandMark className="size-11" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-1">
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||
{/* Progress line + bar; the count shimmers while the install runs. */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className={clsx(bootstrap.status === 'running' && 'shimmer')}>
|
||||
{progress.done} of {progress.total} steps complete
|
||||
</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flat stage list: only the running step is opaque; the rest read as
|
||||
muted. Running loader overhangs left so labels stay aligned; the
|
||||
terminal check/cross sits right of the label. */}
|
||||
<ol className="space-y-0.5">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
@@ -77,22 +81,20 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
rec.state === 'running' && 'bg-card text-foreground',
|
||||
rec.state === 'succeeded' && 'text-foreground/80',
|
||||
rec.state === 'skipped' && 'text-muted-foreground',
|
||||
rec.state === 'failed' &&
|
||||
'bg-destructive/10 text-destructive',
|
||||
!rec.state && 'text-muted-foreground/60'
|
||||
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
|
||||
rec.state === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rec.durationMs != null && rec.state !== 'failed' && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground/70">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
@@ -100,16 +102,12 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
Live output
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bootstrap.logs.length} lines
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col border-l border-(--stroke-nous)">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-(--stroke-nous) px-3 py-2 text-xs">
|
||||
<span className="font-medium text-foreground/80">Live output</span>
|
||||
<span className="tabular-nums text-muted-foreground">{bootstrap.logs.length} lines</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
@@ -127,29 +125,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showLogs && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
@@ -158,25 +146,20 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal-state markers, neutral by design: a muted check for done/skipped
|
||||
// (no celebratory green), a destructive cross for failure. Running renders its
|
||||
// spinner on the left; pending stays icon-less.
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'running') {
|
||||
return <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
return <X size={13} className="shrink-0 text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { Rocket, AlertCircle } from 'lucide-react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
@@ -53,32 +53,23 @@ export default function Success() {
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
|
||||
hermes desktop
|
||||
</code>
|
||||
.
|
||||
<code className="font-mono text-sm text-foreground/80">hermes desktop</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleLaunch()}
|
||||
size="lg"
|
||||
<HackeryButton
|
||||
disabled={launching}
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
{launching ? 'Launching…' : 'Launch Hermes'}
|
||||
</Button>
|
||||
label={launching ? 'Launching' : 'Launch'}
|
||||
loading={launching}
|
||||
onClick={() => void handleLaunch()}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex max-w-2xl items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-1 text-destructive/80">{error}</div>
|
||||
<div className="font-medium text-destructive">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { startInstall } from '../store'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
@@ -42,17 +41,7 @@ export default function Welcome() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="group inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
Install Hermes
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Button>
|
||||
<HackeryButton label="Install" onClick={() => void startInstall()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Loader } from '@/components/ui/loader'
|
||||
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$updateApply,
|
||||
@@ -69,10 +69,7 @@ export function UpdatesOverlay() {
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleClose} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
|
||||
showCloseButton={phase !== 'applying'}
|
||||
>
|
||||
<DialogContent className="max-w-sm overflow-hidden p-0 gap-0" showCloseButton={phase !== 'applying'}>
|
||||
{phase === 'applying' && <ApplyingView apply={apply} />}
|
||||
|
||||
{phase === 'manual' && (
|
||||
@@ -166,11 +163,7 @@ function IdleView({
|
||||
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={u.latestBody}
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title={u.allSetTitle}
|
||||
/>
|
||||
<CenteredStatus body={u.latestBody} icon={<BrandMark className="size-12" />} title={u.allSetTitle} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,7 +182,7 @@ function IdleView({
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
||||
<div className="grid gap-3">
|
||||
{groups.map(group => (
|
||||
<div key={group.id}>
|
||||
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
||||
@@ -247,26 +240,25 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
copied ? 'border-primary/50' : 'border-(--stroke-nous) hover:border-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
<code className="select-all font-mono text-sm text-foreground">
|
||||
<span className="text-muted-foreground">$ </span>
|
||||
<code className="min-w-0 flex-1 truncate select-all font-mono text-sm text-foreground">
|
||||
<span className="select-none text-muted-foreground">$ </span>
|
||||
{command}
|
||||
</code>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
{u.copied}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="size-3.5" />
|
||||
{u.copy}
|
||||
</>
|
||||
<span
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 text-xs font-medium transition-colors',
|
||||
copied ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{copied ? u.copied : u.copy}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorIcon } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import type {
|
||||
@@ -11,7 +14,7 @@ import type {
|
||||
DesktopBootstrapState
|
||||
} from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
|
||||
import { ChevronDown, ChevronRight } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,6 @@ interface DesktopInstallOverlayProps {
|
||||
interface StageRowProps {
|
||||
descriptor: DesktopBootstrapStageDescriptor
|
||||
result: DesktopBootstrapStageResult | undefined
|
||||
isCurrent: boolean
|
||||
now: number
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ function formatElapsed(ms: number): string {
|
||||
return `${m}:${String(s - m * 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
function StageRow({ descriptor, result, now }: StageRowProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.install
|
||||
const state: DesktopBootstrapStageState = result?.state || 'pending'
|
||||
@@ -109,52 +111,48 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
const icon = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
return <Loader className="size-6" type="fourier-flow" />
|
||||
|
||||
case 'succeeded':
|
||||
return <Check className="h-4 w-4 text-emerald-600" />
|
||||
|
||||
case 'skipped':
|
||||
return <Check className="h-4 w-4 text-muted-foreground" />
|
||||
return <Codicon className="text-muted-foreground" name="check" size="0.8125rem" />
|
||||
|
||||
case 'failed':
|
||||
return <AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
return <ErrorIcon size="1rem" />
|
||||
|
||||
case 'pending':
|
||||
|
||||
default:
|
||||
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
return <div className="size-1.5 rounded-full border border-(--ui-stroke-secondary)" />
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const reason = result?.json?.reason || result?.error || null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md px-3 py-2 transition-colors',
|
||||
isCurrent && 'bg-muted/60',
|
||||
state === 'failed' && 'bg-destructive/10'
|
||||
<li className="flex items-center gap-3 px-3 py-1">
|
||||
{state === 'running' && (
|
||||
<div className="-mr-2 -ml-4 flex size-6 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('truncate text-sm', state === 'running' ? 'font-medium' : 'text-muted-foreground')}>
|
||||
{formatStageName(descriptor.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{state === 'running'
|
||||
? elapsed
|
||||
? `${copy.stageStates[state]} · ${elapsed}`
|
||||
: copy.stageStates[state]
|
||||
: null}
|
||||
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
|
||||
{state === 'failed' ? copy.stageStates[state] : null}
|
||||
</span>
|
||||
{state !== 'running' && <span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>}
|
||||
</div>
|
||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{state === 'running'
|
||||
? elapsed
|
||||
? `${copy.stageStates[state]} · ${elapsed}`
|
||||
: copy.stageStates[state]
|
||||
: null}
|
||||
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
|
||||
{state === 'failed' ? copy.stageStates[state] : null}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -245,6 +243,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.install
|
||||
|
||||
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -353,14 +352,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
||||
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
|
||||
<h2 className="text-xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{copy.unsupportedDesc(platformLabel)}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{copy.installCommand}</div>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
|
||||
<pre className="overflow-x-auto rounded-md border border-(--stroke-nous) px-3 py-2.5 font-mono text-[12px]">
|
||||
<code>{ups.installCommand}</code>
|
||||
</pre>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
@@ -385,9 +384,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
||||
<div className="mt-6 flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{copy.installTo} <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
||||
{copy.retryAfterRun}
|
||||
@@ -415,13 +414,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
|
||||
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
|
||||
{/* Header -- always visible, never scrolls */}
|
||||
<div className="flex-shrink-0 p-8 pb-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{failed ? copy.failedDesc : copy.activeDesc}
|
||||
</p>
|
||||
<div className="flex flex-shrink-0 items-start gap-4 p-8 pb-4">
|
||||
{!failed && <BrandMark className="size-11 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{failed ? copy.failedDesc : copy.activeDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable middle: progress, stages, error block, log */}
|
||||
@@ -436,7 +436,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</span>
|
||||
<span className="tabular-nums">{progressPct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
@@ -447,31 +447,25 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
{totalCount === 0 && state.active && (
|
||||
<div className="mb-4 flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<Loader className="size-5" type="lemniscate-bloom" />
|
||||
<Loader className="size-5" type="fourier-flow" />
|
||||
<span>{copy.fetchingManifest}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failed && state.error && (
|
||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{copy.error}</span>
|
||||
<div className="mb-4 flex items-start gap-2 text-sm">
|
||||
<ErrorIcon className="mt-0.5 shrink-0" size="1rem" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-destructive">{copy.error}</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stages.length > 0 && (
|
||||
<ol className="mb-4 space-y-1">
|
||||
<ol className="mb-4 space-y-0.5">
|
||||
{stages.map(stage => (
|
||||
<StageRow
|
||||
descriptor={stage}
|
||||
isCurrent={stage.name === currentStage}
|
||||
key={stage.name}
|
||||
now={now}
|
||||
result={state.stages[stage.name]}
|
||||
/>
|
||||
<StageRow descriptor={stage} key={stage.name} now={now} result={state.stages[stage.name]} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
@@ -529,7 +523,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? <Loader className="size-4" type="fourier-flow" /> : null}
|
||||
{cancelling ? copy.cancelling : copy.cancelInstall}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -542,7 +536,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{copy.transcriptSaved}{' '}
|
||||
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
<code className="font-mono text-(--ui-text-secondary)">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -294,10 +294,10 @@ const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
|
||||
},
|
||||
'fourier-flow': {
|
||||
...baseCurve('Fourier Flow', {
|
||||
durationMs: 8400,
|
||||
durationMs: 2200,
|
||||
particleCount: 92,
|
||||
pulseDurationMs: 6800,
|
||||
rotationDurationMs: 44000,
|
||||
pulseDurationMs: 2000,
|
||||
rotationDurationMs: 15000,
|
||||
strokeWidth: 4.2,
|
||||
trailSpan: 0.31
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user