mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
feat(webui): Mono theme — pure white in light mode, pure black in dark mode
Adds a fourth accent alongside sakura/ocean/violet. Unlike those it is not just a different hue: the coloured themes are glass surfaces (translucent cards with backdrop-filter) floating over an animated gradient-mesh background, so setting --card:#ffffff there still renders as a tinted grey. Mono therefore also switches off the translucency and hides the blobs, so #ffffff is actually #ffffff and #000000 is actually #000000, with greys carrying the hierarchy that hue carries elsewhere. A side effect worth having: no backdrop-filter and no animated blobs makes it the cheapest theme to render, which helps on weak GPUs and over remote desktops. Both light and dark variable blocks are defined, so the existing light/dark toggle drives it with no extra wiring: light -> white, dark -> black. Also fixes a latent theme bug found while checking contrast on black: the active chart's grid baseline assigned the literal string "var(--line)" to ctx.strokeStyle. Canvas 2D does not resolve CSS custom properties, so that was an invalid colour the browser ignored, leaving the previous fillStyle (black) — an invisible baseline on every dark theme. Colours used on a canvas now go through a cssVar() helper. Tests: TestUIThemeMatrix asserts every accent defines BOTH a light and a dark block plus a picker button (a half-defined theme shows up as unreadable text, not as an error); TestUIMonoThemeIsFlat pins the opaque surfaces and disabled blobs; TestUICanvasColorsResolveVars fails if any ctx.strokeStyle/fillStyle is handed a raw var(). Unrelated packaging fix in the same commit: dist:linux only built deb+AppImage while build.linux.target listed rpm too, so `make gui-dist` silently skipped the rpm that release builds are expected to produce. Makefile/README wording updated to match.
This commit is contained in:
4
Makefile
4
Makefile
@ -31,7 +31,7 @@ gui-dev:
|
|||||||
@cd $(GUI_DIR) && npm run dev
|
@cd $(GUI_DIR) && npm run dev
|
||||||
|
|
||||||
# build the embedded core into the GUI, then run electron-builder
|
# 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
|
# 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.
|
# binary does not link against host-only libs and crash on user machines.
|
||||||
gui-dist:
|
gui-dist:
|
||||||
@ -63,7 +63,7 @@ help:
|
|||||||
@echo " make build build core binary (luajit)"
|
@echo " make build build core binary (luajit)"
|
||||||
@echo " make gui run desktop GUI (dev)"
|
@echo " make gui run desktop GUI (dev)"
|
||||||
@echo " make gui-dev run GUI with devtools"
|
@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-deb build deb only"
|
||||||
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
|
@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)"
|
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
|
||||||
|
|||||||
@ -431,7 +431,7 @@ cd cmd/gui
|
|||||||
npm install
|
npm install
|
||||||
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
|
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
|
||||||
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
|
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 # 构建 Windows nsis 安装包(本机 mingw + wine)
|
||||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,无需本机 mingw)
|
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,无需本机 mingw)
|
||||||
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
||||||
|
|||||||
@ -439,7 +439,7 @@ cd cmd/gui
|
|||||||
npm install
|
npm install
|
||||||
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
|
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-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 # Windows NSIS (host mingw + wine)
|
||||||
make gui-win-docker # Windows NSIS, fully dockerized (no host mingw needed)
|
make gui-win-docker # Windows NSIS, fully dockerized (no host mingw needed)
|
||||||
```
|
```
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
"dev": "electron . --no-sandbox --dev",
|
"dev": "electron . --no-sandbox --dev",
|
||||||
"core:linux": "node scripts/prepare-core.js linux",
|
"core:linux": "node scripts/prepare-core.js linux",
|
||||||
"core:win": "node scripts/prepare-core.js win",
|
"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:debian": "npm run core:linux && electron-builder --linux deb",
|
||||||
"dist:win": "npm run core:win && electron-builder --win nsis",
|
"dist:win": "npm run core:win && electron-builder --win nsis",
|
||||||
"dist:dir": "npm run core:linux && electron-builder --dir"
|
"dist:dir": "npm run core:linux && electron-builder --dir"
|
||||||
|
|||||||
@ -83,6 +83,56 @@
|
|||||||
--blob1:rgba(168,85,247,.32); --blob2:rgba(34,211,238,.26); --blob3:rgba(139,92,246,.22);
|
--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);
|
--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}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{margin:0;color:var(--fg);overflow:hidden;
|
body{margin:0;color:var(--fg);overflow:hidden;
|
||||||
@ -671,6 +721,22 @@
|
|||||||
<path d="M12 2.5v19" /></svg
|
<path d="M12 2.5v19" /></svg
|
||||||
>Violet
|
>Violet
|
||||||
</button>
|
</button>
|
||||||
|
<button data-accent="mono" title="Mono — 纯白(浅色)/ 纯黑(深色)">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" />
|
||||||
|
</svg
|
||||||
|
>Mono
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -1836,6 +1902,16 @@
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
// sparkline: turn a value array into a compact polyline (area fill under accent)
|
// 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) {
|
function sparkline(ctx, vals, w, h, color) {
|
||||||
if (!vals.length) {
|
if (!vals.length) {
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color;
|
||||||
@ -1909,8 +1985,11 @@
|
|||||||
qs.data.push(Math.max(rate, 0));
|
qs.data.push(Math.max(rate, 0));
|
||||||
}
|
}
|
||||||
if (qs.data.length > 40) qs.data.shift();
|
if (qs.data.length > 40) qs.data.shift();
|
||||||
// grid baseline
|
// grid baseline. Canvas cannot resolve CSS custom properties, so the
|
||||||
ctx.strokeStyle = "var(--line)";
|
// 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.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, H - 1);
|
ctx.moveTo(0, H - 1);
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user