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 7d64859803c6beebb05130be34f1d4dcd0111d3a
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 15:06:06 2026 +0800

    feat(theme): let the header switch between amber and neutral palettes
---
 web/src/App.tsx                          | 14 ++++-
 web/src/Setting.tsx                      | 20 ++++++-
 web/src/components/shared/app-header.tsx | 57 +++++++++++++++++++-
 web/src/index.css                        | 93 ++++++++++++++++++++++++++++++++
 web/src/locales/en/data.json             |  3 ++
 web/src/locales/zh/data.json             |  3 ++
 web/src/types.ts                         |  3 ++
 7 files changed, 189 insertions(+), 4 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index f6f052e..25b710e 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -46,7 +46,7 @@ import UsagePage from "@/pages/UsagePage";
 import PricingPage from "@/pages/PricingPage";
 import SettingPage from "@/pages/SettingPage";
 import SigninPage from "@/pages/SigninPage";
-import type {Account, ThemeAlgorithm} from "@/types";
+import type {Account, Palette, ThemeAlgorithm} from "@/types";
 
 Setting.initCasdoorSdk(Conf.AuthConfig);
 
@@ -64,6 +64,11 @@ export default function App() {
     Setting.applyThemeAlgorithm(stored);
     return stored;
   });
+  const [colorPalette, setColorPalette] = React.useState<Palette>(() => {
+    const stored = Setting.readPalette();
+    Setting.applyPalette(stored);
+    return stored;
+  });
   const [collapsed, setCollapsed] = React.useState(() => 
localStorage.getItem(collapsedKey) === "true");
   // Below md the rail is a drawer, so the same header button opens it instead
   // of narrowing a column that is not on screen.
@@ -135,6 +140,11 @@ export default function App() {
     Setting.saveThemeAlgorithm(next);
   };
 
+  const changeColorPalette = (next: Palette) => {
+    setColorPalette(next);
+    Setting.savePalette(next);
+  };
+
   const getAccount = React.useCallback(() => {
     AccountBackend.getAccount()
       .then(res => {
@@ -239,6 +249,8 @@ export default function App() {
             isAdmin={Setting.isAdminUser(account)}
             themeAlgorithm={themeAlgorithm}
             onThemeChange={changeTheme}
+            colorPalette={colorPalette}
+            onColorPaletteChange={changeColorPalette}
             onSignout={signout}
             onOpenPalette={() => palette.setOpen(true)}
           />
diff --git a/web/src/Setting.tsx b/web/src/Setting.tsx
index 6e90deb..8b9aa90 100644
--- a/web/src/Setting.tsx
+++ b/web/src/Setting.tsx
@@ -18,7 +18,7 @@ import {toast} from "sonner";
 
 import * as Conf from "@/Conf";
 import {CopyButton} from "@/components/shared/misc";
-import type {Account, ApiResponse, ThemeAlgorithm} from "@/types";
+import type {Account, ApiResponse, Palette, ThemeAlgorithm} from "@/types";
 
 /**
  * Where the REST API lives. It stays empty on purpose: the dev server proxies
@@ -231,6 +231,24 @@ export function saveThemeAlgorithm(themeAlgorithm: 
ThemeAlgorithm) {
   applyThemeAlgorithm(themeAlgorithm);
 }
 
+// The palette is orthogonal to light/dark: each one defines both, so switching
+// palettes never drags the reader out of the mode they chose.
+export const Palettes: Palette[] = ["amber", "neutral"];
+
+export function applyPalette(palette: Palette) {
+  document.documentElement.setAttribute("data-palette", palette);
+}
+
+export function readPalette(): Palette {
+  const raw = localStorage.getItem("palette");
+  return Palettes.includes(raw as Palette) ? (raw as Palette) : "amber";
+}
+
+export function savePalette(palette: Palette) {
+  localStorage.setItem("palette", palette);
+  applyPalette(palette);
+}
+
 export function setLanguage(language: string) {
   localStorage.setItem("language", language);
   i18next.changeLanguage(language);
diff --git a/web/src/components/shared/app-header.tsx 
b/web/src/components/shared/app-header.tsx
index 3cf1325..f41f782 100644
--- a/web/src/components/shared/app-header.tsx
+++ b/web/src/components/shared/app-header.tsx
@@ -14,11 +14,22 @@
 
 import * as React from "react";
 import {useNavigate} from "react-router-dom";
-import {ChevronDown, Languages, LogOut, Moon, PanelLeft, PanelLeftClose, 
Settings, Sun} from "lucide-react";
+import {
+  Check,
+  ChevronDown,
+  Languages,
+  LogOut,
+  Moon,
+  PanelLeft,
+  PanelLeftClose,
+  Palette as PaletteIcon,
+  Settings,
+  Sun,
+} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
-import type {Account, ThemeAlgorithm} from "@/types";
+import type {Account, Palette, ThemeAlgorithm} from "@/types";
 import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
 import {Button} from "@/components/ui/button";
 import {
@@ -57,6 +68,43 @@ function ThemeToggle({
   );
 }
 
+// The swatches are literal rather than var(--primary): the menu has to show 
the
+// palette you are not in, and both colours have to read on either background.
+const paletteSwatch: Record<Palette, string> = {
+  amber: "oklch(0.64 0.16 52)",
+  neutral: "oklch(0.62 0 0)",
+};
+
+const paletteLabel: Record<Palette, string> = {
+  amber: "general:Amber & Ink",
+  neutral: "general:Neutral",
+};
+
+function PaletteSelect({palette, onChange}: {palette: Palette; onChange: 
(next: Palette) => void}) {
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant="ghost" size="icon-sm" 
aria-label={i18next.t("general:Palette")}>
+          <PaletteIcon />
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        {Setting.Palettes.map(option => (
+          <DropdownMenuItem
+            key={option}
+            className={cn(palette === option && "font-semibold")}
+            onSelect={() => onChange(option)}
+          >
+            <span className="size-3 shrink-0 rounded-full" 
style={{backgroundColor: paletteSwatch[option]}} />
+            {i18next.t(paletteLabel[option])}
+            {palette === option ? <Check className="ml-auto size-3.5" /> : 
null}
+          </DropdownMenuItem>
+        ))}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
+
 function LanguageSelect() {
   const [, forceRender] = React.useReducer((count: number) => count + 1, 0);
   const current = Setting.getLanguage();
@@ -171,6 +219,8 @@ export function AppHeader({
   isAdmin,
   themeAlgorithm,
   onThemeChange,
+  colorPalette,
+  onColorPaletteChange,
   onSignout,
   onOpenPalette,
 }: {
@@ -181,6 +231,8 @@ export function AppHeader({
   isAdmin: boolean;
   themeAlgorithm: ThemeAlgorithm;
   onThemeChange: (next: ThemeAlgorithm) => void;
+  colorPalette: Palette;
+  onColorPaletteChange: (next: Palette) => void;
   onSignout: () => void;
   onOpenPalette: () => void;
 }) {
@@ -202,6 +254,7 @@ export function AppHeader({
         {account ? <CommandPaletteTrigger onOpen={onOpenPalette} /> : null}
         <VersionPanel isAdmin={isAdmin} signedIn={account !== null && account 
!== undefined} />
         <ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange} 
/>
+        <PaletteSelect palette={colorPalette} onChange={onColorPaletteChange} 
/>
         <LanguageSelect />
         <AccountMenu account={account} onSignout={onSignout} />
       </div>
diff --git a/web/src/index.css b/web/src/index.css
index 1d76dd5..de64168 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -111,6 +111,99 @@
   --sidebar-ring: oklch(0.735 0.152 58);
 }
 
+/* The palette Gateway shipped before "Amber & Ink", kept as an opt-out for
+   anyone who wants the plain grey. `:not(.dark)` and `.dark` never both match,
+   so neither block has to out-specify the other or come first. */
+html[data-palette="neutral"]:not(.dark) {
+  --radius: 0.625rem;
+
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.97 0 0);
+  --muted-foreground: oklch(0.556 0 0);
+  --accent: oklch(0.97 0 0);
+  --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.52 0.115 152);
+  --success-foreground: oklch(0.985 0 0);
+  --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);
+  --ring: oklch(0.708 0 0);
+
+  --chart-1: oklch(0.646 0.222 41.116);
+  --chart-2: oklch(0.6 0.118 184.704);
+  --chart-3: oklch(0.398 0.07 227.392);
+  --chart-4: oklch(0.828 0.189 84.429);
+  --chart-5: oklch(0.769 0.188 70.08);
+
+  --sidebar: oklch(0.985 0 0);
+  --sidebar-foreground: oklch(0.145 0 0);
+  --sidebar-primary: oklch(0.205 0 0);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.97 0 0);
+  --sidebar-accent-foreground: oklch(0.205 0 0);
+  --sidebar-border: oklch(0.922 0 0);
+  --sidebar-ring: oklch(0.708 0 0);
+}
+
+html[data-palette="neutral"].dark {
+  --radius: 0.625rem;
+
+  --background: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.205 0 0);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.922 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.269 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.269 0 0);
+  --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.72 0.105 158);
+  --success-foreground: oklch(0.145 0 0);
+  --warning: oklch(0.78 0.115 72);
+  --warning-foreground: oklch(0.145 0 0);
+  --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%);
+  --ring: oklch(0.556 0 0);
+
+  --chart-1: oklch(0.488 0.243 264.376);
+  --chart-2: oklch(0.696 0.17 162.48);
+  --chart-3: oklch(0.769 0.188 70.08);
+  --chart-4: oklch(0.627 0.265 303.9);
+  --chart-5: oklch(0.645 0.246 16.439);
+
+  --sidebar: oklch(0.205 0 0);
+  --sidebar-foreground: oklch(0.985 0 0);
+  --sidebar-primary: oklch(0.985 0 0);
+  --sidebar-primary-foreground: oklch(0.205 0 0);
+  --sidebar-accent: oklch(0.269 0 0);
+  --sidebar-accent-foreground: oklch(0.985 0 0);
+  --sidebar-border: oklch(1 0 0 / 12%);
+  --sidebar-ring: oklch(0.556 0 0);
+}
+
 @theme inline {
   --radius-sm: calc(var(--radius) - 4px);
   --radius-md: calc(var(--radius) - 2px);
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index e600d05..a78a9b2 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -737,6 +737,7 @@
   "general": {
     "Action": "Action",
     "Advanced": "Advanced",
+    "Amber & Ink": "Amber & Ink",
     "Back Home": "Back Home",
     "Built": "Built",
     "Cancel": "Cancel",
@@ -775,12 +776,14 @@
     "Login Error": "Login Error",
     "Name": "Name",
     "Name cannot be empty": "Name cannot be empty",
+    "Neutral": "Neutral",
     "New": "New",
     "Next": "Next",
     "No build is published for this platform, so the update has to be done by 
hand": "No build is published for this platform, so the update has to be done 
by hand",
     "No data": "No data",
     "OK": "OK",
     "Owner": "Owner",
+    "Palette": "Palette",
     "Password": "Password",
     "Path": "Path",
     "Previous": "Previous",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 96e410a..ca36f19 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -809,6 +809,7 @@
   "general": {
     "Action": "操作",
     "Advanced": "高级",
+    "Amber & Ink": "琥珀墨",
     "Back Home": "返回首页",
     "Built": "构建于",
     "Cancel": "取消",
@@ -847,12 +848,14 @@
     "Login Error": "登录失败",
     "Name": "名称",
     "Name cannot be empty": "名称不能为空",
+    "Neutral": "中性灰",
     "New": "新",
     "Next": "下一页",
     "No build is published for this platform, so the update has to be done by 
hand": "该平台没有发布构建产物,需要手动升级",
     "No data": "暂无数据",
     "OK": "确定",
     "Owner": "所有者",
+    "Palette": "配色",
     "Password": "密码",
     "Path": "路径",
     "Previous": "上一页",
diff --git a/web/src/types.ts b/web/src/types.ts
index 5f5a9af..63f9190 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -15,6 +15,9 @@
 /** The theme the shell renders in; "dark" toggles the .dark class on <html>. 
*/
 export type ThemeAlgorithm = ("default" | "dark")[];
 
+/** Which colour set the theme draws from, light or dark; see index.css. */
+export type Palette = "amber" | "neutral";
+
 /**
  * The envelope every /api handler returns (controllers.Response): "ok" or
  * "error" in `status`, the payload in `data`, and a second value such as a row


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

Reply via email to