Padlock-on-a-shield mark, dark and light themes, history note
A new mark: a padlock on a shield with the W as its keyhole, in the console's teal on a dark shield. It replaces the placeholder W tile in the sidebar, on the sign-in and setup pages, and as the favicon, with app icons and a web manifest. The SVG sources, a single-colour variant, a wordmark and a social preview image live in docs/brand. The console now has a theme choice: dark by default, light, or follow the system, switched from the sidebar or the Account page and remembered per browser. The light palette is a first-class theme rather than a media query, so both are laid out the same way. The README opens with the mark and gains a History section about the 2025 WGX installer this replaces.
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="docs/brand/wgx-mark-256.png" width="128" height="128" alt="WGX: a padlock on a shield, with a W for a keyhole">
|
||||||
|
</p>
|
||||||
|
|
||||||
# WGX
|
# WGX
|
||||||
|
|
||||||
A WireGuard server with a secure web console, in one container.
|
A WireGuard server with a secure web console, in one container.
|
||||||
@@ -161,6 +165,18 @@ The UI is embedded in the binary. `docker build -t wgx .` does both steps.
|
|||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop against the
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop against the
|
||||||
mock data plane, which needs no privileges.
|
mock data plane, which needs no privileges.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
This is a complete, ground-up rewrite of an earlier WGX, "WireGuard
|
||||||
|
eXtended", which Coffey Labs published in October 2025 and later dropped.
|
||||||
|
That one was an installer: a collection of Bash scripts behind a text-mode
|
||||||
|
menu that set up and hardened a WireGuard stack on Debian 13 around a
|
||||||
|
third-party web UI. The
|
||||||
|
[original announcement](https://jcoffey.dev/articles/wg-easy-installer-debian-13/)
|
||||||
|
is still up. Nothing from it was carried over; this WGX is its own server and
|
||||||
|
its own console, in one container, with the name kept because the intent is
|
||||||
|
the same.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX">
|
||||||
|
<!-- single-colour version: inherits currentColor, for print, status bars and anywhere one ink -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<path d="M64 8 L112 23.5 V60 C112 88.5 92 109.5 64 119.5 C36 109.5 16 88.5 16 60 V23.5 Z" stroke-width="7"/>
|
||||||
|
<path d="M48 64 V52 a16 16 0 0 1 32 0 V64" stroke-width="7.5"/>
|
||||||
|
</g>
|
||||||
|
<path fill-rule="evenodd" fill="currentColor" d="
|
||||||
|
M47 60 h34 a9 9 0 0 1 9 9 v20 a9 9 0 0 1 -9 9 h-34 a9 9 0 0 1 -9 -9 v-20 a9 9 0 0 1 9 -9 z
|
||||||
|
M52 70 l6 17.5 l6 -8.5 l6 8.5 l6 -17.5 l-4.6 -1.6 l-3.6 10.5 l-3.8 -5.4 l-3.8 5.4 l-3.6 -10.5 z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#26332e"/>
|
||||||
|
<stop offset="1" stop-color="#121a17"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3fae90"/>
|
||||||
|
<stop offset="1" stop-color="#2b8f75"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- shield -->
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)"/>
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<!-- shackle -->
|
||||||
|
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" stroke-width="8.5" stroke-linecap="round"/>
|
||||||
|
<!-- lock body -->
|
||||||
|
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)"/>
|
||||||
|
<!-- the W as the keyhole -->
|
||||||
|
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -5,7 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f6f5c'/%3E%3Cpath d='M7 10l4 12 5-9 5 9 4-12' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#121a17" />
|
||||||
<title>WGX</title>
|
<title>WGX</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#26332e"/>
|
||||||
|
<stop offset="1" stop-color="#121a17"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3fae90"/>
|
||||||
|
<stop offset="1" stop-color="#2b8f75"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- shield -->
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)"/>
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<!-- shackle -->
|
||||||
|
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" stroke-width="8.5" stroke-linecap="round"/>
|
||||||
|
<!-- lock body -->
|
||||||
|
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)"/>
|
||||||
|
<!-- the W as the keyhole -->
|
||||||
|
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "WGX",
|
||||||
|
"short_name": "WGX",
|
||||||
|
"description": "WireGuard server console",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111715",
|
||||||
|
"theme_color": "#121a17",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
|
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useAuth, useLive } from "../state";
|
import { useAuth, useLive } from "../state";
|
||||||
|
import { Mark } from "./Mark";
|
||||||
|
import { ThemeSwitch } from "./ThemeSwitch";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
@@ -20,10 +22,8 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark" aria-hidden="true">
|
<div className="brand-mark">
|
||||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
<Mark size={34} />
|
||||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="brand-name">WGX</div>
|
<div className="brand-name">WGX</div>
|
||||||
@@ -43,6 +43,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
|
<ThemeSwitch compact />
|
||||||
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
|
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
|
||||||
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
|
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The WGX mark: a padlock on a shield, with the W as its keyhole. The same
|
||||||
|
// drawing as docs/brand/wgx-mark.svg, inlined so it needs no request and
|
||||||
|
// takes its size from wherever it is placed.
|
||||||
|
export function Mark({ size = 32 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="WGX">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stopColor="#26332e" />
|
||||||
|
<stop offset="1" stopColor="#121a17" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stopColor="#3fae90" />
|
||||||
|
<stop offset="1" stopColor="#2b8f75" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)" />
|
||||||
|
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" strokeWidth="3" strokeLinejoin="round" />
|
||||||
|
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" strokeWidth="8.5" strokeLinecap="round" />
|
||||||
|
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)" />
|
||||||
|
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { getThemeChoice, setThemeChoice, type ThemeChoice } from "../theme";
|
||||||
|
|
||||||
|
const options: { value: ThemeChoice; label: string; icon: typeof Moon }[] = [
|
||||||
|
{ value: "dark", label: "Dark", icon: Moon },
|
||||||
|
{ value: "light", label: "Light", icon: Sun },
|
||||||
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dark / Light / System. `compact` shows icons only, for the sidebar.
|
||||||
|
export function ThemeSwitch({ compact = false }: { compact?: boolean }) {
|
||||||
|
const [choice, setChoice] = useState<ThemeChoice>(getThemeChoice);
|
||||||
|
const pick = (v: ThemeChoice) => {
|
||||||
|
setThemeChoice(v);
|
||||||
|
setChoice(v);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="segmented" role="radiogroup" aria-label="Theme">
|
||||||
|
{options.map((o) => {
|
||||||
|
const Icon = o.icon;
|
||||||
|
return (
|
||||||
|
<button key={o.value} role="radio" aria-checked={choice === o.value} aria-label={o.label} title={o.label} className={choice === o.value ? "active" : ""} onClick={() => pick(o.value)}>
|
||||||
|
<Icon size={14} style={{ verticalAlign: -2 }} />
|
||||||
|
{!compact && <span style={{ marginLeft: 6 }}>{o.label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { initTheme } from "./theme";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initTheme();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api, type SessionInfo } from "../api";
|
|||||||
import { ago, dateTime } from "../format";
|
import { ago, dateTime } from "../format";
|
||||||
import { errorMessage, useAuth, useNow, useToast } from "../state";
|
import { errorMessage, useAuth, useNow, useToast } from "../state";
|
||||||
import { Field, Modal, copyText } from "../components/ui";
|
import { Field, Modal, copyText } from "../components/ui";
|
||||||
|
import { ThemeSwitch } from "../components/ThemeSwitch";
|
||||||
|
|
||||||
export function Account() {
|
export function Account() {
|
||||||
const { me, refresh } = useAuth();
|
const { me, refresh } = useAuth();
|
||||||
@@ -158,6 +159,17 @@ export function Account() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card mt">
|
||||||
|
<div className="card-head">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="muted small" style={{ marginBottom: 10 }}>
|
||||||
|
Dark is the default. The choice is remembered in this browser only.
|
||||||
|
</p>
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="card mt">
|
<div className="card mt">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>Sessions</h2>
|
<h2>Sessions</h2>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { errorMessage, useAuth } from "../state";
|
import { errorMessage, useAuth } from "../state";
|
||||||
import { Field } from "../components/ui";
|
import { Field } from "../components/ui";
|
||||||
|
import { Mark } from "../components/Mark";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { refresh } = useAuth();
|
const { refresh } = useAuth();
|
||||||
@@ -39,10 +40,8 @@ export function Login() {
|
|||||||
<form className="card" onSubmit={submit}>
|
<form className="card" onSubmit={submit}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark" aria-hidden="true">
|
<div className="brand-mark">
|
||||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
<Mark size={44} />
|
||||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="brand-name">WGX</div>
|
<div className="brand-name">WGX</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { errorMessage, useAuth } from "../state";
|
import { errorMessage, useAuth } from "../state";
|
||||||
import { Field } from "../components/ui";
|
import { Field } from "../components/ui";
|
||||||
|
import { Mark } from "../components/Mark";
|
||||||
|
|
||||||
export function Setup() {
|
export function Setup() {
|
||||||
const { refresh } = useAuth();
|
const { refresh } = useAuth();
|
||||||
@@ -35,10 +36,8 @@ export function Setup() {
|
|||||||
<form className="card" onSubmit={submit}>
|
<form className="card" onSubmit={submit}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark" aria-hidden="true">
|
<div className="brand-mark">
|
||||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
<Mark size={44} />
|
||||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="brand-name">WGX</div>
|
<div className="brand-name">WGX</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
|
/* Dark is the default. theme.ts resolves the user's choice (dark, light or
|
||||||
|
follow the system) onto <html data-theme>, so the stylesheet only needs the
|
||||||
|
one override below. */
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f6f5;
|
|
||||||
--bg-elev: #ffffff;
|
|
||||||
--bg-sunken: #e9edeb;
|
|
||||||
--fg: #16201c;
|
|
||||||
--fg-muted: #5c6b64;
|
|
||||||
--fg-faint: #8b9791;
|
|
||||||
--line: #d8dfdb;
|
|
||||||
--line-strong: #b9c4be;
|
|
||||||
--accent: #1f6f5c;
|
|
||||||
--accent-fg: #ffffff;
|
|
||||||
--accent-soft: #dcefe8;
|
|
||||||
--ok: #1d8f4e;
|
|
||||||
--ok-soft: #dcf3e4;
|
|
||||||
--warn: #b7791f;
|
|
||||||
--warn-soft: #fbeed3;
|
|
||||||
--bad: #c2382f;
|
|
||||||
--bad-soft: #f9dedb;
|
|
||||||
--info: #2a6fb0;
|
|
||||||
--rx: #2a6fb0;
|
|
||||||
--tx: #1f6f5c;
|
|
||||||
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06);
|
|
||||||
--radius: 10px;
|
|
||||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
||||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg: #111715;
|
--bg: #111715;
|
||||||
--bg-elev: #19211e;
|
--bg-elev: #19211e;
|
||||||
--bg-sunken: #0c100f;
|
--bg-sunken: #0c100f;
|
||||||
@@ -49,8 +23,35 @@
|
|||||||
--rx: #5f9de0;
|
--rx: #5f9de0;
|
||||||
--tx: #3fae90;
|
--tx: #3fae90;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--radius: 10px;
|
||||||
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f4f6f5;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--bg-sunken: #e9edeb;
|
||||||
|
--fg: #16201c;
|
||||||
|
--fg-muted: #5c6b64;
|
||||||
|
--fg-faint: #8b9791;
|
||||||
|
--line: #d8dfdb;
|
||||||
|
--line-strong: #b9c4be;
|
||||||
|
--accent: #1f6f5c;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
--accent-soft: #dcefe8;
|
||||||
|
--ok: #1d8f4e;
|
||||||
|
--ok-soft: #dcf3e4;
|
||||||
|
--warn: #b7791f;
|
||||||
|
--warn-soft: #fbeed3;
|
||||||
|
--bad: #c2382f;
|
||||||
|
--bad-soft: #f9dedb;
|
||||||
|
--info: #2a6fb0;
|
||||||
|
--rx: #2a6fb0;
|
||||||
|
--tx: #1f6f5c;
|
||||||
|
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06);
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -89,10 +90,7 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
|
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
|
||||||
.brand-mark {
|
.brand-mark { display: grid; place-items: center; flex: none; line-height: 0; }
|
||||||
width: 30px; height: 30px; border-radius: 8px; background: var(--accent);
|
|
||||||
display: grid; place-items: center; color: var(--accent-fg); flex: none;
|
|
||||||
}
|
|
||||||
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
||||||
.brand-sub { font-size: 11px; color: var(--fg-muted); }
|
.brand-sub { font-size: 11px; color: var(--fg-muted); }
|
||||||
.nav a {
|
.nav a {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Theme preference: dark by default, light on request, or follow the OS.
|
||||||
|
// The choice is a per-browser convenience kept in localStorage; the resolved
|
||||||
|
// theme lands on <html data-theme> so the stylesheet needs only one selector.
|
||||||
|
|
||||||
|
export type ThemeChoice = "dark" | "light" | "system";
|
||||||
|
|
||||||
|
const KEY = "wgx.theme";
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
|
||||||
|
export function getThemeChoice(): ThemeChoice {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
if (v === "light" || v === "system" || v === "dark") return v;
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(choice: ThemeChoice): "dark" | "light" {
|
||||||
|
if (choice === "system") return media.matches ? "light" : "dark";
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(choice: ThemeChoice) {
|
||||||
|
document.documentElement.dataset.theme = resolve(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemeChoice(choice: ThemeChoice) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, choice);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
applyTheme(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once at startup: paints the right theme before React renders and
|
||||||
|
// keeps "system" honest when the OS switches.
|
||||||
|
export function initTheme() {
|
||||||
|
applyTheme(getThemeChoice());
|
||||||
|
media.addEventListener("change", () => {
|
||||||
|
if (getThemeChoice() === "system") applyTheme("system");
|
||||||
|
});
|
||||||
|
}
|
||||||