This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit c16138208e32c2b650d3deefba39e1aed0b05874
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 13:04:31 2026 +0800

    feat(nav): put a search palette in the header
---
 web/src/App.tsx                               |  12 ++
 web/src/components/AgentGridCard.tsx          |   2 +-
 web/src/components/AuthenticityOverview.tsx   |   4 +-
 web/src/components/AuthenticityScore.tsx      |   8 +-
 web/src/components/OnboardingChecklist.tsx    |   8 +-
 web/src/components/ProviderAuditCard.tsx      |  25 +--
 web/src/components/shared/app-header.tsx      |   6 +-
 web/src/components/shared/command-palette.tsx | 229 ++++++++++++++++++++++++++
 web/src/components/shared/empty-state.tsx     |   2 +-
 web/src/components/shared/stat-card.tsx       |   2 +-
 web/src/components/ui/alert.tsx               |  10 +-
 web/src/components/ui/badge.tsx               |  14 +-
 web/src/components/ui/command.tsx             |  14 +-
 web/src/components/usage/usage-hero.tsx       |  23 +--
 web/src/index.css                             |  18 +-
 web/src/lib/authenticity.ts                   |  15 +-
 web/src/locales/en/data.json                  |   7 +
 web/src/locales/zh/data.json                  |   7 +
 18 files changed, 339 insertions(+), 67 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 80e442a..a5d03da 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -23,6 +23,7 @@ import * as Setting from "@/Setting";
 import {findGroupOf, selectedKeyOf} from "@/nav";
 import {AppHeader} from "@/components/shared/app-header";
 import {AppSidebar, persistOpenKeys, readSavedOpenKeys, useIsDesktop} from 
"@/components/shared/app-sidebar";
+import {CommandPalette, useCommandPalette} from 
"@/components/shared/command-palette";
 import {Loading} from "@/components/shared/loading";
 import {TooltipProvider} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
@@ -68,6 +69,7 @@ export default function App() {
   const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
   const isDesktop = useIsDesktop();
   const location = useLocation();
+  const palette = useCommandPalette();
 
   const selectedKey = selectedKeyOf(location.pathname, location.search, 
location.hash);
   const wasCollapsedRef = React.useRef(false);
@@ -237,8 +239,18 @@ export default function App() {
             themeAlgorithm={themeAlgorithm}
             onThemeChange={changeTheme}
             onSignout={signout}
+            onOpenPalette={() => palette.setOpen(true)}
           />
 
+          {account ? (
+            <CommandPalette
+              open={palette.open}
+              onOpenChange={palette.setOpen}
+              account={account}
+              isAdmin={Setting.isAdminUser(account)}
+            />
+          ) : null}
+
           {/* min-w-0 keeps a wide table from stretching the whole layout. */}
           <main className="flex min-w-0 flex-1 flex-col">
             <React.Suspense fallback={<Loading type="page" />}>
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index adc9985..0c661d5 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -139,7 +139,7 @@ function PlanUsage({quota}: {quota?: ProviderQuota}) {
       {percent === null ? null : (
         <Progress
           value={percent}
-          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"success"}
+          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"default"}
           className="h-1"
         />
       )}
diff --git a/web/src/components/AuthenticityOverview.tsx 
b/web/src/components/AuthenticityOverview.tsx
index 921fcc6..8d8cabd 100644
--- a/web/src/components/AuthenticityOverview.tsx
+++ b/web/src/components/AuthenticityOverview.tsx
@@ -129,10 +129,10 @@ export function AuthenticityOverview({
           <ScoreDial probe={worst} size={56} />
           <div className="flex min-w-0 flex-col gap-0.5">
             <div className="flex items-center gap-1.5">
-              <ShieldCheck className={cn("size-3.5", style.text)} />
+              <ShieldCheck className="text-muted-foreground size-3.5" />
               <span className="text-sm 
font-medium">{i18next.t("audit:Authenticity")}</span>
             </div>
-            <span className={cn("text-xs", style.text)}>
+            <span className="text-xs">
               {summary.graded === 0
                 ? i18next.t(mode === "off" ? "audit:Probing is off" : 
"audit:Measuring now")
                 : i18next.t(style.verdict)}
diff --git a/web/src/components/AuthenticityScore.tsx 
b/web/src/components/AuthenticityScore.tsx
index 801c811..ca46aed 100644
--- a/web/src/components/AuthenticityScore.tsx
+++ b/web/src/components/AuthenticityScore.tsx
@@ -75,16 +75,18 @@ export function ScoreDial({
           stroke="currentColor"
         />
       </svg>
+      {/* The ring is the one thing here that takes the grade's colour; the
+          number inside it is ordinary text. */}
       <div className="absolute inset-0 flex flex-col items-center 
justify-center gap-1">
         <span
-          className={cn("font-semibold tabular-nums leading-none", style.text)}
+          className="font-semibold tabular-nums leading-none"
           style={{fontSize: size / 3.8}}
         >
           {formatScore(probe)}
         </span>
         {spelled || measured ? (
           <span
-            className={cn("font-medium tracking-wide uppercase leading-none", 
style.text)}
+            className="text-muted-foreground font-medium tracking-wide 
uppercase leading-none"
             style={{fontSize: labelSize}}
           >
             {spelled ? label : letter}
@@ -117,7 +119,7 @@ export function ScoreBadge({
 
   return (
     <SimpleTooltip title={title}>
-      <Badge className={cn("gap-1.5 font-medium", style.badge, className)}>
+      <Badge variant="outline" className={cn("gap-1.5 font-medium", 
style.badge, className)}>
         <span className="tabular-nums">
           {gradeLetter(grade)}
           {grade === "unknown" ? "" : ` · ${formatScore(probe)}`}
diff --git a/web/src/components/OnboardingChecklist.tsx 
b/web/src/components/OnboardingChecklist.tsx
index ebb403c..859fffb 100644
--- a/web/src/components/OnboardingChecklist.tsx
+++ b/web/src/components/OnboardingChecklist.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {CheckCircle2, ChevronRight, Circle, Sparkles, X} from "lucide-react";
+import {CheckCircle2, ChevronRight, Circle, ListChecks, X} from "lucide-react";
 import i18next from "i18next";
 
 import {Button} from "@/components/ui/button";
@@ -100,7 +100,7 @@ export function OnboardingChecklist({
     <Card className="gap-0 py-0 shadow-xs">
       <CardContent className="flex flex-col gap-3 p-4">
         <div className="flex items-center gap-3">
-          <Sparkles className="text-primary size-4 shrink-0" />
+          <ListChecks className="text-muted-foreground size-4 shrink-0" />
           <div className="flex min-w-0 flex-1 flex-col gap-1">
             <div className="flex items-center justify-between gap-2">
               <span className="text-sm font-medium">{i18next.t("agent:Getting 
started")}</span>
@@ -108,7 +108,7 @@ export function OnboardingChecklist({
                 {doneCount}/{steps.length}
               </span>
             </div>
-            <Progress value={(doneCount / steps.length) * 100} tone="success" 
className="h-1.5" />
+            <Progress value={(doneCount / steps.length) * 100} 
className="h-1.5" />
           </div>
           <Button variant="ghost" size="icon-sm" onClick={dismiss} 
aria-label={i18next.t("general:Dismiss")}>
             <X />
@@ -126,7 +126,7 @@ export function OnboardingChecklist({
               )}
             >
               {step.done ? (
-                <CheckCircle2 className="text-success mt-0.5 size-4 shrink-0" 
/>
+                <CheckCircle2 className="text-muted-foreground mt-0.5 size-4 
shrink-0" />
               ) : (
                 <Circle className="text-muted-foreground mt-0.5 size-4 
shrink-0" />
               )}
diff --git a/web/src/components/ProviderAuditCard.tsx 
b/web/src/components/ProviderAuditCard.tsx
index bb95404..51acf71 100644
--- a/web/src/components/ProviderAuditCard.tsx
+++ b/web/src/components/ProviderAuditCard.tsx
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
-import {CircleAlert, CircleCheck, CircleHelp, Stethoscope, TriangleAlert} from 
"lucide-react";
+import {Stethoscope} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
@@ -43,11 +43,13 @@ const visibleModels = 4;
 /** Mirrors llmAuditMinSample on the server, for the sentence that explains 
it. */
 const minSample = 20;
 
-const levelStyles: Record<LlmAuditLevel, {text: string; border: string; icon: 
React.ElementType}> = {
-  ok: {text: "text-success", border: "border-success/25 bg-success/5", icon: 
CircleCheck},
-  warn: {text: "text-warning", border: "border-warning/30 bg-warning/5", icon: 
TriangleAlert},
-  alert: {text: "text-destructive", border: "border-destructive/30 
bg-destructive/5", icon: CircleAlert},
-  unknown: {text: "text-muted-foreground", border: "border-border", icon: 
CircleHelp},
+// A tile is a neutral surface. The level is a dot beside the title, and only a
+// case that failed colours anything larger than that.
+const levelStyles: Record<LlmAuditLevel, {dot: string; value: string; border: 
string}> = {
+  ok: {dot: "bg-success", value: "text-foreground", border: ""},
+  warn: {dot: "bg-warning", value: "text-foreground", border: ""},
+  alert: {dot: "bg-destructive", value: "text-destructive", border: 
"border-destructive/35"},
+  unknown: {dot: "bg-muted-foreground/40", value: "text-muted-foreground", 
border: ""},
 };
 
 function percent(share: number) {
@@ -89,12 +91,11 @@ function CheckTile({
   weight?: number;
 }) {
   const style = levelStyles[level] ?? levelStyles.unknown;
-  const Icon = style.icon;
 
   return (
-    <div className={cn("flex flex-col gap-1 rounded-lg border p-3", 
style.border)}>
+    <div className={cn("bg-card flex flex-col gap-1 rounded-lg border p-3", 
style.border)}>
       <div className="text-muted-foreground flex items-center gap-1.5 text-xs 
font-medium">
-        <Icon className={cn("size-3.5 shrink-0", style.text)} />
+        <span className={cn("size-1.5 shrink-0 rounded-full", style.dot)} />
         <span className="truncate">{title}</span>
         {weight && level !== "unknown" ? (
           <SimpleTooltip title={i18next.t("audit:Weight hint")}>
@@ -102,7 +103,7 @@ function CheckTile({
           </SimpleTooltip>
         ) : null}
       </div>
-      <span className={cn("text-lg font-semibold tabular-nums", 
style.text)}>{value}</span>
+      <span className={cn("text-lg font-semibold tabular-nums", 
style.value)}>{value}</span>
       <span className="text-muted-foreground text-xs 
leading-snug">{detail}</span>
     </div>
   );
@@ -339,10 +340,10 @@ function ScoreHeadline({probe}: {probe: ProviderProbe}) {
   const {alerts, warnings, measured} = probeFindings(probe);
 
   return (
-    <div className="flex items-center gap-4 rounded-lg border p-3">
+    <div className="bg-card flex items-center gap-4 rounded-lg border p-3">
       <ScoreDial probe={probe} size={84} />
       <div className="flex min-w-0 flex-col gap-1">
-        <span className={cn("text-sm font-semibold", 
style.text)}>{i18next.t(style.label)}</span>
+        <span className="text-sm font-semibold">{i18next.t(style.label)}</span>
         <span className="text-muted-foreground text-xs 
leading-snug">{i18next.t(style.verdict)}</span>
         <span className="text-muted-foreground text-xs">
           {fill("audit:Score from cases", {
diff --git a/web/src/components/shared/app-header.tsx 
b/web/src/components/shared/app-header.tsx
index 8326da6..f2af4e1 100644
--- a/web/src/components/shared/app-header.tsx
+++ b/web/src/components/shared/app-header.tsx
@@ -30,6 +30,7 @@ import {
 } from "@/components/ui/dropdown-menu";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {BreadcrumbBar} from "@/components/shared/breadcrumb-bar";
+import {CommandPaletteTrigger} from "@/components/shared/command-palette";
 import {VersionPanel} from "@/components/shared/version-panel";
 
 function ThemeToggle({
@@ -163,6 +164,7 @@ export function AppHeader({
   themeAlgorithm,
   onThemeChange,
   onSignout,
+  onOpenPalette,
 }: {
   collapsed: boolean;
   onToggleSidebar: () => void;
@@ -172,6 +174,7 @@ export function AppHeader({
   themeAlgorithm: ThemeAlgorithm;
   onThemeChange: (next: ThemeAlgorithm) => void;
   onSignout: () => void;
+  onOpenPalette: () => void;
 }) {
   return (
     <header className="bg-background/95 
supports-[backdrop-filter]:bg-background/80 sticky top-0 z-30 flex h-13 
shrink-0 items-center justify-between gap-2 border-b px-2 backdrop-blur">
@@ -187,7 +190,8 @@ export function AppHeader({
         <BreadcrumbBar uri={uri} />
       </div>
 
-      <div className="flex items-center gap-0.5 pr-1">
+      <div className="flex items-center gap-1.5 pr-1">
+        {account ? <CommandPaletteTrigger onOpen={onOpenPalette} /> : null}
         <VersionPanel isAdmin={isAdmin} signedIn={account !== null && account 
!== undefined} />
         <ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange} 
/>
         <LanguageSelect />
diff --git a/web/src/components/shared/command-palette.tsx 
b/web/src/components/shared/command-palette.tsx
new file mode 100644
index 0000000..f7f96f4
--- /dev/null
+++ b/web/src/components/shared/command-palette.tsx
@@ -0,0 +1,229 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {useNavigate} from "react-router-dom";
+import {Bot, CornerDownLeft, Search} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import {agentDetailPath} from "@/lib/agents";
+import {navGroups} from "@/nav";
+import type {Account, Agent, Provider} from "@/types";
+import {AgentIcon} from "@/components/AgentIcon";
+import {Button} from "@/components/ui/button";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {
+  CommandDialog,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+} from "@/components/ui/command";
+
+/** Owns the ⌘K / Ctrl+K shortcut, so the shell can hand `open` to both the 
palette and its trigger. */
+export function useCommandPalette() {
+  const [open, setOpen] = React.useState(false);
+
+  React.useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) 
{
+        event.preventDefault();
+        setOpen(previous => !previous);
+      }
+    };
+    document.addEventListener("keydown", onKeyDown);
+    return () => document.removeEventListener("keydown", onKeyDown);
+  }, []);
+
+  return {open, setOpen};
+}
+
+/**
+ * The lists behind the object hits. Neither endpoint takes a search term, so
+ * they are fetched once the palette is first opened and filtered here — both
+ * lists are the size of one machine's setup, not of a directory.
+ */
+function useSearchable(open: boolean, account: Account | null | undefined, 
isAdmin: boolean) {
+  const [agents, setAgents] = React.useState<Agent[]>([]);
+  const [providers, setProviders] = React.useState<Provider[]>([]);
+  const loadedRef = React.useRef(false);
+
+  React.useEffect(() => {
+    if (!open || !account || loadedRef.current) {
+      return;
+    }
+    loadedRef.current = true;
+
+    ProviderBackend.getProviders(account.name)
+      .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => setProviders([]));
+
+    if (isAdmin) {
+      // The scan only an admin may run, so everyone else searches pages and
+      // providers alone.
+      AgentBackend.getAgents()
+        .then(res => setAgents(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setAgents([]));
+    }
+  }, [open, account, isAdmin]);
+
+  return {agents, providers};
+}
+
+function agentValue(agent: Agent, agents: Agent[]) {
+  const duplicated = agents.filter(candidate => candidate.agentId === 
agent.agentId).length > 1;
+  return duplicated ? `${agent.name} ${agent.agentId} ${agent.path}` : 
`${agent.name} ${agent.agentId}`;
+}
+
+/** Every page in the rail, a group's sections included, as flat rows. */
+function pageHits(isAdmin: boolean) {
+  return navGroups
+    .filter(group => !group.hidden && (!group.adminOnly || isAdmin))
+    .flatMap(group => {
+      const label = i18next.t(group.label);
+      const self = {key: group.key, label: label, hint: "", path: group.path 
?? group.key};
+      const children = (group.children ?? []).map(child => ({
+        key: child.key,
+        label: i18next.t(child.label),
+        hint: label,
+        path: child.path,
+      }));
+      return [self, ...children];
+    });
+}
+
+/**
+ * Every console page by name, and the agents and providers behind them — the
+ * way to reach one of thirty providers without paging through the list.
+ */
+export function CommandPalette({
+  open,
+  onOpenChange,
+  account,
+  isAdmin,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  account: Account | null | undefined;
+  isAdmin: boolean;
+}) {
+  const navigate = useNavigate();
+  const {agents, providers} = useSearchable(open, account, isAdmin);
+
+  // Recomputed per render: the labels come from i18next, which changes 
language
+  // without changing this component's inputs.
+  const pages = pageHits(isAdmin);
+
+  const go = (to: string) => {
+    onOpenChange(false);
+    navigate(to);
+  };
+
+  return (
+    <CommandDialog open={open} onOpenChange={onOpenChange} 
title={i18next.t("general:Search")}>
+      <CommandInput placeholder={i18next.t("general:Search pages, agents and 
providers")} />
+      <CommandList className="max-h-[380px]">
+        <CommandEmpty>{i18next.t("general:No data")}</CommandEmpty>
+
+        <CommandGroup heading={i18next.t("general:Pages")}>
+          {pages.map(page => (
+            <CommandItem
+              key={page.key}
+              value={`${page.hint} ${page.label}`}
+              onSelect={() => go(page.path)}
+            >
+              <CornerDownLeft className="opacity-50" />
+              <span className="flex-1 truncate">{page.label}</span>
+              {page.hint === "" ? null : <span 
className="text-muted-foreground max-w-[50%] truncate 
text-xs">{page.hint}</span>}
+            </CommandItem>
+          ))}
+        </CommandGroup>
+
+        {agents.length === 0 ? null : (
+          <>
+            <CommandSeparator />
+            <CommandGroup heading={i18next.t("agent:Agents")}>
+              {agents.map(agent => (
+                <CommandItem
+                  key={`${agent.agentId}/${agent.path}`}
+                  // The path only joins the searched text for an agent 
installed
+                  // twice, where the name alone would name two rows.
+                  value={agentValue(agent, agents)}
+                  onSelect={() => go(agentDetailPath(agent, agents))}
+                >
+                  <AgentIcon agent={agent.agentId} size={16} fallback={<Bot 
className="size-4 opacity-50" />} />
+                  <span className="flex-1 truncate">{agent.name || 
agent.agentId}</span>
+                  <span className="text-muted-foreground max-w-[50%] truncate 
text-xs">{agent.path}</span>
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </>
+        )}
+
+        {providers.length === 0 ? null : (
+          <>
+            <CommandSeparator />
+            <CommandGroup heading={i18next.t("provider:Providers")}>
+              {providers.map(provider => (
+                <CommandItem
+                  key={`${provider.owner}/${provider.name}`}
+                  value={`${provider.displayName} ${provider.name} 
${provider.type}`}
+                  onSelect={() => 
go(`/providers/${provider.owner}/${provider.name}`)}
+                >
+                  <ProviderIcon icon={provider.icon} 
baseUrl={provider.baseUrl} alt={provider.type} size={16} />
+                  <span className="flex-1 truncate">{provider.displayName || 
provider.name}</span>
+                  <span className="text-muted-foreground max-w-[50%] truncate 
text-xs">{provider.type}</span>
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </>
+        )}
+      </CommandList>
+    </CommandDialog>
+  );
+}
+
+/** The header's search box, which only ever opens the palette. */
+export function CommandPaletteTrigger({onOpen}: {onOpen: () => void}) {
+  const isMac = typeof navigator !== "undefined" && 
/Mac|iPhone|iPad/.test(navigator.platform);
+  return (
+    <>
+      <button
+        type="button"
+        onClick={onOpen}
+        className="border-input text-muted-foreground hover:bg-accent 
hover:text-foreground hidden h-8 w-56 items-center gap-2 rounded-md border 
px-2.5 text-sm transition-colors md:flex lg:w-72"
+      >
+        <Search className="size-3.5 shrink-0" />
+        <span className="flex-1 truncate 
text-left">{i18next.t("general:Search")}</span>
+        <kbd className="bg-muted rounded border px-1.5 py-0.5 text-[10px] 
leading-none font-medium">
+          {isMac ? "⌘" : "Ctrl+"}K
+        </kbd>
+      </button>
+      {/* No shortcut to press on a phone, so the bar becomes a button. */}
+      <Button
+        variant="ghost"
+        size="icon-sm"
+        className="md:hidden"
+        onClick={onOpen}
+        aria-label={i18next.t("general:Search")}
+      >
+        <Search className="size-4" />
+      </Button>
+    </>
+  );
+}
diff --git a/web/src/components/shared/empty-state.tsx 
b/web/src/components/shared/empty-state.tsx
index 478b41f..441e297 100644
--- a/web/src/components/shared/empty-state.tsx
+++ b/web/src/components/shared/empty-state.tsx
@@ -64,7 +64,7 @@ export function ErrorState({
 }) {
   return (
     <div className={cn("flex flex-col items-center justify-center gap-2 px-6 
py-14 text-center", className)}>
-      <div className="bg-destructive/10 text-destructive flex size-10 
items-center justify-center rounded-full">
+      <div className="bg-muted text-destructive flex size-10 items-center 
justify-center rounded-full">
         <TriangleAlert className="size-5" />
       </div>
       <p className="text-sm font-medium">{title ?? i18next.t("general:Could 
not load this")}</p>
diff --git a/web/src/components/shared/stat-card.tsx 
b/web/src/components/shared/stat-card.tsx
index 4f5527d..64c4ec6 100644
--- a/web/src/components/shared/stat-card.tsx
+++ b/web/src/components/shared/stat-card.tsx
@@ -73,7 +73,7 @@ export function StatCard({
       {percent !== undefined && percent !== null ? (
         <Progress
           value={Math.min(100, Math.max(0, Number(percent) || 0))}
-          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"success"}
+          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"default"}
           className="h-1.5"
         />
       ) : null}
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
index 86f653e..ce5b3cc 100644
--- a/web/src/components/ui/alert.tsx
+++ b/web/src/components/ui/alert.tsx
@@ -23,11 +23,13 @@ const alertVariants = cva(
   {
     variants: {
       variant: {
+        // As shadcn ships it: the surface stays neutral. Something wrong
+        // colours its words; something merely worth knowing colours its icon.
         default: "bg-card text-card-foreground",
-        destructive: "border-destructive/30 bg-destructive/8 text-destructive 
[&>svg]:text-destructive",
-        warning: "border-warning/35 bg-warning/10 text-warning 
[&>svg]:text-warning",
-        success: "border-success/30 bg-success/8 text-success 
[&>svg]:text-success",
-        info: "border-info/30 bg-info/8 text-info [&>svg]:text-info",
+        destructive: "bg-card text-destructive [&>svg]:text-destructive",
+        warning: "bg-card text-warning [&>svg]:text-warning",
+        success: "bg-card text-card-foreground [&>svg]:text-success",
+        info: "bg-card text-card-foreground [&>svg]:text-info",
       },
     },
     defaultVariants: {variant: "default"},
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
index 60bf062..f5a8a1e 100644
--- a/web/src/components/ui/badge.tsx
+++ b/web/src/components/ui/badge.tsx
@@ -27,13 +27,13 @@ const badgeVariants = cva(
         secondary: "border-transparent bg-secondary text-secondary-foreground",
         destructive: "border-transparent bg-destructive 
text-destructive-foreground",
         outline: "text-foreground",
-        // Semantic tones are tinted rather than solid, so a table row carrying
-        // several of them still reads as a row instead of a row of buttons.
-        success: "border-success/25 bg-success/12 text-success",
-        warning: "border-warning/30 bg-warning/15 text-warning",
-        info: "border-info/25 bg-info/12 text-info",
-        danger: "border-destructive/25 bg-destructive/12 text-destructive",
-        muted: "border-border bg-muted text-muted-foreground",
+        // Semantic tones are outlined rather than filled: the word carries the
+        // colour, so a table of them reads as text instead of a row of pills.
+        success: "border-success/35 text-success",
+        warning: "border-warning/35 text-warning",
+        info: "border-info/35 text-info",
+        danger: "border-destructive/35 text-destructive",
+        muted: "border-transparent bg-muted text-muted-foreground",
       },
     },
     defaultVariants: {variant: "default"},
diff --git a/web/src/components/ui/command.tsx 
b/web/src/components/ui/command.tsx
index 8a972bc..5c434dd 100644
--- a/web/src/components/ui/command.tsx
+++ b/web/src/components/ui/command.tsx
@@ -17,6 +17,7 @@ import {Command as CommandPrimitive} from "cmdk";
 import {SearchIcon} from "lucide-react";
 
 import {cn} from "@/lib/utils";
+import {Dialog, DialogContent, DialogTitle} from "@/components/ui/dialog";
 
 function Command({className, ...props}: React.ComponentProps<typeof 
CommandPrimitive>) {
   return (
@@ -31,6 +32,17 @@ function Command({className, ...props}: 
React.ComponentProps<typeof CommandPrimi
   );
 }
 
+function CommandDialog({title, children, ...props}: 
React.ComponentProps<typeof Dialog> & {title: string}) {
+  return (
+    <Dialog {...props}>
+      <DialogContent className="overflow-hidden p-0" showCloseButton={false}>
+        <DialogTitle className="sr-only">{title}</DialogTitle>
+        <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-item]]:px-2 
[&_[cmdk-item]]:py-2.5">{children}</Command>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
 function CommandInput({className, ...props}: React.ComponentProps<typeof 
CommandPrimitive.Input>) {
   return (
     <div data-slot="command-input-wrapper" className="flex h-9 items-center 
gap-2 border-b px-3">
@@ -103,4 +115,4 @@ function CommandShortcut({className, ...props}: 
React.ComponentProps<"span">) {
   );
 }
 
-export {Command, CommandInput, CommandList, CommandEmpty, CommandGroup, 
CommandItem, CommandShortcut, CommandSeparator};
+export {Command, CommandDialog, CommandInput, CommandList, CommandEmpty, 
CommandGroup, CommandItem, CommandShortcut, CommandSeparator};
diff --git a/web/src/components/usage/usage-hero.tsx 
b/web/src/components/usage/usage-hero.tsx
index 6811356..9d9d3df 100644
--- a/web/src/components/usage/usage-hero.tsx
+++ b/web/src/components/usage/usage-hero.tsx
@@ -14,10 +14,9 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign, 
Database, Sparkles} from "lucide-react";
+import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign, 
Database, DatabaseZap} from "lucide-react";
 import i18next from "i18next";
 
-import {cn} from "@/lib/utils";
 import {Card, CardContent} from "@/components/ui/card";
 import {Progress} from "@/components/ui/progress";
 import {Skeleton} from "@/components/ui/skeleton";
@@ -29,13 +28,11 @@ function MiniStat({
   icon: Icon,
   label,
   value,
-  accent,
   hint,
 }: {
   icon: React.ComponentType<{className?: string}>;
   label: React.ReactNode;
   value: React.ReactNode;
-  accent: string;
   hint?: React.ReactNode;
 }) {
   // SimpleTooltip renders its child alone when there is no title, so the hint
@@ -44,7 +41,7 @@ function MiniStat({
     <SimpleTooltip title={hint}>
       <div className="bg-background/60 flex flex-col gap-1 rounded-lg border 
p-3">
         <div className="text-muted-foreground flex items-center gap-1.5 
text-xs font-medium">
-          <Icon className={cn("size-3.5 shrink-0", accent)} />
+          <Icon className="size-3.5 shrink-0" />
           <span className="truncate">{label}</span>
         </div>
         <span className="text-sm font-semibold tabular-nums">{value}</span>
@@ -107,7 +104,7 @@ export function UsageHero({
                 {i18next.t("llm:Requests")}
               </span>
               <span className="flex items-center gap-1.5 text-sm font-semibold 
tabular-nums">
-                <Activity className="text-info size-3.5" />
+                <Activity className="text-muted-foreground size-3.5" />
                 {totals.requests.toLocaleString()}
                 {totals.failed ? (
                   <span className="text-warning text-xs font-normal">
@@ -119,8 +116,8 @@ export function UsageHero({
             <div className="bg-border h-8 w-px" />
             <div className="flex flex-col">
               <span className="text-muted-foreground text-[11px] 
font-medium">{i18next.t("llm:Cost")}</span>
-              <span className="text-success flex items-center gap-1.5 text-sm 
font-semibold tabular-nums">
-                <CircleDollarSign className="size-3.5" />
+              <span className="flex items-center gap-1.5 text-sm font-semibold 
tabular-nums">
+                <CircleDollarSign className="text-muted-foreground size-3.5" />
                 {formatCost(totals.cost)}
               </span>
             </div>
@@ -132,33 +129,29 @@ export function UsageHero({
             icon={ArrowDownToLine}
             label={i18next.t("llm:Fresh input")}
             value={formatTokens(totals.promptTokens)}
-            accent="text-info"
           />
           <MiniStat
             icon={ArrowUpFromLine}
             label={i18next.t("llm:Output")}
             value={formatTokens(totals.completionTokens)}
-            accent="text-chart-4"
           />
           <MiniStat
             icon={Database}
             label={i18next.t("llm:Cache write")}
             value={formatTokens(totals.cacheWriteTokens)}
-            accent="text-warning"
             hint={cacheWriteHint}
           />
           <MiniStat
-            icon={Sparkles}
+            icon={DatabaseZap}
             label={i18next.t("llm:Cache read")}
             value={formatTokens(totals.cacheReadTokens)}
-            accent="text-success"
           />
           <div className="bg-background/60 col-span-2 flex flex-col 
justify-center gap-2 rounded-lg border p-3 lg:col-span-1">
             <div className="flex items-center justify-between text-xs">
               <span className="text-muted-foreground 
font-medium">{i18next.t("llm:Cache hit rate")}</span>
-              <span className="text-success font-semibold 
tabular-nums">{hitRate.toFixed(hitRate >= 99.95 ? 0 : 1)}%</span>
+              <span className="font-semibold 
tabular-nums">{hitRate.toFixed(hitRate >= 99.95 ? 0 : 1)}%</span>
             </div>
-            <Progress value={hitRate} tone="success" className="h-1.5" />
+            <Progress value={hitRate} className="h-1.5" />
           </div>
         </div>
 
diff --git a/web/src/index.css b/web/src/index.css
index 307fb46..c3824e8 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -4,7 +4,9 @@
 @custom-variant dark (&:is(.dark *));
 
 /* shadcn's neutral base palette. --primary is a neutral near-black rather than
-   a hue, so the semantic colours below carry all the meaning a page needs. */
+   a hue, so the semantic colours below carry all the meaning a page needs.
+   Those three are kept low-chroma on purpose: they are ink for a word or a
+   dot, never a wash behind a panel. */
 :root {
   --radius: 0.625rem;
 
@@ -24,11 +26,11 @@
   --accent-foreground: oklch(0.205 0 0);
   --destructive: oklch(0.577 0.245 27.325);
   --destructive-foreground: oklch(0.985 0 0);
-  --success: oklch(0.627 0.194 149.214);
+  --success: oklch(0.52 0.115 152);
   --success-foreground: oklch(0.985 0 0);
-  --warning: oklch(0.705 0.187 47.604);
-  --warning-foreground: oklch(0.145 0 0);
-  --info: oklch(0.588 0.158 241.966);
+  --warning: oklch(0.55 0.115 65);
+  --warning-foreground: oklch(0.985 0 0);
+  --info: oklch(0.52 0.115 248);
   --info-foreground: oklch(0.985 0 0);
   --border: oklch(0.922 0 0);
   --input: oklch(0.922 0 0);
@@ -67,11 +69,11 @@
   --accent-foreground: oklch(0.985 0 0);
   --destructive: oklch(0.704 0.191 22.216);
   --destructive-foreground: oklch(0.985 0 0);
-  --success: oklch(0.696 0.17 162.48);
+  --success: oklch(0.72 0.105 158);
   --success-foreground: oklch(0.145 0 0);
-  --warning: oklch(0.769 0.188 70.08);
+  --warning: oklch(0.78 0.115 72);
   --warning-foreground: oklch(0.145 0 0);
-  --info: oklch(0.685 0.145 241.966);
+  --info: oklch(0.7 0.095 245);
   --info-foreground: oklch(0.145 0 0);
   --border: oklch(1 0 0 / 12%);
   --input: oklch(1 0 0 / 18%);
diff --git a/web/src/lib/authenticity.ts b/web/src/lib/authenticity.ts
index 23166e6..eafbf69 100644
--- a/web/src/lib/authenticity.ts
+++ b/web/src/lib/authenticity.ts
@@ -29,7 +29,8 @@ export const gradeFloors: {grade: ProbeGrade; floor: 
number}[] = [
  * How each grade is painted and worded. The wording is deliberately about what
  * was measured rather than about the provider's character: an F says the 
answers
  * did not match what the API documents, which is a finding to send to whoever
- * sold the key.
+ * sold the key. The paint is one accent per grade: an outlined badge and the
+ * ring on the dial, never a filled panel.
  */
 export const gradeStyles: Record<
   ProbeGrade,
@@ -37,42 +38,42 @@ export const gradeStyles: Record<
 > = {
   A: {
     text: "text-success",
-    badge: "border-success/30 bg-success/10 text-success",
+    badge: "border-success/35 text-success",
     ring: "text-success",
     label: "audit:Grade A",
     verdict: "audit:Grade A detail",
   },
   B: {
     text: "text-success",
-    badge: "border-success/30 bg-success/10 text-success",
+    badge: "border-success/35 text-success",
     ring: "text-success",
     label: "audit:Grade B",
     verdict: "audit:Grade B detail",
   },
   C: {
     text: "text-warning",
-    badge: "border-warning/30 bg-warning/10 text-warning",
+    badge: "border-warning/35 text-warning",
     ring: "text-warning",
     label: "audit:Grade C",
     verdict: "audit:Grade C detail",
   },
   D: {
     text: "text-warning",
-    badge: "border-warning/30 bg-warning/10 text-warning",
+    badge: "border-warning/35 text-warning",
     ring: "text-warning",
     label: "audit:Grade D",
     verdict: "audit:Grade D detail",
   },
   F: {
     text: "text-destructive",
-    badge: "border-destructive/30 bg-destructive/10 text-destructive",
+    badge: "border-destructive/35 text-destructive",
     ring: "text-destructive",
     label: "audit:Grade F",
     verdict: "audit:Grade F detail",
   },
   unknown: {
     text: "text-muted-foreground",
-    badge: "border-border bg-muted text-muted-foreground",
+    badge: "border-border text-muted-foreground",
     ring: "text-muted-foreground",
     label: "audit:Grade unknown",
     verdict: "audit:Grade unknown detail",
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 15aa73e..ff6269c 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -19,12 +19,15 @@
   },
   "agent": {
     "Account": "Account",
+    "Across {count} agents": "Across {count} agents",
+    "Active today": "Active today",
     "Add instance": "Add instance",
     "Advanced view": "Advanced view",
     "Advanced view hint": "Every installation found, with its processes, path, 
owner and install method.",
     "Agent": "Agent",
     "Agent Records": "Activity",
     "Agent Sessions": "Sessions",
+    "Agent Sessions detail": "Every conversation your agents have had, from 
monitoring and from the transcripts they write themselves",
     "Agent configuration": "Agent configuration",
     "Agent installation not found": "Agent installation not found",
     "Agent installed": "Agent installed",
@@ -45,6 +48,7 @@
     "Any other tool that reads": "Any other tool that reads",
     "Any other tool that writes": "Any other tool that writes",
     "Any provider": "Any provider",
+    "Any source": "Any source",
     "Apply a patch": "Apply a patch",
     "Ask the user a question": "Ask the user a question",
     "Auto refresh": "Auto refresh",
@@ -279,6 +283,7 @@
     "blocked": "blocked",
     "opencode config hint": "opencode reads no base URL from the environment: 
the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, 
loaded through @ai-sdk/openai-compatible and selected with \"model\": 
\"<provider>/<model>\".",
     "switches": "switches",
+    "{count} from transcripts": "{count} from transcripts",
     "{count} messages": "{count} messages"
   },
   "agentConfig": {
@@ -778,7 +783,9 @@
     "Restarting into the new version": "Restarting into the new version",
     "Retry": "Retry",
     "Save": "Save",
+    "Pages": "Pages",
     "Search": "Search",
+    "Search pages, agents and providers": "Search pages, agents and providers",
     "Select": "Select",
     "Set a proxy": "Set a proxy",
     "Signed out successfully": "Signed out successfully",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0feaaba..f083e84 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -19,12 +19,15 @@
   },
   "agent": {
     "Account": "账户",
+    "Across {count} agents": "覆盖 {count} 个 Agent",
+    "Active today": "今日活跃",
     "Add instance": "添加实例",
     "Advanced view": "高级视图",
     "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
     "Agent": "智能体",
     "Agent Records": "活动记录",
     "Agent Sessions": "会话",
+    "Agent Sessions detail": "Agent 的每一次会话,既来自监控,也来自它们自己写下的记录",
     "Agent configuration": "Agent 配置文件",
     "Agent installation not found": "没有找到这个智能体的安装",
     "Agent installed": "安装完成",
@@ -45,6 +48,7 @@
     "Any other tool that reads": "其他所有读取类工具",
     "Any other tool that writes": "其他所有写入类工具",
     "Any provider": "任意供应商",
+    "Any source": "全部来源",
     "Apply a patch": "应用补丁",
     "Ask the user a question": "向用户提问",
     "Auto refresh": "自动刷新",
@@ -279,6 +283,7 @@
     "blocked": "项被拦截",
     "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 
~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 
@ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。",
     "switches": "个开关",
+    "{count} from transcripts": "{count} 个来自会话记录",
     "{count} messages": "{count} 条消息"
   },
   "agentConfig": {
@@ -850,7 +855,9 @@
     "Restarting into the new version": "正在重启到新版本",
     "Retry": "重试",
     "Save": "保存",
+    "Pages": "页面",
     "Search": "搜索",
+    "Search pages, agents and providers": "搜索页面、智能体和供应商",
     "Select": "请选择",
     "Set a proxy": "去配置代理",
     "Signed out successfully": "已退出登录",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to