* 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
220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
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'
|
|
import {
|
|
KEYBIND_ACTIONS,
|
|
KEYBIND_CATEGORIES,
|
|
KEYBIND_PANEL_ACTION,
|
|
KEYBIND_READONLY,
|
|
type KeybindActionMeta,
|
|
type KeybindReadonly
|
|
} from '@/lib/keybinds/actions'
|
|
import { formatCombo } from '@/lib/keybinds/combo'
|
|
import { arraysEqual } from '@/lib/storage'
|
|
import {
|
|
$bindings,
|
|
$capture,
|
|
$keybindPanelOpen,
|
|
beginCapture,
|
|
closeKeybindPanel,
|
|
conflictsFor,
|
|
endCapture,
|
|
resetAllBindings,
|
|
resetBinding
|
|
} from '@/store/keybinds'
|
|
|
|
// The full hotkey map. Quiet popover, click a row's chip to rebind.
|
|
export function KeybindPanel() {
|
|
const { t } = useI18n()
|
|
const open = useStore($keybindPanelOpen)
|
|
const bindings = useStore($bindings)
|
|
const k = t.keybinds
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
|
|
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
|
|
|
const toggleCategory = (category: string) =>
|
|
setCollapsed(prev => {
|
|
const next = new Set(prev)
|
|
|
|
if (next.has(category)) {
|
|
next.delete(category)
|
|
} else {
|
|
next.add(category)
|
|
}
|
|
|
|
return next
|
|
})
|
|
|
|
return (
|
|
<DialogPrimitive.Root onOpenChange={next => !next && closeKeybindPanel()} open={open}>
|
|
<DialogPrimitive.Portal>
|
|
<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-(--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">
|
|
<div className="min-w-0">
|
|
<DialogPrimitive.Title className="text-sm font-semibold text-foreground">{k.title}</DialogPrimitive.Title>
|
|
<DialogPrimitive.Description className="mt-0.5 text-[0.72rem] text-muted-foreground">
|
|
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
|
|
</DialogPrimitive.Description>
|
|
</div>
|
|
<HeaderButton icon="discard" label={k.resetAll} onClick={resetAllBindings} />
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
|
|
{KEYBIND_CATEGORIES.map(category => {
|
|
const actions = KEYBIND_ACTIONS.filter(
|
|
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
|
)
|
|
|
|
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
|
|
|
|
if (actions.length === 0 && readonly.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const sectionOpen = !collapsed.has(category)
|
|
|
|
return (
|
|
<section key={category}>
|
|
<CategoryHeader
|
|
label={k.categories[category] ?? category}
|
|
onToggle={() => toggleCategory(category)}
|
|
open={sectionOpen}
|
|
/>
|
|
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
|
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
</DialogPrimitive.Root>
|
|
)
|
|
}
|
|
|
|
// Collapsible category header — chevron fades in on hover, rotates when open
|
|
// (matches the sessions sidebar section pattern).
|
|
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
|
return (
|
|
<button
|
|
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
|
onClick={onToggle}
|
|
type="button"
|
|
>
|
|
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
|
<DisclosureCaret
|
|
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
|
open={open}
|
|
size="0.6875rem"
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
|
|
return (
|
|
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
|
|
<Codicon name={icon} size="0.8125rem" />
|
|
{label}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
|
const { t } = useI18n()
|
|
const k = t.keybinds
|
|
const bindings = useStore($bindings)
|
|
const capture = useStore($capture)
|
|
|
|
const combos = bindings[action.id] ?? []
|
|
const capturing = capture === action.id
|
|
const label = k.actions[action.id] ?? action.id
|
|
const isDefault = arraysEqual(combos, [...action.defaults])
|
|
|
|
const conflict = combos
|
|
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
|
|
.find(Boolean)
|
|
|
|
return (
|
|
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
|
|
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
|
|
|
|
{conflict && (
|
|
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
|
|
<Codicon name="warning" size="0.8125rem" />
|
|
</span>
|
|
)}
|
|
|
|
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
|
|
<button
|
|
aria-label={k.rebind}
|
|
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
|
|
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
|
|
title={k.rebind}
|
|
type="button"
|
|
>
|
|
{capturing ? (
|
|
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
|
|
) : combos.length > 0 ? (
|
|
combos.map(combo => (
|
|
<span className="kbd-cap" key={combo}>
|
|
{formatCombo(combo)}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Reset only shows once a binding diverges from its default; the spacer
|
|
holds the column otherwise so rows stay aligned. */}
|
|
{isDefault ? (
|
|
<span aria-hidden className="size-6 shrink-0" />
|
|
) : (
|
|
<button
|
|
aria-label={k.reset}
|
|
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
|
|
onClick={() => resetBinding(action.id)}
|
|
title={k.reset}
|
|
type="button"
|
|
>
|
|
<Codicon name="discard" size="0.8125rem" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
|
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
|
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
|
const { t } = useI18n()
|
|
const k = t.keybinds
|
|
const label = k.actions[shortcut.id] ?? shortcut.id
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
|
<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 => (
|
|
<span className="kbd-cap" key={key}>
|
|
{formatCombo(key)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span aria-hidden className="size-6 shrink-0" />
|
|
</div>
|
|
)
|
|
}
|