diff --git a/Makefile b/Makefile
index dc77b8c..5ca0493 100644
--- a/Makefile
+++ b/Makefile
@@ -31,7 +31,7 @@ gui-dev:
@cd $(GUI_DIR) && npm run dev
# build the embedded core into the GUI, then run electron-builder
-# targets: deb + AppImage (linux), rpm needs system rpmbuild
+# targets: deb + rpm + AppImage (linux); rpm needs system rpmbuild
# NOTE: run in a clean env (no LD_LIBRARY_PATH pollution) so the packaged
# binary does not link against host-only libs and crash on user machines.
gui-dist:
@@ -63,7 +63,7 @@ help:
@echo " make build build core binary (luajit)"
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
- @echo " make gui-dist build deb + AppImage (linux)"
+ @echo " make gui-dist build deb + rpm + AppImage (linux)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
diff --git a/README.md b/README.md
index d167448..abc6013 100644
--- a/README.md
+++ b/README.md
@@ -431,7 +431,7 @@ cd cmd/gui
npm install
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
-make gui-dist # 构建 deb + AppImage(linux)
+make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,无需本机 mingw)
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
diff --git a/README_EN.md b/README_EN.md
index 20fa9a2..af8f556 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -439,7 +439,7 @@ cd cmd/gui
npm install
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
make gui-deb # local deb (share with other Linux users)
-make gui-dist # deb + AppImage (linux)
+make gui-dist # deb + rpm + AppImage (linux; rpm needs system rpmbuild)
make gui-win # Windows NSIS (host mingw + wine)
make gui-win-docker # Windows NSIS, fully dockerized (no host mingw needed)
```
diff --git a/cmd/gui/package.json b/cmd/gui/package.json
index bc7eecc..44f4366 100644
--- a/cmd/gui/package.json
+++ b/cmd/gui/package.json
@@ -10,7 +10,7 @@
"dev": "electron . --no-sandbox --dev",
"core:linux": "node scripts/prepare-core.js linux",
"core:win": "node scripts/prepare-core.js win",
- "dist:linux": "npm run core:linux && electron-builder --linux deb AppImage",
+ "dist:linux": "npm run core:linux && electron-builder --linux deb rpm AppImage",
"dist:debian": "npm run core:linux && electron-builder --linux deb",
"dist:win": "npm run core:win && electron-builder --win nsis",
"dist:dir": "npm run core:linux && electron-builder --dir"
diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index d68d0b1..e054a05 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -83,6 +83,56 @@
--blob1:rgba(168,85,247,.32); --blob2:rgba(34,211,238,.26); --blob3:rgba(139,92,246,.22);
--line:rgba(168,85,247,.16); --line-strong:rgba(139,92,246,.18);
}
+ /* ---- Mono: pure white (light) / pure black (dark) ----
+ Unlike the coloured accents this one deliberately drops the glass
+ translucency and the gradient-mesh blobs: "pure white" has to actually be
+ #fff, not a tinted translucent card floating over a gradient. Greys carry
+ the hierarchy that hue carries in the other themes, and the flat surfaces
+ also make this the cheapest theme to render (no backdrop-filter, no
+ animated blobs) — handy on weak GPUs and remote desktops. */
+ html[data-accent="mono"]{
+ --primary:#1f1f1f; --primary-h:#000000; --primary-50:#f4f4f5; --primary-100:#e8e8ea;
+ --primary-200:#d4d4d8; --primary-300:#a1a1aa; --primary-400:#52525b; --primary-500:#1f1f1f;
+ --secondary:#71717a; --secondary-h:#3f3f46; --secondary-50:#f4f4f5; --secondary-200:#d4d4d8;
+ --danger:#b91c1c; --danger-50:#fdeaea; --ok:#15803d; --warn:#a16207; --info:#3f3f46;
+ --bg-s1:#ffffff; --bg-s2:#ffffff; --bg-s3:#ffffff;
+ --card:#ffffff; --card-solid:#ffffff; --card2:#f6f6f7;
+ --line:rgba(0,0,0,.12); --line-strong:rgba(0,0,0,.22);
+ --fg:#18181b; --muted:#66666f;
+ --blob1:transparent; --blob2:transparent; --blob3:transparent;
+ --sh-sm:0 1px 2px rgba(0,0,0,.06);
+ --sh-md:0 2px 8px rgba(0,0,0,.09);
+ --sh-lg:0 8px 24px rgba(0,0,0,.13);
+ --glass:0px;
+ }
+ html[data-theme="dark"][data-accent="mono"]{
+ --primary:#f4f4f5; --primary-h:#ffffff; --primary-50:#161618; --primary-100:#1f1f22;
+ --primary-200:#2a2a2e; --primary-300:#3f3f46; --primary-400:#a1a1aa; --primary-500:#f4f4f5;
+ --secondary:#a1a1aa; --secondary-h:#d4d4d8; --secondary-50:#161618; --secondary-200:#2a2a2e;
+ --danger:#f87171; --danger-50:#2a1414; --ok:#4ade80; --warn:#fbbf24; --info:#d4d4d8;
+ --bg-s1:#000000; --bg-s2:#000000; --bg-s3:#000000;
+ --card:#0a0a0b; --card-solid:#0a0a0b; --card2:#151517;
+ --line:rgba(255,255,255,.14); --line-strong:rgba(255,255,255,.26);
+ --fg:#f4f4f5; --muted:#8c8c96;
+ --blob1:transparent; --blob2:transparent; --blob3:transparent;
+ --sh-sm:0 1px 2px rgba(0,0,0,.6);
+ --sh-md:0 2px 8px rgba(0,0,0,.7);
+ --sh-lg:0 8px 24px rgba(0,0,0,.8);
+ --glass:0px;
+ }
+ /* Flat surfaces: kill the translucency/blur that the glass themes rely on,
+ otherwise white/black would show through as grey. */
+ html[data-accent="mono"] #bgfx{background:var(--bg-s1)}
+ html[data-accent="mono"] #bgfx .blob{display:none}
+ html[data-accent="mono"] .sidebar,
+ html[data-accent="mono"] .card,
+ html[data-accent="mono"] .modal,
+ html[data-accent="mono"] .breadcrumb{backdrop-filter:none;-webkit-backdrop-filter:none}
+ html[data-accent="mono"] nav button.sb-i.active{background:var(--primary-50)}
+ html[data-accent="mono"] .sb-logo{background:var(--fg);color:var(--bg-s1);box-shadow:none}
+ html[data-accent="mono"] ::selection{background:var(--fg);color:var(--bg-s1)}
+ html[data-accent="mono"] ::-webkit-scrollbar-thumb{background:rgba(128,128,136,.45)}
+ html[data-accent="mono"] ::-webkit-scrollbar-thumb:hover{background:rgba(128,128,136,.75)}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);overflow:hidden;
@@ -671,6 +721,22 @@
Violet
+
@@ -1836,6 +1902,16 @@
.join("");
}
// sparkline: turn a value array into a compact polyline (area fill under accent)
+ // cssVar reads a CSS custom property off the document root. Canvas 2D does
+ // not understand var(), so any theme-driven colour used on a canvas has to
+ // be resolved here first.
+ function cssVar(name, fallback) {
+ const v = getComputedStyle(document.documentElement)
+ .getPropertyValue(name)
+ .trim();
+ return v || fallback;
+ }
+
function sparkline(ctx, vals, w, h, color) {
if (!vals.length) {
ctx.strokeStyle = color;
@@ -1909,8 +1985,11 @@
qs.data.push(Math.max(rate, 0));
}
if (qs.data.length > 40) qs.data.shift();
- // grid baseline
- ctx.strokeStyle = "var(--line)";
+ // grid baseline. Canvas cannot resolve CSS custom properties, so the
+ // literal "var(--line)" used to be an invalid colour that the browser
+ // silently ignored, leaving whatever fillStyle was set last (black) —
+ // invisible on dark themes. Resolve it against the document instead.
+ ctx.strokeStyle = cssVar("--line", "rgba(128,128,128,.2)");
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - 1);
diff --git a/internal/gateway/ui_contract_test.go b/internal/gateway/ui_contract_test.go
index 111cf5e..73edcac 100644
--- a/internal/gateway/ui_contract_test.go
+++ b/internal/gateway/ui_contract_test.go
@@ -249,3 +249,101 @@ func TestUIRecordsPagingWiring(t *testing.T) {
}
}
}
+
+// TestUIThemeMatrix pins the theme system: every accent must define both a light
+// and a dark variable block, so switching light/dark can never leave a theme
+// half-defined (which shows up as unreadable text rather than as an error).
+func TestUIThemeMatrix(t *testing.T) {
+ src := uiSource(t)
+
+ accents := []string{"sakura", "ocean", "violet", "mono"}
+ for _, a := range accents {
+ light := fmt.Sprintf(`html[data-accent=%q]{`, a)
+ dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
+ if !strings.Contains(src, light) {
+ t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
+ }
+ // sakura is the default palette: its light values live in :root, and the
+ // generic dark block covers it.
+ if a != "sakura" && !strings.Contains(src, dark) {
+ t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
+ }
+ if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
+ t.Errorf("accent %q has no picker button", a)
+ }
+ }
+}
+
+// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
+// the surfaces must be opaque and the glass/blob decoration must be switched off,
+// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
+func TestUIMonoThemeIsFlat(t *testing.T) {
+ src := uiSource(t)
+
+ lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
+ if !ok {
+ t.Fatal("mono light block not found")
+ }
+ for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
+ if !strings.Contains(lightBlock, want) {
+ t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
+ }
+ }
+ if !strings.Contains(lightBlock, "--blob1:transparent") {
+ t.Error("mono light theme must disable the gradient blobs")
+ }
+
+ darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
+ if !ok {
+ t.Fatal("mono dark block not found")
+ }
+ for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
+ if !strings.Contains(darkBlock, want) {
+ t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
+ }
+ }
+ if !strings.Contains(darkBlock, "--blob1:transparent") {
+ t.Error("mono dark theme must disable the gradient blobs")
+ }
+
+ // the flat-surface overrides must exist, or backdrop-filter turns #fff grey
+ for _, want := range []string{
+ `html[data-accent="mono"] #bgfx .blob{display:none}`,
+ `backdrop-filter:none`,
+ } {
+ if !strings.Contains(src, want) {
+ t.Errorf("mono theme needs the flat-surface override %q", want)
+ }
+ }
+}
+
+// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
+// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
+// no-op that leaves the previous colour (often black) in place — invisible on a
+// dark background. Theme-driven canvas colours must go through cssVar().
+func TestUICanvasColorsResolveVars(t *testing.T) {
+ src := uiSource(t)
+ if !strings.Contains(src, "function cssVar(") {
+ t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
+ }
+ for _, prop := range []string{"strokeStyle", "fillStyle"} {
+ bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
+ if strings.Contains(src, bad) {
+ t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
+ }
+ }
+}
+
+// cssBlock returns the text of the CSS rule starting at selector, up to its
+// closing brace.
+func cssBlock(src, selector string) (string, bool) {
+ i := strings.Index(src, selector)
+ if i < 0 {
+ return "", false
+ }
+ j := strings.Index(src[i:], "}")
+ if j < 0 {
+ return "", false
+ }
+ return src[i : i+j+1], true
+}