Files
Netcatty/index.html
T
陈大猫 2933e108bc feat: support system theme auto-switching (#301)
* feat: support system theme auto-switching\n\nAdd 'system' as a third theme option alongside 'light' and 'dark'.\nWhen set to 'system', the UI theme automatically follows the OS\ncolor scheme preference and switches in real-time when the system\nappearance changes.\n\nChanges:\n- useSettingsState.ts: Add resolvedTheme state derived from\n  matchMedia('prefers-color-scheme: dark'), add listener for\n  system preference changes, update applyThemeTokens to use\n  resolvedTheme instead of theme directly\n- SettingsAppearanceTab.tsx: Replace dark mode Toggle with\n  3-segment selector (Light / System / Dark) using Sun/Monitor/Moon\n  icons\n- en.ts/zh-CN.ts: Replace darkMode i18n keys with new theme keys\n  including 'system' option\n- Default theme changed from 'light' to 'system' for new users\n\nPartially addresses #294

* fix: derive resolvedTheme synchronously and guard matchMedia\n\nAddress Codex review feedback:\n1. Replace resolvedTheme useState+useEffect with synchronous\n   derivation from systemPreference state. This eliminates the\n   one-frame stale render where useLayoutEffect could apply\n   tokens from the old palette before useEffect updated\n   resolvedTheme.\n2. Add window.matchMedia guard in the system preference listener\n   to prevent crashes in jsdom tests or constrained webviews.\n3. Make the matchMedia listener unconditional (always tracks OS\n   preference) to avoid setup/teardown churn when toggling modes.

* fix: resolve 'system' theme in pre-hydration bootstrap to prevent flash

The index.html bootstrap script only handled 'dark'/'light' stored
values. Since DEFAULT_THEME is now 'system', new users (or users who
chose system mode) would get a wrong-theme first paint until React
mounted. Now resolve 'system' via matchMedia('(prefers-color-scheme:
dark)') before applying the CSS class, eliminating the visible flash.

Also use the resolved theme (not raw stored value) for accent foreground
calculation to ensure correct contrast on first paint.

Addresses Codex review on PR #301.

* fix: use resolvedTheme for top-bar toggle to avoid no-op in system mode

When theme preference is 'system' and the OS is dark, the toggle button
showed a moon icon and clicking it just switched from 'system' to 'dark'
— visually a no-op. Now we:

1. Pass resolvedTheme (always 'light'|'dark') to TopTabs for icon display
2. Toggle based on resolvedTheme so the first click always produces a
   visible change (e.g. system+dark → light, system+light → dark)

Addresses Codex review on PR #301.
2026-03-09 21:49:00 +08:00

214 lines
6.6 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
<title>netcatty SSH</title>
<style>
/* Load extended Unicode ranges for terminal box drawing characters */
@font-face {
font-family: 'JetBrains Mono Extended';
src: local('JetBrains Mono'), local('JetBrainsMono-Regular');
unicode-range: U+2500-257F, U+2580-259F, U+25A0-25FF, U+2700-27BF;
}
:root {
--background: 216 33% 96%;
--foreground: 222 47% 12%;
--card: 0 0% 100%;
--card-foreground: 222 47% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 12%;
--primary: 208 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 16% 90%;
--secondary-foreground: 222 47% 12%;
--muted: 220 16% 90%;
--muted-foreground: 220 10% 45%;
--accent: var(--primary);
--accent-foreground: 222 47% 12%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 220 16% 82%;
--input: 220 16% 82%;
--ring: 208 100% 50%;
--radius: 0.65rem;
}
.dark {
--background: 220 28% 8%;
--foreground: 210 40% 95%;
--card: 220 22% 12%;
--card-foreground: 210 40% 95%;
--popover: 220 22% 12%;
--popover-foreground: 210 40% 95%;
--primary: 200 100% 61%;
--primary-foreground: 220 40% 96%;
--secondary: 220 16% 16%;
--secondary-foreground: 210 40% 90%;
--muted: 220 16% 16%;
--muted-foreground: 220 10% 70%;
--accent: var(--primary);
--accent-foreground: 220 40% 96%;
--destructive: 0 70% 50%;
--destructive-foreground: 210 40% 96%;
--border: 220 22% 18%;
--input: 220 22% 18%;
--ring: 200 100% 61%;
--radius: 0.65rem;
}
body {
font-family: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
/* Global Modern Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 99px;
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Utility to hide scrollbar */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Splash screen styles */
.splash-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--background));
z-index: 9999;
transition: opacity 0.2s ease-out;
}
.splash-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.splash-logo {
width: 64px;
height: 64px;
color: hsl(var(--primary));
}
.splash-spinner {
width: 24px;
height: 24px;
border: 2px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<script>
(function () {
try {
var theme = localStorage.getItem('netcatty_theme_v1');
var accentMode = localStorage.getItem('netcatty_accent_mode_v1');
var accentColor = localStorage.getItem('netcatty_color_v1');
var lang = localStorage.getItem('netcatty_ui_language_v1');
var root = document.documentElement;
// Resolve 'system' (or absent — default is 'system') via OS preference
var resolved = theme;
if (!theme || theme === 'system') {
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
if (resolved === 'dark' || resolved === 'light') {
root.classList.remove('light', 'dark');
root.classList.add(resolved);
}
if (accentMode === 'custom' && accentColor) {
root.style.setProperty('--primary', accentColor);
root.style.setProperty('--accent', accentColor);
root.style.setProperty('--ring', accentColor);
var parts = accentColor.split(/\s+/);
var lightness = parseFloat((parts[2] || '').replace('%', ''));
var accentForeground = resolved === 'dark'
? '220 40% 96%'
: (!isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--accent-foreground', accentForeground);
root.style.setProperty('--primary-foreground', accentForeground);
}
if (lang) root.lang = lang;
} catch (e) {
// ignore
}
})();
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-background text-foreground overflow-hidden antialiased h-screen w-screen">
<div id="root" class="h-full w-full">
<!-- Splash screen: shown while React loads, hidden after first paint -->
<div id="splash" class="splash-screen">
<div class="splash-content">
<svg class="splash-logo" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.1" />
<path
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
stroke="currentColor" stroke-width="2" />
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<div class="splash-spinner"></div>
</div>
</div>
</div>
<script type="module" src="/index.tsx"></script>
</body>
</html>