Compare commits

...

5 Commits

Author SHA1 Message Date
Brooklyn Nicholson
6ea6b9a44f style(installer): match progress header to overlay & token-size failure buttons
Port the nous-girl BrandMark (asset + component) into the Tauri shim and give
the progress screen the same brand + title + description structure as the
desktop install overlay. Drop the failure screen's oversized lg/outline
buttons for the shared default token + a quiet text-link "Open logs".
2026-06-06 18:40:18 -05:00
Brooklyn Nicholson
e2152ce72d style(desktop): drop the changelog divider in the update overlay
Flatten the available-update view — the hero/changelog split rides on gap
spacing now instead of a --ui-stroke-tertiary hairline.
2026-06-06 18:21:12 -05:00
Brooklyn Nicholson
4b7b8a47a6 style(installer): bring the Tauri setup shim onto the design system
The signed Hermes-Setup installer/updater rendered emerald checks, boxed
stage rows, a destructive error card, and lucide spinners — diverged from
the desktop overlays. Align it using the shared tokens it already imports,
keeping the shim self-contained (no desktop component coupling):

- progress: flat stage rows (only the running step is opaque; rest muted),
  neutral check / destructive cross right of the label, running loader left,
  hairline --stroke-nous borders, fill-less log panel; fixed shimmer heading
  (no per-stage echo, no redundant header spinner).
- loader: port just the fourier-flow curve standalone (rotation dropped).
- buttons: re-sync button.tsx to the desktop's variants; [ INSTALL ] /
  [ LAUNCH ] use the onboarding HackeryButton, ported standalone.
- success: de-box the launch-error block + hairline code chip.
2026-06-06 18:15:52 -05:00
Brooklyn Nicholson
3328ce7691 style(desktop): polish installer stage rows & loader
Drop the per-stage hover card; align rows flat with the running
fourier-flow loader on the left, muted non-active steps, and status/
checks right-aligned. Add the BrandMark to the installer header and
speed up the fourier-flow curve.
2026-06-06 17:32:30 -05:00
Brooklyn Nicholson
92b8a12d98 style(desktop): bring installer & update overlays onto the design system
The install overlay never got the overlay design pass the update overlay did.
Align both to DESIGN.md — reusing existing primitives only (no new deps):

install-overlay:
- Loader2 spinners -> lemniscate Loader (running stage + cancelling)
- green emerald check / AlertTriangle -> neutral Codicon check + ErrorIcon
- de-box the failure block (drop destructive bg card) -> flat icon + message
- command <pre> / inline code chips -> hairline (--stroke-nous), no bg
- shadcn bg-muted/* -> --ui-* tokens (progress track, current-row); text-2xl -> text-xl

updates-overlay:
- drop border-border/70 on DialogContent (base Dialog already gives shadow-nous + --stroke-nous)
- de-box the changelog card -> flat --ui-stroke-tertiary divider
- manual command block: bg-muted box + emerald check -> hairline + primary-flash on copy
- "all set" emerald CheckCircle2 -> on-brand BrandMark
2026-06-06 17:14:10 -05:00
12 changed files with 352 additions and 226 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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>
)
}

View File

@@ -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: {

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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&rsquo;t launch the desktop app</div>
<div className="mt-1 text-destructive/80">{error}</div>
<div className="font-medium text-destructive">Couldn&rsquo;t launch the desktop app</div>
<div className="mt-0.5 text-muted-foreground">{error}</div>
</div>
</div>
)}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}),