From 8fe8c2d6c476d50d4aaed13a924329fabd57a190 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:46:28 -0500 Subject: [PATCH 1/2] style(desktop): bring the install & update overlays onto the design system 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". --- apps/desktop/src/app/updates-overlay.tsx | 38 +++---- .../components/desktop-install-overlay.tsx | 100 +++++++++--------- apps/desktop/src/components/ui/loader.tsx | 6 +- 3 files changed, 68 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 1ed9e327f66..19e649705da 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -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 { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy' import { cn } from '@/lib/utils' import { @@ -94,10 +94,7 @@ export function UpdatesOverlay() { return ( - + {phase === 'applying' && } {phase === 'manual' && ( @@ -204,7 +201,7 @@ function IdleView({ return ( } + icon={} title={u.allSetTitle} /> ) @@ -229,7 +226,7 @@ function IdleView({ {body} -
+
{groups.map(group => (

{group.label}

@@ -304,26 +301,25 @@ function ManualView({ command, message, onDone }: { command: string | null; mess
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index e11ae6de0e3..c06c9f13441 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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, iconSize, Loader2 } from '@/lib/icons' +import { ChevronDown, ChevronRight, iconSize } 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 + return case 'succeeded': - return case 'skipped': - return + return case 'failed': - return + return case 'pending': default: - return
+ return
} }, [state]) const reason = result?.json?.reason || result?.error || null return ( -
  • + {state === 'running' && ( +
    {icon}
    )} - > -
    {icon}
    -
    - +
    + {formatStageName(descriptor.name)} - - {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} - + {state !== 'running' && {icon}}
    {reason && state !== 'pending' &&

    {reason}

    }
    + + {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} +
  • ) } @@ -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(EMPTY_STATE) const [logOpen, setLogOpen] = useState(false) const [copied, setCopied] = useState(false) @@ -353,12 +352,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP return (
    -

    {copy.oneTimeTitle}

    +

    {copy.oneTimeTitle}

    {copy.unsupportedDesc(platformLabel)}

    {copy.installCommand}
    -
    +            
                   {ups.installCommand}
                 
    @@ -383,9 +382,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
    -
    +
    - {copy.installTo} {ups.activeRoot} + {copy.installTo} {ups.activeRoot}
    @@ -540,7 +536,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
    {copy.transcriptSaved}{' '} - %LOCALAPPDATA%\hermes\logs\ + %LOCALAPPDATA%\hermes\logs\
    + ) +} diff --git a/apps/bootstrap-installer/src/components/loader.tsx b/apps/bootstrap-installer/src/components/loader.tsx new file mode 100644 index 00000000000..4dc2ec8934d --- /dev/null +++ b/apps/bootstrap-installer/src/components/loader.tsx @@ -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 (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, 'children'> { + label?: string + pathSteps?: number + strokeScale?: number +} + +export function Loader({ + className, + label = 'Loading', + pathSteps = 240, + role = 'status', + strokeScale = 1, + ...props +}: LoaderProps) { + const particleRefs = useRef>([]) + const pathRef = useRef(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 ( +
    + +
    + ) +} diff --git a/apps/bootstrap-installer/src/main.tsx b/apps/bootstrap-installer/src/main.tsx index aa1f7f1d532..5b744d5d67e 100644 --- a/apps/bootstrap-installer/src/main.tsx +++ b/apps/bootstrap-installer/src/main.tsx @@ -2,11 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './app.tsx' import './styles.css' +import { watchTheme } from './theme' + +// Follow the OS light/dark appearance. theme.ts paints the first frame on +// import (synchronously, from the media query); this subscribes to live OS +// theme changes via the authoritative Tauri window theme. +void watchTheme() -// Default to LIGHT mode — matches the Hermes desktop's default. The -// desktop's runtime theme system can switch to .dark later, but our -// installer ships in light mode only since we don't carry the theme -// provider machinery. createRoot(document.getElementById('root')!).render( diff --git a/apps/bootstrap-installer/src/routes/failure.tsx b/apps/bootstrap-installer/src/routes/failure.tsx index 4125e0b5b3c..13b7e16f0b5 100644 --- a/apps/bootstrap-installer/src/routes/failure.tsx +++ b/apps/bootstrap-installer/src/routes/failure.tsx @@ -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) {
    - -
    diff --git a/apps/bootstrap-installer/src/routes/progress.tsx b/apps/bootstrap-installer/src/routes/progress.tsx index 4a1dc2569fc..30f48de42f3 100644 --- a/apps/bootstrap-installer/src/routes/progress.tsx +++ b/apps/bootstrap-installer/src/routes/progress.tsx @@ -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,7 +24,9 @@ interface ProgressProps { */ export default function ProgressScreen({ bootstrap }: ProgressProps) { const progress = useStore($progress) + const mode = useStore($mode) const [showLogs, setShowLogs] = useState(false) + const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(null) useEffect(() => { @@ -30,69 +35,82 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) { } }, [bootstrap.logs.length, showLogs]) - const currentStage = - bootstrap.currentStage != null - ? bootstrap.stages[bootstrap.currentStage] - : null + // Tick once a second while the run is in flight so the active step shows a + // live elapsed timer — a long single step (e.g. the dependency download) + // reads as working, not frozen. Stops when nothing is running. + useEffect(() => { + if (bootstrap.status !== 'running') { + return + } + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [bootstrap.status]) + + 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 (
    -
    -
    -
    - {bootstrap.status === 'running' && ( - - )} - - {bootstrap.status === 'running' - ? currentStage - ? currentStage.info.title - : 'Preparing\u2026' - : bootstrap.status === 'completed' - ? 'Done' - : 'Installing'} - -
    -
    - {progress.done} of {progress.total} steps -
    -
    - {/* Top progress bar — plain HTML, derived from --primary so it - tracks the theme accent. */} -
    -
    + {/* Header: brand + title + description, matching the desktop install overlay. */} +
    + +
    +

    {title}

    +

    {description}

    -
    -
      +
      + {/* Progress line + bar; the count shimmers while the install runs. + pt-2 matches the log header's py-2 so the "steps complete" line and + the "Live output" header share a baseline. */} +
      +
      + + {progress.done} of {progress.total} steps complete + + {pct}% +
      +
      +
      +
      +
      + + {/* 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. */} +
        {bootstrap.stageOrder.map((name) => { const rec = bootstrap.stages[name] if (!rec) return null + const meta = + rec.state === 'running' && rec.startedAt != null + ? formatElapsed(now - rec.startedAt) + : rec.durationMs != null && rec.state !== 'failed' + ? formatDuration(rec.durationMs) + : null return (
      1. - + {rec.state === 'running' && } {rec.info.title} - {rec.durationMs != null && ( - - {formatDuration(rec.durationMs)} - - )} + {meta && {meta}} +
      2. ) })} @@ -100,16 +118,12 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
      {showLogs && ( -
      -
      -
      - Live output -
      -
      - {bootstrap.logs.length} lines -
      +
      +
      + Live output + {bootstrap.logs.length} lines
      -
      +
      {bootstrap.logs.map((entry, idx) => (
      -
      +
      {bootstrap.status === 'running' && ( - )} @@ -158,25 +162,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 - } if (state === 'succeeded') { - return + return } if (state === 'skipped') { - return + return } if (state === 'failed') { - return + return } - return ( -
      - ) + return null } function formatDuration(ms: number): string { @@ -186,3 +185,11 @@ function formatDuration(ms: number): string { const s = Math.round((ms % 60000) / 1000) return `${m}m ${s}s` } + +// Live elapsed for a running stage: bare seconds under a minute, then m:ss. +function formatElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + return `${m}:${String(s - m * 60).padStart(2, '0')}` +} diff --git a/apps/bootstrap-installer/src/routes/success.tsx b/apps/bootstrap-installer/src/routes/success.tsx index 3b0c17d5050..339291d2aa6 100644 --- a/apps/bootstrap-installer/src/routes/success.tsx +++ b/apps/bootstrap-installer/src/routes/success.tsx @@ -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() {

      You can launch from here, or any time from your terminal with{' '} - - hermes desktop - - . + hermes desktop.

      - + label={launching ? 'Launching' : 'Launch'} + loading={launching} + onClick={() => void handleLaunch()} + /> {error && ( -
      - +
      +
      -
      Couldn’t launch the desktop app
      -
      {error}
      +
      Couldn’t launch the desktop app
      +
      {error}
      )} diff --git a/apps/bootstrap-installer/src/routes/welcome.tsx b/apps/bootstrap-installer/src/routes/welcome.tsx index 535954af148..c09080cfcfb 100644 --- a/apps/bootstrap-installer/src/routes/welcome.tsx +++ b/apps/bootstrap-installer/src/routes/welcome.tsx @@ -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() {

      - + void startInstall()} />
      ) } diff --git a/apps/bootstrap-installer/src/store.ts b/apps/bootstrap-installer/src/store.ts index cb4c1e6212c..d2235886781 100644 --- a/apps/bootstrap-installer/src/store.ts +++ b/apps/bootstrap-installer/src/store.ts @@ -31,6 +31,10 @@ export interface StageRecord { info: StageInfo state: StageState | null durationMs?: number + /** Wall-clock time the stage entered `running`, stamped client-side so the UI + * can tick a live elapsed timer for long steps. Preserved across repeated + * running events. */ + startedAt?: number error?: string } @@ -84,6 +88,34 @@ export const $progress = computed($bootstrap, (b) => { return { done, total, fraction: done / total } }) +/** Apply a stage transition: stamp `startedAt` on the running edge, track the + * active stage. Shared by the live Rust handler and the fake-boot preview so the + * two behave identically. */ +function withStageState( + cur: BootstrapStateModel, + name: string, + state: StageState, + durationMs?: number, + error?: string +): BootstrapStateModel { + const existing = cur.stages[name] + if (!existing) return cur + return { + ...cur, + stages: { + ...cur.stages, + [name]: { + ...existing, + state, + startedAt: state === 'running' ? (existing.startedAt ?? Date.now()) : existing.startedAt, + durationMs, + error + } + }, + currentStage: state === 'running' ? name : cur.currentStage + } +} + // --------------------------------------------------------------------------- // Tauri event subscription // --------------------------------------------------------------------------- @@ -133,6 +165,19 @@ let unlisten: UnlistenFn | null = null export async function initialize(): Promise { if (unlisten) return + // Dev-only isolated preview (see runFakeBoot): drive the screens in a plain + // browser, no Tauri backend, no real install. + const fake = fakeMode() + if (fake) { + unlisten = () => {} + $logPath.set('~/.hermes/logs/bootstrap-installer.log') + $hermesHome.set('~/.hermes') + $mode.set(fake === 'update' ? 'update' : 'install') + // Update auto-runs (it's a hand-off); install/failure wait for the welcome click. + if (fake === 'update') void runFakeBoot('update') + return + } + // Pull static info on mount for the diagnostics footer. try { const [logPath, hermesHome, mode] = await Promise.all([ @@ -173,23 +218,13 @@ export async function initialize(): Promise { break } case 'stage': { - const existing = cur.stages[payload.name] - if (!existing) { + if (!cur.stages[payload.name]) { console.warn('stage event for unknown stage', payload.name) break } - const next: StageRecord = { - ...existing, - state: payload.state, - durationMs: payload.durationMs, - error: payload.error - } - $bootstrap.set({ - ...cur, - stages: { ...cur.stages, [payload.name]: next }, - currentStage: - payload.state === 'running' ? payload.name : cur.currentStage - }) + $bootstrap.set( + withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error) + ) break } case 'log': { @@ -240,6 +275,11 @@ export async function initialize(): Promise { // --------------------------------------------------------------------------- export async function startInstall(opts?: { branch?: string }): Promise { + const fake = fakeMode() + if (fake) { + void runFakeBoot(fake === 'failure' ? 'failure' : 'install') + return + } // Reset before kicking off so a retry from the failure screen clears // the previous run's state. $bootstrap.set(INITIAL) @@ -255,6 +295,10 @@ export async function startInstall(opts?: { branch?: string }): Promise { } export async function startUpdate(): Promise { + if (fakeMode()) { + void runFakeBoot('update') + return + } // Update is driven by the desktop handing off (Hermes-Setup.exe --update); // there's no welcome click. Reset + jump straight to progress, then let the // Rust side stream the synthetic update manifest. @@ -264,15 +308,135 @@ export async function startUpdate(): Promise { } export async function cancelInstall(): Promise { + if (fakeMode()) { + fakeCancelled = true + return + } await invoke('cancel_bootstrap') } export async function launchHermesDesktop(): Promise { + if (fakeMode()) throw new Error('Preview mode — launching is disabled.') const installRoot = $bootstrap.get().installRoot if (!installRoot) throw new Error('no install root') await invoke('launch_hermes_desktop', { installRoot }) } export async function openLogDir(): Promise { + if (fakeMode()) return await invoke('open_log_dir') } + +// --------------------------------------------------------------------------- +// Dev-only isolated preview ("fake boot") +// +// Synthesises the manifest + stage/log events Rust normally streams, so the +// whole reskin can be reviewed in a plain browser (`npm run dev`): +// ?fake=install welcome → [ INSTALL ] → success +// ?fake=update auto-runs the granular update flow +// ?fake=failure install that fails partway +// Gated on import.meta.env.DEV → stripped from the shipped Tauri bundle. +// --------------------------------------------------------------------------- + +type FakeMode = 'install' | 'update' | 'failure' + +function fakeMode(): FakeMode | null { + if (!import.meta.env.DEV || typeof window === 'undefined') return null + const v = new URLSearchParams(window.location.search).get('fake') + return v === 'install' || v === 'update' || v === 'failure' ? v : null +} + +interface FakeStage { + name: string + title: string +} + +const FAKE_INSTALL_STAGES: FakeStage[] = [ + { name: 'system-packages', title: 'System packages' }, + { name: 'uv', title: 'uv' }, + { name: 'python', title: 'Python environment' }, + { name: 'repo', title: 'Hermes repository' }, + { name: 'dependencies', title: 'Python dependencies' }, + { name: 'node', title: 'Node runtime' }, + { name: 'desktop', title: 'Desktop app' } +] + +const FAKE_UPDATE_STAGES: FakeStage[] = [ + { name: 'handoff', title: 'Preparing to update' }, + { name: 'update', title: 'Downloading the latest version' }, + { name: 'rebuild', title: 'Rebuilding the desktop app' }, + { name: 'install', title: 'Installing the update' } +] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +let fakeRunning = false +let fakeCancelled = false + +const fakeStage = (name: string, state: StageState, durationMs?: number, error?: string) => + $bootstrap.set(withStageState($bootstrap.get(), name, state, durationMs, error)) + +const fakeLog = (stage: string, line: string) => + $bootstrap.set({ ...$bootstrap.get(), logs: [...$bootstrap.get().logs, { stage, line, stream: 'stdout' }] }) + +const fakeFail = (error: string) => + $bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null }) + +async function runFakeBoot(kind: FakeMode): Promise { + if (fakeRunning) return + fakeRunning = true + fakeCancelled = false + try { + const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES + const cancelled = () => { + if (!fakeCancelled) return false + fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.') + $route.set('failure') + return true + } + + $bootstrap.set({ + ...INITIAL, + status: 'running', + stageOrder: stages.map((s) => s.name), + stages: Object.fromEntries( + stages.map((s): [string, StageRecord] => [ + s.name, + { info: { ...s, category: kind, needs_user_input: false }, state: null } + ]) + ) + }) + $route.set('progress') + + // Blow up midway in the failure preview so the failure screen shows. + const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null + + for (const s of stages) { + if (cancelled()) return + fakeStage(s.name, 'running') + + const durationMs = 700 + Math.floor(Math.random() * 2200) + const lines = Math.max(2, Math.round(durationMs / 450)) + for (let l = 0; l < lines; l++) { + await sleep(durationMs / lines) + if (cancelled()) return + fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}…`) + } + + if (s.name === failAt) { + fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.') + fakeFail('Simulated failure for preview (fake boot).') + $route.set('failure') + return + } + fakeStage(s.name, 'succeeded', durationMs) + } + + $bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null }) + // Install lands on success; update stays on progress (the real updater + // relaunches the desktop and exits from there). + if (kind !== 'update') $route.set('success') + } finally { + fakeRunning = false + } +} diff --git a/apps/bootstrap-installer/src/styles.css b/apps/bootstrap-installer/src/styles.css index 3171b8c073e..c999a20b319 100644 --- a/apps/bootstrap-installer/src/styles.css +++ b/apps/bootstrap-installer/src/styles.css @@ -18,10 +18,12 @@ * to the file that contains them, so they continue to point at the * correct node_modules path even from here. * - * Forced light mode: the desktop ships with a runtime theme switcher - * (ThemeProvider + applyTheme) that can flip to dark via document.documentElement. - * The installer has no UI for theme switching, so we stay on the desktop's - * default light surface (Nous-blue accent on near-white chrome). + * Follows the OS appearance: the installer has no in-app theme switcher, so + * src/theme.ts tracks the Tauri window theme and toggles `.dark` on + * . The desktop's runtime applyTheme() normally PAINTS the dark seed + * colors inline (its imported :root.dark below only flips the per-mode mix + * knobs + neutral chrome), so we supply the Nous *dark* seeds ourselves in the + * :root.dark block at the end of this file. */ @import '../../desktop/src/styles.css'; @@ -49,3 +51,38 @@ transparent 60% ); } + +/* + * Dark appearance — Nous dark seeds. + * + * The imported desktop :root.dark only flips the per-mode mix knobs + neutral + * chrome; the seed COLORS are normally painted at runtime by the desktop's + * applyTheme(). The installer has no theme runtime, so we mirror them here from + * apps/desktop/src/themes/presets.ts (nousTheme.darkColors). The whole + * --ui-* / --dt-* chain in the imported stylesheet derives from these seeds, so + * flipping them is enough — we only additionally override the few tokens + * applyTheme() sets inline that DON'T derive from a seed (primary-foreground on + * the cream accent, destructive). Unlayered on purpose so it wins over the + * imported @layer base :root light seeds. Keep in sync with nousTheme.darkColors + * if that palette is retuned. + */ +:root.dark { + color-scheme: dark; + + --theme-foreground: #ffe6cb; + --theme-primary: #ffe6cb; + --theme-secondary: #1b45a4; + --theme-accent-soft: #1540b1; + --theme-midground: #0053fd; + --theme-warm: #ffe6cb; + --theme-background-seed: #0d2f86; + --theme-sidebar-seed: #09286f; + --theme-card-seed: #12378f; + --theme-elevated-seed: #123a96; + --theme-bubble-seed: #143b91; + + /* Non-derived shadcn tokens applyTheme() paints inline (Nous dark values). */ + --dt-primary-foreground: #0d2f86; + --dt-destructive: #c0473a; + --dt-destructive-foreground: #fef2f2; +} diff --git a/apps/bootstrap-installer/src/theme.ts b/apps/bootstrap-installer/src/theme.ts new file mode 100644 index 00000000000..ed1fd3f21fe --- /dev/null +++ b/apps/bootstrap-installer/src/theme.ts @@ -0,0 +1,51 @@ +import { getCurrentWindow, type Theme } from '@tauri-apps/api/window' + +/* + * OS appearance follower. + * + * The installer ships no in-app theme switcher, so it tracks the system the + * way the desktop overlays do. Two Tauri realities shape this: + * + * 1. The strict `script-src 'self'` CSP (tauri.conf.json) forbids an inline + * pre-paint