Compare commits

...

5 Commits

Author SHA1 Message Date
Austin Pickett
17b5acbdc9 fix(web): confirm sidebar Update Hermes before running
Match the Restart Gateway flow with a confirm dialog that fetches cached
update metadata so users see commit-behind context before applying.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:17:10 -04:00
Austin Pickett
2fbd2c48ae fix(web): confirm sidebar gateway restart and use DS checkboxes
Prompt before restarting from the sidebar system menu, and replace native
checkboxes on the System page with the design-system Checkbox component.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:15:28 -04:00
Austin Pickett
30f7816cd1 fix(web): polish dashboard sidebar chrome and model card menus
Use momentum easing for sidebar transitions, switch sidebar typography to
sans-serif, replace the profile native select with the DS Select, and stop
clipping the Models page Use-as dropdown inside model cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:09:50 -04:00
Austin Pickett
e996673bec fix(web): theme terminal foreground and restore backdrop plugin slot
Make Nous Blue terminal text readable without the inversion layer, re-mount
the backdrop plugin slot, and drop unused backdrop CSS vars from theme apply.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 08:46:37 -04:00
Austin Pickett
36ddca4b50 fix(web): remove marketing backdrop stack for lighter dashboard shell
Drop the CSS lens overlay (blend modes, noise, inversion) and backdrop-blur
from the ops dashboard so compositing no longer competes with xterm on /chat.
Use flat theme backgrounds and direct Nous Blue palette colors instead of
FG-inversion authoring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 14:38:02 -04:00
27 changed files with 278 additions and 390 deletions

View File

@@ -58,8 +58,8 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint";
@@ -100,7 +100,7 @@ import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
import type { StatusResponse, UpdateCheckResponse } from "@/lib/api";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
@@ -483,18 +483,23 @@ export default function App() {
<ProfileProvider>
<div
data-layout-variant={layoutVariant}
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-background-base text-text-primary antialiased"
>
<SelectionSwitcher />
<Backdrop />
<PluginSlot name="backdrop" />
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
>
<PluginSlot name="backdrop" />
</div>
<header
className={cn(
"lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
"flex items-center gap-2 px-4 py-2",
"border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm",
"bg-background-base",
)}
style={{
background: "var(--component-header-background)",
@@ -514,10 +519,7 @@ export default function App() {
<Menu />
</Button>
<Typography
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
<Typography className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground">
{t.app.brand}
</Typography>
</header>
@@ -529,7 +531,7 @@ export default function App() {
onClick={closeMobile}
className={cn(
"lg:hidden fixed inset-0 z-40 p-0 block",
"bg-black/60 backdrop-blur-sm",
"bg-black/70",
)}
/>
)}
@@ -543,13 +545,13 @@ export default function App() {
id="app-sidebar"
aria-label={t.app.navigation}
className={cn(
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col font-sans",
"border-r border-current/20",
"bg-background-base/95 backdrop-blur-sm",
"transition-[transform] duration-200 ease-out",
"bg-background-base",
"transition-[transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
"lg:transition-[width] lg:duration-300 lg:ease-[cubic-bezier(0.23,1,0.32,1)]",
collapsed && "lg:w-14",
)}
style={{
@@ -573,10 +575,7 @@ export default function App() {
>
<PluginSlot name="header-left" />
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
style={{ mixBlendMode: "plus-lighter" }}
>
<Typography className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase">
Hermes
<br />
Agent
@@ -638,7 +637,7 @@ export default function App() {
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
"font-sans text-display text-xs tracking-[0.12em] text-text-tertiary",
isDesktopCollapsed && "lg:hidden",
)}
id="hermes-sidebar-plugin-nav-heading"
@@ -845,7 +844,7 @@ function SidebarNavLink({
cn(
"group/nav relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"font-sans text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
@@ -879,7 +878,6 @@ function SidebarNavLink({
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
@@ -904,6 +902,47 @@ function SidebarSystemActions({
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
useSystemActions();
const canUpdateHermes = status?.can_update_hermes === true;
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
const [updateConfirmInfo, setUpdateConfirmInfo] =
useState<UpdateCheckResponse | null>(null);
const [updateConfirmChecking, setUpdateConfirmChecking] = useState(false);
useEffect(() => {
if (!updateConfirmOpen) {
setUpdateConfirmInfo(null);
return;
}
let cancelled = false;
setUpdateConfirmChecking(true);
api
.checkHermesUpdate(false)
.then((info) => {
if (!cancelled) setUpdateConfirmInfo(info);
})
.catch(() => {
if (!cancelled) setUpdateConfirmInfo(null);
})
.finally(() => {
if (!cancelled) setUpdateConfirmChecking(false);
});
return () => {
cancelled = true;
};
}, [updateConfirmOpen]);
const updateConfirmDescription = useMemo(() => {
if (updateConfirmInfo?.behind && updateConfirmInfo.behind > 0) {
const cmd = updateConfirmInfo.update_command;
const n = updateConfirmInfo.behind;
return `This will run 'hermes update' (${cmd}) and pull ${n} new commit${n === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.`;
}
const cmd = updateConfirmInfo?.update_command ?? "hermes update";
return (
t.status.updateHermesConfirmMessage ??
`This will run 'hermes update' (${cmd}) and restart the gateway when it finishes.`
);
}, [t.status.updateHermesConfirmMessage, updateConfirmInfo]);
const items: SystemActionItem[] = [
{
@@ -926,12 +965,35 @@ function SidebarSystemActions({
const handleClick = (action: SystemAction) => {
if (isBusy) return;
if (action === "restart") {
setRestartConfirmOpen(true);
return;
}
if (action === "update") {
setUpdateConfirmOpen(true);
return;
}
void runAction(action);
navigate("/sessions");
onNavigate();
};
const confirmRestart = () => {
setRestartConfirmOpen(false);
void runAction("restart");
navigate("/sessions");
onNavigate();
};
const confirmUpdate = () => {
setUpdateConfirmOpen(false);
void runAction("update");
navigate("/sessions");
onNavigate();
};
return (
<>
<div
className={cn(
"shrink-0 flex flex-col",
@@ -942,7 +1004,7 @@ function SidebarSystemActions({
<span
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
"font-sans text-display text-xs tracking-[0.12em] text-text-tertiary",
collapsed && "lg:hidden",
)}
>
@@ -970,6 +1032,36 @@ function SidebarSystemActions({
))}
</ul>
</div>
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.restartGateway}
description={
t.status.restartGatewayConfirmMessage ??
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward."
}
loading={pendingAction === "restart"}
onCancel={() => setRestartConfirmOpen(false)}
onConfirm={confirmRestart}
open={restartConfirmOpen}
title={
t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`
}
/>
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.updateHermesConfirmNow ?? "Update now"}
description={
updateConfirmChecking ? t.common.loading : updateConfirmDescription
}
loading={pendingAction === "update" || updateConfirmChecking}
onCancel={() => setUpdateConfirmOpen(false)}
onConfirm={confirmUpdate}
open={updateConfirmOpen}
title={t.status.updateHermesConfirmTitle ?? `${t.status.updateHermes}?`}
/>
</>
);
}
@@ -1012,7 +1104,7 @@ function SystemActionButton({
className={cn(
"group/action relative flex w-full items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display text-xs tracking-[0.1em]",
"font-sans text-display text-xs tracking-[0.1em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
busy
@@ -1050,7 +1142,6 @@ function SystemActionButton({
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
@@ -1186,8 +1277,8 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
className={cn(
"fixed z-[100] pointer-events-none",
"px-2 py-1",
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
"bg-background-base border border-current/20 shadow-lg",
"font-sans text-display text-xs tracking-[0.1em] text-midground uppercase",
)}
style={{
top: rect.top + rect.height / 2,

View File

@@ -1,137 +0,0 @@
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
/**
* Replicates the visual layer stack of `<Overlays dark />` from
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
*
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
* truth. Defaults match LENS_0 (the Hermes teal dark preset); the deep canvas
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
* `--component-backdrop-bg-blend-mode` (default `difference`).
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
* light theme keep `difference` here — the canvas is flipped by
* the z-200 FG inversion layer, not by changing this blend mode.
* The CSS var is exposed as a hook so future presets can override
* it (e.g. `multiply` to paint the bg as-is before inversion)
* without touching this component.
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
* layer that flips the dashboard into "light mode" for inverted
* themes; for normal dark themes its alpha is 0 so it's a no-op.
* Deliberately placed above every UI overlay z-index (modals,
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
* elements get inverted along with the rest of the page instead
* of painting with pre-inversion colors on top of the lens.
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier. Sits above the inversion
* layer by design so the grain is not flipped.
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
* in that case so low-power / accessibility-conscious sessions stay crisp,
* mirroring the DS `<Noise />` component's own opt-out.
*/
export function Backdrop() {
const gpuTier = useGpuTier();
return (
<>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={
{
backgroundColor: "var(--background-base)",
mixBlendMode:
"var(--component-backdrop-bg-blend-mode, difference)",
} as unknown as React.CSSProperties
}
/>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[2]"
style={
{
// Themes can override the filler background by setting
// `assets.bg` — the <img> hides itself when a CSS bg is set
// so the two don't double-darken. CSS var fallbacks keep the
// default behaviour unchanged when no theme customises these.
mixBlendMode:
"var(--component-backdrop-filler-blend-mode, difference)",
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
backgroundImage: "var(--theme-asset-bg)",
backgroundSize: "var(--component-backdrop-background-size, cover)",
backgroundPosition:
"var(--component-backdrop-background-position, center)",
} as unknown as React.CSSProperties
}
>
<img
alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
fetchPriority="low"
src={fillerBgUrl}
/>
</div>
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[99]"
style={{
background:
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
mixBlendMode: "lighten",
opacity: 0.22,
}}
/>
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
+ fgBlend: 'difference' in `design-language/src/ui/components/
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
the layer is fully transparent and contributes nothing; with
alpha 1 + opaque white it inverts the entire stack below it,
producing the LENS_5I "light mode" look without altering any
downstream component code.
z-200 (not 100) so it sits above every portaled UI overlay —
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
z-[100], which is what the DS Lens picks too; portals append
at the end of <body>, so equal z-index + later DOM order means
they'd paint on top of the inversion and skip the flip. Inlined
z-index for the same reason the DS does it — Tailwind's JIT
scan sometimes drops non-default z utilities. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0"
style={{
backgroundColor: "var(--foreground)",
mixBlendMode: "difference",
zIndex: 200,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[201]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
backgroundSize: "512px 512px",
mixBlendMode: "color-dodge",
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
}}
/>
)}
</>
);
}

View File

@@ -66,7 +66,7 @@ export function ConfirmDialog({
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 p-4"
>
<div
ref={dialogRef}

View File

@@ -78,7 +78,6 @@ export function LanguageSwitcher({ collapsed = false, dropUp = false }: Language
>
<span className="inline-flex items-center gap-1.5">
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{locale === "en" ? "EN" : current.name}
@@ -151,7 +150,7 @@ function LanguageSwitcherOptions({
aria-selected={selected}
className={cn(
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
"font-mondwest text-display text-xs tracking-[0.08em]",
"font-sans text-display text-xs tracking-[0.08em]",
"hover:bg-accent hover:text-accent-foreground transition-colors",
selected ? "font-semibold text-foreground" : "text-muted-foreground",
)}

View File

@@ -288,7 +288,7 @@ export function ModelPickerDialog(props: Props) {
// Toast.tsx for the same pattern.
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"

View File

@@ -164,7 +164,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={handleBackdrop}
role="dialog"
aria-modal="true"

View File

@@ -1,4 +1,9 @@
import { useMemo } from "react";
import { Users } from "lucide-react";
import {
Select,
SelectOption,
} from "@nous-research/ui/ui/components/select";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
@@ -10,14 +15,24 @@ import { cn } from "@/lib/utils";
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
export function ProfileSwitcher({ collapsed }: ProfileSwitcherProps) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
const currentDashboardLabel = useMemo(
() =>
(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
),
[currentProfile, t.app.currentProfileOption],
);
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
const managingLabel = t.app.managingProfile ?? "Managing profile";
return (
<div
@@ -25,7 +40,7 @@ export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={t.app.managingProfile ?? "Managing profile"}
title={managingLabel}
>
<Users
className={cn(
@@ -33,35 +48,38 @@ export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<select
aria-label={t.app.managingProfile ?? "Managing profile"}
<Select
className={cn(
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
isOther
? "border-amber-500/50 text-amber-300"
: "border-border text-text-secondary",
"min-w-0 flex-1",
collapsed && "lg:hidden",
"[&_button]:h-7 [&_button]:border-border [&_button]:bg-background [&_button]:px-2 [&_button]:text-xs",
"[&_button]:font-sans [&_button]:normal-case [&_button]:tracking-normal",
"[&_[role=listbox]>div]:font-sans [&_[role=listbox]>div]:text-xs",
"[&_[role=listbox]>div]:normal-case [&_[role=listbox]>div]:tracking-normal",
isOther &&
"[&_button]:border-amber-500/50 [&_button]:text-amber-300",
)}
id="hermes-profile-switcher"
onValueChange={setProfile}
value={profile}
onChange={(e) => setProfile(e.target.value)}
>
<option value="">
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
<SelectOption value="">{currentDashboardLabel}</SelectOption>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<option key={name} value={name}>
<SelectOption key={name} value={name}>
{name}
</option>
</SelectOption>
))}
</select>
{collapsed && (
<span className="sr-only">{managed}</span>
)}
</Select>
{collapsed && <span className="sr-only">{managed}</span>}
</div>
);
}
interface ProfileSwitcherProps {
collapsed?: boolean;
}

View File

@@ -25,11 +25,10 @@ export function SidebarFooter({ status }: SidebarFooterProps) {
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-mondwest text-display text-xs tracking-[0.12em] text-midground",
"font-sans text-display text-xs tracking-[0.12em] text-midground",
"transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</a>

View File

@@ -31,7 +31,7 @@ export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
"focus-visible:ring-inset",
)}
>
<div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
<div className="flex flex-col gap-1 font-sans text-xs leading-snug tracking-[0.08em]">
<p className="break-words">
<span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>

View File

@@ -81,7 +81,6 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
{!collapsed && (
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
@@ -121,7 +120,7 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
aria-label={sheetTitle}
className={cn(
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"border border-current/20 bg-background-base/95",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
@@ -134,7 +133,6 @@ export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitch
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
@@ -192,7 +190,6 @@ function ThemeSwitcherOptions({
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-display text-xs tracking-wide"
>
{th.label}
@@ -235,7 +232,6 @@ function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
<span className="inline-flex items-center gap-1.5">
<Type className="h-3 w-3 text-text-tertiary" />
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{t.theme?.fontTitle ?? "Font"}
@@ -317,12 +313,6 @@ function FontSection({ fontChoices, fontId, setFont }: FontSectionProps) {
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
// Inverted themes (Nous Blue / future lens themes) author their palette
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
// difference layer flips the page. The picker can't replay that math
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
// mirrors the on-screen result. Falls back to the raw palette hexes for
// every other theme so existing dark-theme swatches are untouched.
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,

View File

@@ -214,7 +214,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}

View File

@@ -55,7 +55,7 @@ export function PageHeaderProvider({
className={cn(
"z-1 w-full shrink-0",
"box-border border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm",
"bg-background-base",
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
)}
@@ -88,7 +88,6 @@ export function PageHeaderProvider({
? "shrink truncate"
: "truncate",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{displayTitle}
</h1>

View File

@@ -121,6 +121,9 @@ export const en: Translations = {
platformError: "error",
recentSessions: "Recent Sessions",
restartGateway: "Restart Gateway",
restartGatewayConfirmMessage:
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward.",
restartGatewayConfirmTitle: "Restart gateway?",
restartingGateway: "Restarting gateway…",
running: "Running",
runningRemote: "Running (remote)",
@@ -129,6 +132,10 @@ export const en: Translations = {
startedInBackground: "Started in background — check logs for progress",
stopped: "Stopped",
updateHermes: "Update Hermes",
updateHermesConfirmMessage:
"This runs hermes update and restarts the gateway when it finishes. Active sessions keep their prompt cache until then.",
updateHermesConfirmNow: "Update now",
updateHermesConfirmTitle: "Update Hermes?",
updatingHermes: "Updating Hermes…",
waitingForOutput: "Waiting for output…",
},

View File

@@ -139,6 +139,8 @@ export interface Translations {
activeSessions: string;
recentSessions: string;
restartGateway: string;
restartGatewayConfirmMessage?: string;
restartGatewayConfirmTitle?: string;
restartingGateway: string;
running: string;
runningRemote: string;
@@ -147,6 +149,9 @@ export interface Translations {
startedInBackground: string;
stopped: string;
updateHermes: string;
updateHermesConfirmMessage?: string;
updateHermesConfirmNow?: string;
updateHermesConfirmTitle?: string;
updatingHermes: string;
waitingForOutput: string;
};

View File

@@ -43,10 +43,8 @@
}
/* ------------------------------------------------------------------ */
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
/* canonical Hermes palette is the default — teal canvas + cream */
/* accent — without relying on leva/gsap at runtime. */
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) palette applied
statically as the default dashboard theme. */
/* ------------------------------------------------------------------ */
:root {
@@ -63,10 +61,6 @@
--background-base: #041c1c;
--background-alpha: 1;
/* Consumed by <Backdrop />; also theme-switchable. */
--warm-glow: rgba(255, 189, 56, 0.35);
--noise-opacity-mul: 1;
/* Typography tokens — rewritten by ThemeProvider. Defaults match the
system stack so themes that don't override look native. */
--theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
@@ -228,11 +222,6 @@ code { font-size: 0.875rem; }
display: none;
}
/* Plus-lighter blend used by logos/titles for a subtle glow. */
.blend-lighter {
mix-blend-mode: plus-lighter;
}
/* System UI-monospace stack — distinct from `font-courier` (Courier
Prime), used for dense data readouts where the display font would
break the grid. Routes through the theme's mono stack so themes
@@ -256,14 +245,3 @@ code { font-size: 0.875rem; }
2px 2px;
}
/* When a theme provides `assets.bg`, the backdrop's <div> renders it as
a CSS background; the default filler <img> is hidden to prevent
double-compositing. Unset → initial → empty, so the :not() selector
matches and the default image stays visible. */
:root:not([style*="--theme-asset-bg:"]) .theme-default-filler {
display: block;
}
:root[style*="--theme-asset-bg:"] .theme-default-filler {
display: none;
}

View File

@@ -326,7 +326,7 @@ export default function ChannelsPage() {
{editing && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
role="dialog"
aria-modal="true"

View File

@@ -80,14 +80,19 @@ function generateChannelId(scope?: string): string {
// with cream foreground — we intentionally don't pick monokai or a loud
// theme, because the TUI's skin engine already paints the content; the
// terminal chrome just needs to sit quietly inside the dashboard.
// `background` is omitted here — it's supplied dynamically from the active
// theme's `terminalBackground` field so users can control it via YAML themes.
const TERMINAL_THEME_STATIC = {
foreground: "#f0e6d2",
cursor: "#f0e6d2",
cursorAccent: "#0d2626",
selectionBackground: "#f0e6d244",
};
const DEFAULT_TERMINAL_BACKGROUND = "#000000";
const DEFAULT_TERMINAL_FOREGROUND = "#f0e6d2";
function buildTerminalTheme(background: string, foreground: string) {
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground:
foreground.length === 7 ? `${foreground}44` : foreground,
};
}
/**
* CSS width for xterm font tiers.
@@ -215,10 +220,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
);
const { theme } = useTheme();
const terminalBg = theme.terminalBackground ?? "#000000";
const terminalBg = theme.terminalBackground ?? DEFAULT_TERMINAL_BACKGROUND;
const terminalFg = theme.terminalForeground ?? DEFAULT_TERMINAL_FOREGROUND;
const terminalTheme = useMemo(
() => ({ ...TERMINAL_THEME_STATIC, background: terminalBg }),
[terminalBg],
() => buildTerminalTheme(terminalBg, terminalFg),
[terminalBg, terminalFg],
);
// The dashboard keeps ChatPage mounted persistently so the PTY survives tab
@@ -911,12 +917,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
}, [isActive]);
// Keep the live xterm theme in sync when the active theme's terminal
// background changes (e.g. user switches to a custom YAML theme mid-session).
// colors change (e.g. user switches to a custom YAML theme mid-session).
useEffect(() => {
const term = termRef.current;
if (!term) return;
term.options.theme = { ...TERMINAL_THEME_STATIC, background: terminalBg };
}, [terminalBg]);
term.options.theme = terminalTheme;
}, [terminalTheme]);
// Layout:
// outer flex column — sits inside the dashboard's content area
@@ -946,7 +952,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
onClick={closeMobilePanel}
className={cn(
"fixed inset-0 z-[55] p-0 block",
"bg-black/60 backdrop-blur-sm",
"bg-black/60",
)}
/>
)}
@@ -958,7 +964,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
className={cn(
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
"border-l border-current/20 text-midground",
"bg-background-base/95 backdrop-blur-sm",
"bg-background-base/95",
"transition-transform duration-200 ease-out",
"[background:var(--component-sidebar-background)]",
"[clip-path:var(--component-sidebar-clip-path)]",
@@ -976,7 +982,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
<Typography
mondwest
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.modelToolsSheetTitle}
<br />
@@ -1051,7 +1056,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
Offer an in-place restart so the user never has to refresh the
whole page to get a working chat back. */}
{sessionEnded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60 backdrop-blur-sm">
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60">
<div className="text-sm tracking-wide text-white/80">
Session ended.
</div>
@@ -1074,13 +1079,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
"absolute z-10",
"normal-case tracking-normal font-normal",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"bg-black/20",
"opacity-70 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME_STATIC.foreground }}
style={{ color: terminalFg }}
>
<span className="inline-flex items-center gap-1.5">
<Copy className="h-3 w-3 shrink-0" />

View File

@@ -546,7 +546,7 @@ export default function CronPage() {
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
role="dialog"
aria-modal="true"
@@ -667,7 +667,7 @@ export default function CronPage() {
{editJob && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && setEditJob(null)}
role="dialog"
aria-modal="true"

View File

@@ -334,7 +334,7 @@ export default function McpPage() {
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) =>
e.target === e.currentTarget && setCreateModalOpen(false)
}
@@ -455,7 +455,7 @@ export default function McpPage() {
{installEntry && (
<div
ref={installModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) =>
e.target === e.currentTarget && setInstallEntry(null)
}

View File

@@ -268,7 +268,7 @@ function UseAsMenu({
}, [open]);
return (
<div className="relative" data-use-as-menu>
<div className={cn("relative", open && "z-20")} data-use-as-menu>
<Button
size="sm"
outlined
@@ -392,7 +392,7 @@ function ModelCard({
return (
<Card
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
className={cn("min-w-0 max-w-full", isMain && "ring-1 ring-primary/40")}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
@@ -575,7 +575,7 @@ function AuxiliaryTasksModal({
return (
<div
ref={modalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
@@ -779,7 +779,7 @@ function MoaModelsModal({
if (!preset) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4">
<Card className="max-h-[85vh] w-full max-w-2xl overflow-auto">
<CardHeader>
<CardTitle className="text-sm">Configure Mixture of Agents presets</CardTitle>

View File

@@ -804,7 +804,7 @@ export default function ProfilesPage() {
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) =>
e.target === e.currentTarget && setCreateModalOpen(false)
}
@@ -1231,7 +1231,7 @@ export default function ProfilesPage() {
{editorName && (
<div
ref={editorModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && closeEditor()}
role="dialog"
aria-modal="true"

View File

@@ -31,6 +31,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@@ -570,7 +571,7 @@ export default function SystemPage() {
{hookModalOpen && (
<div
ref={hookModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && setHookModalOpen(false)}
role="dialog"
aria-modal="true"
@@ -635,15 +636,21 @@ export default function SystemPage() {
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<div className="flex items-center gap-2.5">
<Checkbox
checked={hookApprove}
onChange={(e) => setHookApprove(e.target.checked)}
id="hook-approve"
onCheckedChange={(checked) => setHookApprove(checked === true)}
/>
Approve now (grant consent so it fires; otherwise it stays
configured but inactive)
</label>
<Label
className="cursor-pointer text-sm font-normal normal-case tracking-normal text-muted-foreground"
htmlFor="hook-approve"
>
Approve now (grant consent so it fires; otherwise it stays
configured but inactive)
</Label>
</div>
<p className="text-xs text-warning">
Shell hooks run arbitrary commands on this host. Only add scripts
you trust. Takes effect on the next gateway/session restart.
@@ -1097,16 +1104,21 @@ export default function SystemPage() {
</Button>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground select-none">
<input
type="checkbox"
className="accent-current"
<div className="flex items-center gap-2.5">
<Checkbox
checked={shareRedact}
disabled={sharing}
onChange={(e) => setShareRedact(e.target.checked)}
id="share-redact"
onCheckedChange={(checked) => setShareRedact(checked === true)}
/>
Redact credential-shaped tokens before upload (recommended)
</label>
<Label
className="cursor-pointer select-none text-xs font-normal normal-case tracking-normal text-muted-foreground"
htmlFor="share-redact"
>
Redact credential-shaped tokens before upload (recommended)
</Label>
</div>
{shareResult && (
<div className="flex flex-col gap-2 border-t border-border pt-3">

View File

@@ -303,7 +303,7 @@ export default function WebhooksPage() {
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
role="dialog"
aria-modal="true"

View File

@@ -19,7 +19,8 @@ import React, { Fragment, useEffect, useState } from "react";
* these in their manifest's `slots` field get wired in automatically.
*
* Shell-wide slots:
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
* - `backdrop` — optional full-viewport background decoration;
* mounted behind shell chrome at z-0
* - `header-left` — injected before the Hermes brand in the top bar
* - `header-right` — injected before the theme/language switchers
* - `header-banner` — injected below the top nav bar, full-width

View File

@@ -82,8 +82,6 @@ function paletteVars(palette: ThemePalette): Record<string, string> {
...layerVars("background", palette.background),
...layerVars("midground", palette.midground),
...layerVars("foreground", palette.foreground),
"--warm-glow": palette.warmGlow,
"--noise-opacity-mul": String(palette.noiseOpacity),
};
}
@@ -391,11 +389,15 @@ function applyTheme(theme: DashboardTheme) {
applyCustomCSS(theme.customCSS);
applyLayoutVariant(theme.layoutVariant);
// Terminal background — read by ChatPage via useTheme(); also available as CSS var.
// Terminal colors — read by ChatPage via useTheme(); also available as CSS vars.
root.style.setProperty(
"--theme-terminal-background",
theme.terminalBackground ?? "#000000",
);
root.style.setProperty(
"--theme-terminal-foreground",
theme.terminalForeground ?? "#f0e6d2",
);
// Re-assert the font override last: theme application just rewrote
// --theme-font-sans/-display, so an active override has to win again.

View File

@@ -184,98 +184,27 @@ export const roseTheme: DashboardTheme = {
},
};
/**
* Nous Blue — the inverted "light mode" Hermes look, ported from the
* LENS_5I overlay preset in `@nous-research/ui`.
*
* Unlike the other built-ins (which paint dark color directly on the
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
* that flips the entire stack below it. Authoring colors stay dark
* (`#170d02` brown background, `#FFAC02` orange midground), and the
* inversion converts them to their visual complements at paint time —
* the orange midground reads as #0053FD Nous-blue on screen, against a
* cream `#E8F2FD` canvas.
*
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
* so we leave the bg layer's blend mode at the `difference` default —
* `difference(#170d02, #000)` passes the bg through unchanged, and the
* subsequent FG-difference layer then inverts it to cream. Using
* `multiply` here would collapse the bg to pure black against the
* `bg-black` root and produce a plain-white canvas instead of the
* intended cream-blue.
*
* Source of truth for the palette: `design-language/src/ui/components/
* overlays/lens.ts` (LENS_5I export).
*/
/** Light mode — vivid Nous-blue accents on a cream canvas. */
export const nousBlueTheme: DashboardTheme = {
name: "nous-blue",
label: "Nous Blue",
description: "Light mode — vivid Nous-blue accents on cream canvas",
palette: {
background: { hex: "#170d02", alpha: 1 },
midground: { hex: "#FFAC02", alpha: 1 },
foreground: { hex: "#FFFFFF", alpha: 1 },
// Same warm-amber as nousnet-web's overlay glow; after the FG
// inversion it reads as a cool ultraviolet vignette in the top-left.
warmGlow: "rgba(255, 172, 2, 0.18)",
// Noise sits above the FG inversion and is NOT flipped, so a softer
// multiplier keeps it from speckling over the bright post-inversion
// canvas.
noiseOpacity: 0.4,
background: { hex: "#E8F2FD", alpha: 1 },
midground: { hex: "#0053FD", alpha: 1 },
foreground: { hex: "#170d02", alpha: 0 },
warmGlow: "rgba(0, 83, 253, 0.12)",
noiseOpacity: 0,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
// Inverted page: the embedded terminal is below the FG layer too, so
// a `#000000` source paints as visual white — i.e. a proper light-mode
// terminal pane. xterm picks lighter palette colors against the "black"
// canvas, which then read as dark text on screen post-inversion.
terminalBackground: "#000000",
componentStyles: {
backdrop: {
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
// gets amplified post-inversion: small variations against the deep
// `#170d02` source bg are barely visible, but those same variations
// against the bright `#E8F2FD` post-inversion canvas read as a
// heavy cloud/marble pattern — especially on near-empty pages
// (loading spinners, blank states). 0.02 keeps subtle grain
// without overwhelming the canvas.
fillerOpacity: "0.02",
},
},
// Pre-invert absolute-hex tokens so they read as their familiar colors
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
// Without these, the default destructive/success/warning tokens would
// appear as their unintuitive complements.
colorOverrides: {
destructive: "#04d3c9",
destructiveForeground: "#000000",
success: "#b5217f",
warning: "#0042c7",
},
// Pre-inverted data-series accents for the Analytics/Models token
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
// through the FG difference layer as dark navy + hot-coral on the
// bright Nous-blue canvas — the coral is the "red" users see for
// Output values without these overrides. Source → on-screen:
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
// Input keeps the cream source so it stays a neutral, low-contrast
// dark-blue against the cream canvas; output paints as the brand
// Nous-blue so the "primary" series in token-flow charts reads as
// the highlight color, matching the rest of the inverted UI chrome.
terminalBackground: "#f5f8fc",
terminalForeground: "#170d02",
seriesColors: {
inputTokenAccent: "#ffe6cb",
outputTokenAccent: "#ffac02",
inputTokenAccent: "#001934",
outputTokenAccent: "#0053fd",
},
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
// amber rgba) doesn't reflect what users see after the FG inversion,
// so we paint the post-inversion visual triplet directly:
// white → vivid Nous-blue → cream/light-blue
// matching the actual on-screen rendering of the theme.
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
swatchColors: ["#170d02", "#0053FD", "#E8F2FD"],
};
/**

View File

@@ -4,10 +4,9 @@
* Themes customise three orthogonal layers:
*
* 1. `palette` — the 3-layer color triplet (background/midground/
* foreground) + warm-glow + noise opacity. The
* design-system cascade in `src/index.css` derives
* every shadcn-compat token (card, muted, border,
* primary, etc.) from this triplet via `color-mix()`.
* foreground). Legacy `warmGlow` / `noiseOpacity`
* fields remain for theme YAML compat but are unused
* by the lightweight shell.
* 2. `typography` — font families, base font size, line height,
* letter spacing. An optional `fontUrl` is injected
* as `<link rel="stylesheet">` so self-hosted and
@@ -33,10 +32,9 @@ export interface ThemePalette {
/** Top-layer highlight. In LENS_0 this is white @ alpha 0 — invisible by
* default but still drives `--color-ring`-style accents. */
foreground: ThemeLayer;
/** Warm vignette color for <Backdrop />, as an rgba() string. */
/** Legacy palette field — kept for theme YAML compat. */
warmGlow: string;
/** Scalar multiplier (01.2) on the noise overlay. Lower for softer themes
* like Mono and Rosé, higher for grittier themes like Cyberpunk. */
/** Legacy palette field — kept for theme YAML compat. */
noiseOpacity: number;
}
@@ -79,12 +77,11 @@ export interface ThemeLayout {
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
/** Named hero/background assets a theme can populate. Each value is
* emitted as a CSS var (`--theme-asset-<name>`). The default shell
* consumes `bg` in `<Backdrop />` when present; other slots are
* plugin-facing — a cockpit sidebar plugin reads `--theme-asset-hero`
* to render its hero render without coupling to the theme name. */
* emitted as a CSS var (`--theme-asset-<name>`). Plugin slots and
* shell chrome may consume these via CSS. */
export interface ThemeAssets {
/** Full-viewport background image URL, injected under the noise layer. */
/** Full-viewport background image URL. Exposed as `--theme-asset-bg` for
* the `backdrop` plugin slot or theme `customCSS`. */
bg?: string;
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
hero?: string;
@@ -103,7 +100,7 @@ export interface ThemeAssets {
/** Component-style override buckets. Each bucket's entries become CSS
* vars (`--component-<bucket>-<kebab-property>`) that shell components
* (Card, Backdrop, App header/footer, etc.) read. Values are plain CSS
* (Card, App header/footer, etc.) read. Values are plain CSS
* strings — we don't parse them, so themes can use `clip-path`,
* `border-image`, `background`, `box-shadow`, and anything else CSS
* accepts. */
@@ -124,13 +121,7 @@ export interface ThemeComponentStyles {
* `--series-input-token` / `--series-output-token` CSS vars consumed
* inline by pages that render input-vs-output token flows. Themes can
* omit either field to inherit the default token defined in
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
*
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
* values so they read as their intended visual color after the FG
* difference layer flips them (`out = 255 channel`). E.g. to make
* output paint as Nous-blue `#0053FD` on screen, set
* `outputTokenAccent: "#FFAC02"` — the difference math reverses it. */
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output). */
export interface ThemeSeriesColors {
/** Input-tokens series accent (Analytics chart bars + table values). */
inputTokenAccent?: string;
@@ -181,18 +172,17 @@ export interface DashboardTheme {
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
/** Data-series accent colors for Analytics/Models token charts.
* See `ThemeSeriesColors` for inversion-aware values. */
/** Data-series accent colors for Analytics/Models token charts. */
seriesColors?: ThemeSeriesColors;
/** Explicit 3-color swatch override for the theme picker. Use when the
* palette's raw hex values don't reflect what users see on screen —
* e.g. inverted "lens" themes whose foreground-difference layer flips
* the authored colors to their visual complements. Order matches the
/** Explicit 3-color swatch override for the theme picker. Order matches the
* default swatch cells: [background, midground, warmGlow]. */
swatchColors?: [string, string, string];
/** Background color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#000000"` when absent. */
terminalBackground?: string;
/** Default text/cursor color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#f0e6d2"` when absent. */
terminalForeground?: string;
}
/**