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
This commit is contained in:
brooklyn!
2026-06-06 16:32:47 -05:00
committed by GitHub
parent c79e3fd0ba
commit f033b7dbfb
33 changed files with 1098 additions and 1144 deletions
@@ -38,17 +38,9 @@ export function UrlDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-5">
<DialogHeader className="flex-row items-center gap-3 sm:items-center">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div>
<DialogHeader>
<DialogTitle icon={Globe}>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -434,7 +434,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
{c.newCron}
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
<div className="divide-y divide-(--ui-stroke-tertiary)">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
+8 -12
View File
@@ -449,7 +449,7 @@ function PlatformDetail({
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
@@ -477,17 +477,13 @@ function PlatformDetail({
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
@@ -1,7 +1,6 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
@@ -12,6 +11,7 @@ interface OverlaySearchInputProps {
value: string
}
// Borderless underline search — matches the tools/skills page (PageSearchShell).
export function OverlaySearchInput({
containerClassName,
inputRef,
@@ -22,11 +22,7 @@ export function OverlaySearchInput({
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
containerClassName={containerClassName}
inputRef={inputRef}
loading={loading}
onChange={onChange}
+51 -96
View File
@@ -30,10 +30,8 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -41,30 +39,20 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps extends React.ComponentProps<'section'> {
interface ProfilesViewProps {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({
onClose,
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
export function ProfilesView({ onClose }: ProfilesViewProps) {
const { t } = useI18n()
const p = t.profiles
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
@@ -77,8 +65,6 @@ export function ProfilesView({
})
} catch (err) {
notifyError(err, p.failedLoad)
} finally {
setRefreshing(false)
}
}, [p])
@@ -88,24 +74,6 @@ export function ProfilesView({
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? p.refreshing : p.refresh,
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [p, refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@@ -172,64 +140,54 @@ export function ProfilesView({
return (
<OverlayView closeLabel={p.close} onClose={onClose}>
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">{p.title}</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? p.count(profiles.length) : ''}
</span>
</header>
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<Button
className="mb-1 w-full justify-start gap-2"
onClick={() => setCreateOpen(true)}
size="sm"
variant="text"
>
<Codicon name="add" />
{p.newProfile}
</Button>
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
)}
</OverlaySidebar>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Codicon name="add" />
{p.newProfile}
</Button>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</li>
)}
</ul>
</aside>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
@@ -261,7 +219,6 @@ export function ProfilesView({
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</OverlayView>
)
}
@@ -273,7 +230,7 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
return (
<button
className={cn(
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
onClick={onSelect}
@@ -368,7 +325,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<DetailRow label={p.modelLabel}>
{profile.model ? (
<>
@@ -475,9 +432,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
{p.loadingSoul}
</div>
<PageLoader className="min-h-44" label={p.loadingSoul} />
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@@ -92,9 +93,7 @@ export function AboutSettings() {
return (
<SettingsContent>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-8" />
</span>
<BrandMark className="size-16" />
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
@@ -10,7 +11,7 @@ import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { Pill, SectionHeading, SettingsContent } from './primitives'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -56,169 +57,105 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
{ id: 'product', label: a.product },
{ id: 'technical', label: a.technical }
] as const
return (
<SettingsContent>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
title={t.language.label}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
below={
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
}
description={a.themeDesc}
title={a.themeTitle}
wide
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={toolOptions}
value={toolViewMode}
/>
}
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
</div>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<LanguageSwitcher />
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.colorMode}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
</div>
<Pill>{t.settings.modeOptions[mode].label}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
const active = mode === id
const copy = t.settings.modeOptions[id]
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
</section>
</div>
</SettingsContent>
)
@@ -96,7 +96,7 @@ export function KeyField({
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
@@ -106,9 +106,10 @@ export function KeyField({
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
className="text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="inline"
type="button"
variant="text"
>
@@ -535,13 +535,13 @@ export function GatewaySettings() {
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
@@ -591,14 +591,14 @@ export function GatewaySettings() {
size="sm"
variant="text"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
@@ -607,7 +607,7 @@ export function GatewaySettings() {
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<FileText className="size-4" />
<FileText />
{g.openLogs}
</Button>
}
@@ -111,8 +111,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
className="text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
size="inline"
type="button"
variant="textStrong"
>
@@ -143,8 +144,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
className="py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
size="inline"
type="button"
variant="text"
>
@@ -111,13 +111,15 @@ export function GatewayMenuPanel({
</Tip>
))}
</ul>
<button
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
<Button
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
onClick={onOpenSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</button>
</Button>
</div>
)}
+4 -10
View File
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { useI18n } from '@/i18n'
@@ -56,7 +57,7 @@ export function KeybindPanel() {
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-[0_20px_48px_-24px_rgba(0,0,0,0.55)] duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
@@ -124,14 +125,10 @@ function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: ()
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<button
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.72rem] font-medium text-muted-foreground transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClick}
type="button"
>
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
<Codicon name={icon} size="0.8125rem" />
{label}
</button>
</Button>
)
}
@@ -152,8 +149,6 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
return (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
{/* Mirrors the reset button's footprint on the right so rows stay uniform. */}
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
@@ -211,7 +206,6 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
+34 -31
View File
@@ -15,6 +15,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -191,32 +192,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
))}
</>
) : undefined
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
@@ -236,21 +227,33 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleSkills.length === 0 ? (
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -276,7 +279,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleToolsets.length === 0 ? (
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : (
@@ -284,7 +287,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
+14 -22
View File
@@ -1,14 +1,16 @@
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 { ErrorState } from '@/components/ui/error-state'
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, Loader2, Sparkles, Terminal } from '@/lib/icons'
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$updateApply,
@@ -119,7 +121,7 @@ function IdleView({
if (!status && checking) {
return (
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={u.checking} />
<CenteredStatus icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />} title={u.checking} />
)
}
@@ -131,7 +133,7 @@ function IdleView({
{u.tryAgain}
</Button>
}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
@@ -156,7 +158,7 @@ function IdleView({
</Button>
}
body={u.connectionRetry}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
@@ -179,9 +181,7 @@ function IdleView({
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">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-7" />
</span>
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -209,13 +209,9 @@ function IdleView({
<Button className="font-semibold" onClick={onInstall} size="lg">
{u.updateNow}
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onLater}
type="button"
>
<Button className="font-medium" onClick={onLater} type="button" variant="text">
{u.maybeLater}
</button>
</Button>
</div>
{remaining > 0 && (
@@ -242,9 +238,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
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">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Terminal className="size-7" />
</span>
<Terminal className="size-8 text-primary" />
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -280,7 +274,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
{u.manualPickedUp}
</p>
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
{u.done}
</Button>
</div>
@@ -300,9 +294,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
return (
<div className="grid gap-5 px-6 pb-6 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Loader2 className="size-7 animate-spin" />
</span>
<Loader className="size-16" label={label} type="lemniscate-bloom" />
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -365,7 +357,7 @@ function CenteredStatus({
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">
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span>
{icon}
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}