Files
hermes-agent/apps/desktop/src/app/updates-overlay.tsx
T
brooklyn! f033b7dbfb feat(desktop): unified overlay design system, BrandMark & onboarding redesign (#40708)
* fix(desktop): unify dialog/overlay buttons on shared Button component

Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.

* feat(desktop): add dev-only dialog gallery for auditing overlays

A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.

* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)

The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.

* fix(desktop): stop overriding button icon size to size-4

Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.

* feat(desktop): add BrandMark, use it in the updates overlay hero

New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.

NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.

* feat(desktop): add real logo.png asset, render it white in BrandMark

logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.

* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding

Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.

* fix(desktop): bump updates-overlay BrandMark to size-16

* feat(desktop): use downscaled karb.webp in BrandMark

Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.

* feat(desktop): use nous-girl mark in BrandMark, invert in dark

Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.

* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)

The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.

* fix(desktop): give BrandMark an explicit white bg tile

* fix(desktop): use nous-girl.jpg directly in BrandMark

* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)

* style(desktop): bump nous light --theme-secondary to 14% blue

* fix(desktop): outline button is transparent, not chrome-filled

The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.

* fix(desktop): clean outline button — thin border, no shadow/fill

Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.

* fix(desktop): stop forcing tertiary bg on outline buttons

A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.

* style(desktop): unified overlay design system + restore #38631 flat-UI

Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.

- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
  NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
  shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
  profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
  profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers

* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs

- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
2026-06-06 16:32:47 -05:00

370 lines
11 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
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 { cn } from '@/lib/utils'
import {
$updateApply,
$updateChecking,
$updateOverlayOpen,
$updateStatus,
applyUpdates,
checkUpdates,
resetUpdateApplyState,
setUpdateOverlayOpen,
type UpdateApplyState
} from '@/store/updates'
function totalItems(groups: readonly CommitGroup[]) {
return groups.reduce((sum, g) => sum + g.items.length, 0)
}
export function UpdatesOverlay() {
const open = useStore($updateOverlayOpen)
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
const apply = useStore($updateApply)
useEffect(() => {
if (open && !status && !checking) {
void checkUpdates()
}
}, [checking, open, status])
const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'manual' | 'error' =
apply.stage === 'manual'
? 'manual'
: apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
return
}
setUpdateOverlayOpen(next)
if (!next && (apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual')) {
resetUpdateApplyState()
}
}
const handleInstall = () => {
void applyUpdates()
}
return (
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
)}
{phase === 'error' && (
<ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} />
)}
{phase === 'idle' && (
<IdleView
behind={behind}
checking={checking}
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void checkUpdates()}
status={status}
/>
)}
</DialogContent>
</Dialog>
)
}
function IdleView({
behind,
checking,
commits,
onInstall,
onLater,
onRetryCheck,
status
}: {
behind: number
checking: boolean
commits: readonly DesktopUpdateCommit[]
onInstall: () => void
onLater: () => void
onRetryCheck: () => void
status: DesktopUpdateStatus | null
}) {
const { t } = useI18n()
const u = t.updates
if (!status && checking) {
return (
<CenteredStatus icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />} title={u.checking} />
)
}
if (!status) {
return (
<CenteredStatus
action={
<Button onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
}
if (!status.supported) {
return (
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
)
}
if (status.error) {
return (
<CenteredStatus
action={
<Button disabled={checking} onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
}
body={u.connectionRetry}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
}
if (behind === 0) {
return (
<CenteredStatus
body={u.latestBody}
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title={u.allSetTitle}
/>
)
}
const groups = buildCommitChangelog(commits)
const shownItems = totalItems(groups)
const remaining = Math.max(0, behind - shownItems)
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{u.availableBody}
</DialogDescription>
</div>
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
<span className="leading-snug">{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
<div className="grid gap-2">
<Button className="font-semibold" onClick={onInstall} size="lg">
{u.updateNow}
</Button>
<Button className="font-medium" onClick={onLater} type="button" variant="text">
{u.maybeLater}
</Button>
</div>
{remaining > 0 && (
<p className="text-center text-xs text-muted-foreground">
{u.moreChanges(remaining)}
</p>
)}
</div>
)
}
function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
const { t } = useI18n()
const u = t.updates
const [copied, setCopied] = useState(false)
const handleCopy = () => {
void writeClipboardText(command).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<Terminal className="size-8 text-primary" />
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{u.manualBody}
</DialogDescription>
</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"
onClick={handleCopy}
type="button"
>
<code className="select-all font-mono text-sm text-foreground">
<span className="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>
</button>
<p className="text-center text-xs text-muted-foreground">
{u.manualPickedUp}
</p>
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
{u.done}
</Button>
</div>
)
}
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const { t } = useI18n()
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent)))
: null
return (
<div className="grid gap-5 px-6 pb-6 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<Loader className="size-16" label={label} type="lemniscate-bloom" />
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
{u.applyingBody}
</DialogDescription>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
percent === null && 'w-1/3 animate-pulse'
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
<p className="text-center text-xs text-muted-foreground">{u.applyingClose}</p>
</div>
)
}
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
const { t } = useI18n()
const u = t.updates
return (
<ErrorState
className="px-6 pb-6 pt-7 pr-8"
description={
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
{message || u.errorBody}
</DialogDescription>
}
title={
<DialogTitle className="text-center text-xl font-semibold tracking-tight">{u.errorTitle}</DialogTitle>
}
>
<Button className="font-semibold" onClick={onRetry} size="lg">
{u.tryAgain}
</Button>
<Button onClick={onDismiss} variant="text">
{u.notNow}
</Button>
</ErrorState>
)
}
function CenteredStatus({
action,
body,
icon,
title
}: {
action?: React.ReactNode
body?: string
icon: React.ReactNode
title: string
}) {
return (
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
{icon}
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}
</div>
{action && <div className="flex justify-center">{action}</div>}
</div>
)
}