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 +}