Refactor global styles and enhance MITM functionality

- Updated global CSS to implement a new brand color palette and improve light/dark theme consistency.
- Enhanced the MitmServerCard component to provide clearer user feedback regarding admin privileges.
- Filtered LLM combos in the CombosPage to ensure only relevant data is displayed.
- Improved APIPageClient layout for better usability and visual consistency.
- Added functionality to save and load DNS tool states in the MITM manager.
- Updated OAuth configuration URLs for Qwen to reflect the new endpoint structure.
- Refined tunnel management logic to improve reliability and user experience.
This commit is contained in:
decolua
2026-05-03 18:00:35 +07:00
parent 1686adc704
commit 6cdf40b44e
40 changed files with 1029 additions and 762 deletions
@@ -23,6 +23,8 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
const isAdmin = status?.isAdmin !== false;
// No privilege: not admin/root AND (Win OR no cached sudo password)
const noPrivilege = !isAdmin && (serverIsWindows || (!status?.hasCachedPassword && status?.needsSudoPassword !== false));
const fetchStatus = useCallback(async () => {
try {
@@ -217,6 +219,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
<button
onClick={() => handleAction("start")}
disabled={loading || (serverIsWindows && !isAdmin)}
title={serverIsWindows && !isAdmin ? "Administrator required" : undefined}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5"
>
<span className="material-symbols-outlined text-[16px]">play_circle</span>
+2 -1
View File
@@ -32,7 +32,8 @@ export default function CombosPage() {
const providersData = await providersRes.json();
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
if (combosRes.ok) setCombos(combosData.combos || []);
// Only LLM combos here — webSearch/webFetch combos belong to media-providers/web
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind));
if (providersRes.ok) {
setActiveProviders(providersData.connections || []);
}
@@ -675,8 +675,8 @@ export default function APIPageClient({ machineId }) {
/>
{/* Cloudflare Tunnel */}
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
tunnelEnabled ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" : "bg-sidebar text-text-muted"
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
tunnelEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>Tunnel</span>
{tunnelEnabled && !tunnelLoading ? (
<>
@@ -742,7 +742,6 @@ export default function APIPageClient({ machineId }) {
}
setShowEnableTunnelModal(true);
}}
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
>
Enable
</Button>
@@ -750,8 +749,8 @@ export default function APIPageClient({ machineId }) {
</div>
{/* Tailscale */}
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${
tsEnabled ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" : "bg-sidebar text-text-muted"
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
tsEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>Tailscale</span>
{tsEnabled && !tsLoading ? (
<>
@@ -850,8 +849,8 @@ export default function APIPageClient({ machineId }) {
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">Token Saver</h2>
</div>
<div className="flex items-center justify-between pt-2 pb-4 border-b border-border">
<div className="pr-4">
<div className="flex items-center justify-between pt-2 pb-4 border-b border-border gap-4">
<div className="min-w-0 flex-1">
<p className="font-medium">
Compress tool output{" "}
<a
@@ -864,7 +863,7 @@ export default function APIPageClient({ machineId }) {
</a>
</p>
<p className="text-sm text-text-muted">
Auto-compress tool output (git diff/grep/ls/tree/logs) before sending to LLM (60-90% fewer input tokens on common dev commands). Disable if you see issues.
git/grep/ls/tree/logs 60-90% fewer input tokens
</p>
</div>
<Toggle
@@ -872,8 +871,8 @@ export default function APIPageClient({ machineId }) {
onChange={() => handleRtkEnabled(!rtkEnabled)}
/>
</div>
<div className="flex items-center justify-between pt-4">
<div className="pr-4">
<div className="flex items-center justify-between pt-4 gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="font-medium">
Compress LLM output{" "}
<a
@@ -886,32 +885,34 @@ export default function APIPageClient({ machineId }) {
</a>
</p>
<p className="text-sm text-text-muted">
Inject a terse-style instruction into the system prompt so the LLM replies shorter (~65% fewer output tokens on average, up to 87%). Code, errors and warnings stay exact.
Terse-style system prompt ~65% fewer output tokens (up to 87%)
</p>
</div>
<Toggle
checked={cavemanEnabled}
onChange={() => handleCavemanEnabled(!cavemanEnabled)}
/>
</div>
{cavemanEnabled && (
<div className="mt-3 flex items-center gap-2">
{CAVEMAN_LEVELS.map((lvl) => (
<button
key={lvl.id}
onClick={() => handleCavemanLevel(lvl.id)}
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
cavemanLevel === lvl.id
? "bg-primary text-white border-primary"
: "bg-transparent border-border text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
title={lvl.desc}
>
{lvl.label}
</button>
))}
<div className="flex items-center gap-3 shrink-0">
{cavemanEnabled && (
<div className="flex items-center gap-1.5">
{CAVEMAN_LEVELS.map((lvl) => (
<button
key={lvl.id}
onClick={() => handleCavemanLevel(lvl.id)}
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
cavemanLevel === lvl.id
? "bg-primary text-white border-primary"
: "bg-transparent border-border text-text-muted hover:bg-surface-2"
}`}
title={lvl.desc}
>
{lvl.label}
</button>
))}
</div>
)}
<Toggle
checked={cavemanEnabled}
onChange={() => handleCavemanEnabled(!cavemanEnabled)}
/>
</div>
)}
</div>
</Card>
{/* API Keys */}
@@ -1089,14 +1090,14 @@ export default function APIPageClient({ machineId }) {
onClose={() => setShowEnableTunnelModal(false)}
>
<div className="flex flex-col gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="bg-surface-2 border border-border-subtle rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">cloud_upload</span>
<span className="material-symbols-outlined text-primary">cloud_upload</span>
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-1">
<p className="text-sm text-text-main font-medium mb-1">
Cloudflare Tunnel
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
<p className="text-sm text-text-muted">
Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.
</p>
</div>
@@ -1118,11 +1119,7 @@ export default function APIPageClient({ machineId }) {
</p>
<div className="flex gap-2">
<Button
onClick={handleEnableTunnel}
fullWidth
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
>
<Button onClick={handleEnableTunnel} fullWidth>
Start Tunnel
</Button>
<Button onClick={() => setShowEnableTunnelModal(false)} variant="ghost" fullWidth>Cancel</Button>
@@ -1139,7 +1136,7 @@ export default function APIPageClient({ machineId }) {
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.</p>
<div className="flex gap-2">
<Button onClick={handleDisableTunnel} fullWidth disabled={tunnelLoading} className="bg-red-500! hover:bg-red-600! text-white!">
<Button onClick={handleDisableTunnel} fullWidth disabled={tunnelLoading} variant="danger">
{tunnelLoading ? "Disabling..." : "Disable"}
</Button>
<Button onClick={() => setShowDisableTunnelModal(false)} variant="ghost" fullWidth disabled={tunnelLoading}>Cancel</Button>
@@ -1167,11 +1164,7 @@ export default function APIPageClient({ machineId }) {
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">Tailscale is not installed. Install it to enable Funnel.</p>
<div className="flex gap-2">
<Button
onClick={handleInstallTailscale}
fullWidth
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
<Button onClick={handleInstallTailscale} fullWidth>
Install Tailscale
</Button>
<Button onClick={() => setShowTsModal(false)} variant="ghost" fullWidth>Cancel</Button>
@@ -1211,7 +1204,6 @@ export default function APIPageClient({ machineId }) {
handleConnectTailscale(tab);
}}
fullWidth
className="bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
Connect
</Button>
@@ -1233,7 +1225,7 @@ export default function APIPageClient({ machineId }) {
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.</p>
<div className="flex gap-2">
<Button onClick={handleDisableTailscale} fullWidth disabled={tsLoading} className="bg-red-500! hover:bg-red-600! text-white!">
<Button onClick={handleDisableTailscale} fullWidth disabled={tsLoading} variant="danger">
{tsLoading ? "Disabling..." : "Disable"}
</Button>
<Button onClick={() => setShowDisableTsModal(false)} variant="ghost" fullWidth disabled={tsLoading}>Cancel</Button>
@@ -1248,9 +1240,8 @@ export default function APIPageClient({ machineId }) {
function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
return (
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[68px] text-center ${badge === "CF" ? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400" :
badge === "TS" ? "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" :
"bg-sidebar text-text-muted"
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>{label}</span>
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
<button
@@ -75,11 +75,7 @@ export default function MitmPageClient() {
const mitmTools = Object.entries(MITM_TOOLS);
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-1 sm:px-0">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold text-text-main sm:text-2xl">MITM</h1>
<p className="text-sm text-text-muted">Route supported IDE traffic through 9Router with local DNS interception.</p>
</div>
<div className="flex w-full flex-col gap-6">
{/* MITM Server Card */}
<MitmServerCard
apiKeys={apiKeys}
@@ -762,15 +762,15 @@ export default function ProviderDetailPage() {
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{providerInfo.name}</h1>
{providerInfo.notice?.apiKeyUrl && !providerInfo.deprecated && (
{(providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website) && (
<a
href={providerInfo.notice.apiKeyUrl}
href={providerInfo.notice?.apiKeyUrl || providerInfo.notice?.signupUrl || providerInfo.website}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">open_in_new</span>
Get API Key
{providerInfo.notice?.apiKeyUrl ? "Get API Key" : "Sign up / Learn more"}
</a>
)}
</div>
@@ -911,20 +911,21 @@ export default function ProviderDetailPage() {
</div>
{connections.length === 0 ? (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-primary/10 text-primary shrink-0">
<span className="material-symbols-outlined text-[18px]">{isOAuth ? "lock" : "key"}</span>
</div>
<p className="text-sm text-text-muted">No connections yet</p>
</div>
<p className="text-text-main font-medium mb-1">No connections yet</p>
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
{!isCompatible && (
<div className="flex flex-col gap-2 justify-center sm:flex-row">
<div className="flex gap-2">
{providerId === "iflow" && (
<Button icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
Cookie Auth
<Button size="sm" icon="cookie" variant="secondary" onClick={() => setShowIFlowCookieModal(true)}>
Cookie
</Button>
)}
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
<Button size="sm" icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
{providerId === "iflow" ? "OAuth" : "Add Connection"}
</Button>
</div>
+30 -12
View File
@@ -5,6 +5,13 @@ import { useSearchParams, useRouter } from "next/navigation";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import RequestDetailsTab from "./components/RequestDetailsTab";
const PERIODS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "60d", label: "60D" },
];
export default function UsagePage() {
return (
<Suspense fallback={<CardSkeleton />}>
@@ -18,6 +25,7 @@ function UsageContent() {
const router = useRouter();
const [tabLoading, setTabLoading] = useState(false);
const [period, setPeriod] = useState("7d");
const tabFromUrl = searchParams.get("tab");
const activeTab = tabFromUrl && ["overview", "logs", "details"].includes(tabFromUrl)
@@ -30,21 +38,32 @@ function UsageContent() {
const params = new URLSearchParams(searchParams);
params.set("tab", value);
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
// Brief loading flash so user sees feedback
setTimeout(() => setTabLoading(false), 300);
};
return (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "details", label: "Details" },
]}
value={activeTab}
onChange={handleTabChange}
className="w-full sm:w-auto"
/>
{/* Tabs + period selector on same row */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "details", label: "Details" },
]}
value={activeTab}
onChange={handleTabChange}
className="w-full sm:w-auto"
/>
{activeTab === "overview" && (
<SegmentedControl
options={PERIODS}
value={period}
onChange={setPeriod}
size="sm"
className="w-full sm:w-auto"
/>
)}
</div>
{tabLoading ? (
<CardSkeleton />
@@ -52,7 +71,7 @@ function UsageContent() {
<>
{activeTab === "overview" && (
<Suspense fallback={<CardSkeleton />}>
<UsageStats />
<UsageStats period={period} setPeriod={setPeriod} hidePeriodSelector />
</Suspense>
)}
{activeTab === "logs" && <RequestLogger />}
@@ -62,4 +81,3 @@ function UsageContent() {
</div>
);
}
@@ -46,13 +46,22 @@ function requiresSudoPassword(pwd) {
}
function checkIsAdmin() {
if (!isWin) return true;
try {
require("child_process").execSync("net session >nul 2>&1", { windowsHide: true });
return true;
} catch {
return false;
if (isWin) {
try {
require("child_process").execSync("net session >nul 2>&1", { windowsHide: true });
return true;
} catch {
return false;
}
}
return typeof process.getuid === "function" && process.getuid() === 0;
}
function checkPrivilege(pwd) {
if (checkIsAdmin()) return true;
if (isWin) return false;
if (!isSudoPasswordRequired()) return true;
return !!pwd;
}
// GET - Full MITM status (server + per-tool DNS)
@@ -94,6 +103,13 @@ export async function POST(request) {
);
}
if (!checkPrivilege(pwd)) {
return NextResponse.json(
{ error: isWin ? "Administrator required — restart 9Router as Administrator" : "Root or sudo password required to start MITM" },
{ status: 403 }
);
}
if (mitmRouterBaseUrl !== undefined && mitmRouterBaseUrl !== null) {
try {
const normalized = normalizeMitmRouterBaseUrlInput(mitmRouterBaseUrl);
@@ -149,6 +165,12 @@ export async function PATCH(request) {
if (requiresSudoPassword(pwd)) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
}
if (!checkPrivilege(pwd)) {
return NextResponse.json(
{ error: isWin ? "Administrator required — restart 9Router as Administrator" : "Root or sudo password required to modify DNS" },
{ status: 403 }
);
}
if (action === "enable") {
await enableToolDNS(tool, pwd);
-12
View File
@@ -138,9 +138,6 @@ export async function GET() {
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: combo.name,
parent: null,
});
}
@@ -154,9 +151,6 @@ export async function GET() {
object: "model",
created: timestamp,
owned_by: alias,
permission: [],
root: model.id,
parent: null,
});
}
}
@@ -175,9 +169,6 @@ export async function GET() {
object: "model",
created: timestamp,
owned_by: providerAlias,
permission: [],
root: modelId,
parent: null,
});
}
} else {
@@ -267,9 +258,6 @@ export async function GET() {
object: "model",
created: timestamp,
owned_by: outputAlias,
permission: [],
root: modelId,
parent: null,
});
}
}
+1 -32
View File
@@ -1,32 +1 @@
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*"
};
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
/**
* GET /v1 - Return models list (OpenAI compatible)
*/
export async function GET() {
const models = [
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
{ id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" },
{ id: "gpt-4o", object: "model", owned_by: "openai" },
{ id: "gemini-2.5-pro", object: "model", owned_by: "google" }
];
return new Response(JSON.stringify({
object: "list",
data: models
}), {
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
});
}
export { GET, OPTIONS } from "./models/route";
+313 -131
View File
@@ -3,80 +3,183 @@
@custom-variant dark (&:where(.dark, .dark *));
/* macOS-inspired Color Palette with Terracotta Primary */
/* ============================================================
9Router palette — adopted from 9remote_private/web
Brand orange (dark) / soft coral (light), neutral warm bases
============================================================ */
:root {
/* Primary - Warm Coral/Terracotta */
--color-primary: #D97757;
--color-primary-hover: #C56243;
/* Light theme */
--color-bg: #FBF9F6;
--color-bg-alt: #F5F1ED;
--color-surface: #FFFFFF;
--color-sidebar: rgba(246, 246, 246, 0.8);
--color-border: rgba(0, 0, 0, 0.1);
--color-text-main: #383733;
--color-text-muted: #75736E;
/* Shadows - subtle macOS style */
--shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 12px rgba(0, 0, 0, 0.015);
--shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.12);
/* Brand scale (light) - centered on #E56A4A */
--color-brand-50: #fdf1ed;
--color-brand-100: #fadccf;
--color-brand-200: #f4b59c;
--color-brand-300: #ee8d6a;
--color-brand-400: #ea7855;
--color-brand-500: #E56A4A;
--color-brand-600: #cc5236;
--color-brand-700: #a64027;
--color-brand-800: #7a2f1d;
--color-brand-900: #4d1e12;
/* Primary (legacy alias for backward compat with existing components) */
--color-primary: var(--color-brand-500);
--color-primary-hover: var(--color-brand-600);
/* Surfaces & backgrounds (light) */
--color-bg: #FDFAF6;
--color-bg-alt: #F7F3EE;
--color-surface: #ffffff;
--color-surface-2: #f4f4f5;
--color-surface-3: #e7e7e9;
--color-sidebar: rgba(244, 241, 236, 0.85);
/* Borders */
--color-border: #e5e7eb;
--color-border-subtle: #f1f1f3;
/* Text */
--color-text: #0a0a0a;
--color-text-main: #0a0a0a;
--color-text-muted: #6B7280;
--color-text-subtle: #9CA3AF;
/* Status */
--color-danger: #cf222e;
--color-success: #10B981;
--color-warning: #F59E0B;
--color-info: #3B82F6;
/* Radius */
--radius-brand: 10px;
--radius-brand-lg: 14px;
/* Shadows */
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.04);
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.18);
--shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06);
--shadow-elev:
inset 0 1px 0 0 rgba(255,255,255,0.8),
0 1px 2px rgba(15,23,42,0.04),
0 12px 36px -8px rgba(15,23,42,0.10);
--shadow-focus: 0 0 0 3px rgba(229,106,74,0.18);
color-scheme: light;
}
.dark {
/* Dark theme */
--color-bg: #191918;
/* Brand scale (dark) - centered on #E56A4A, same as light for consistency */
--color-brand-50: #fdf1ed;
--color-brand-100: #fadccf;
--color-brand-200: #f4b59c;
--color-brand-300: #ee8d6a;
--color-brand-400: #ea7855;
--color-brand-500: #E56A4A;
--color-brand-600: #cc5236;
--color-brand-700: #a64027;
--color-brand-800: #7a2f1d;
--color-brand-900: #4d1e12;
--color-primary: #E56A4A;
--color-primary-hover: #cc5236;
/* Surfaces (dark - Claude-like neutral warm) */
--color-bg: #1a1a1a;
--color-bg-alt: #1F1F1E;
--color-surface: #242423;
--color-sidebar: rgba(30, 30, 30, 0.8);
--color-border: rgba(255, 255, 255, 0.1);
--color-text-main: #ECEBE8;
--color-text-muted: #9E9D99;
/* Dark shadows - subtle macOS style */
--shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.15);
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.3);
--color-surface: #262626;
--color-surface-2: #303030;
--color-surface-3: #3a3a3a;
--color-sidebar: rgba(30, 30, 30, 0.85);
--color-border: #333333;
--color-border-subtle: #2a2a2a;
--color-text: #ededed;
--color-text-main: #ededed;
--color-text-muted: #9ca3af;
--color-text-subtle: #6b7280;
--color-danger: #ef4444;
--color-success: #22c55e;
--color-warning: #fbbf24;
--color-info: #60a5fa;
--shadow-soft: 0 1px 2px 0 rgba(0,0,0,0.3);
--shadow-warm: 0 2px 12px -2px rgba(229, 106, 74, 0.25);
--shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.45);
--shadow-elev:
inset 0 1px 0 0 rgba(255,255,255,0.06),
0 1px 2px rgba(0,0,0,0.4),
0 16px 48px -8px rgba(0,0,0,0.55);
--shadow-focus: 0 0 0 3px rgba(229, 106, 74, 0.18);
color-scheme: dark;
}
@theme inline {
/* Primary */
/* Brand scale */
--color-brand-50: var(--color-brand-50);
--color-brand-100: var(--color-brand-100);
--color-brand-200: var(--color-brand-200);
--color-brand-300: var(--color-brand-300);
--color-brand-400: var(--color-brand-400);
--color-brand-500: var(--color-brand-500);
--color-brand-600: var(--color-brand-600);
--color-brand-700: var(--color-brand-700);
--color-brand-800: var(--color-brand-800);
--color-brand-900: var(--color-brand-900);
/* Primary aliases */
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
/* Auto-switch colors (use CSS variables from :root/.dark) */
/* Semantic */
--color-bg: var(--color-bg);
--color-bg-alt: var(--color-bg-alt);
--color-surface: var(--color-surface);
--color-surface-2: var(--color-surface-2);
--color-surface-3: var(--color-surface-3);
--color-sidebar: var(--color-sidebar);
--color-border: var(--color-border);
--color-border-subtle: var(--color-border-subtle);
--color-text: var(--color-text);
--color-text-main: var(--color-text-main);
--color-text-muted: var(--color-text-muted);
/* Static colors (for explicit light/dark usage) */
--color-bg-light: #FBF9F6;
--color-bg-dark: #191918;
--color-surface-light: #FFFFFF;
--color-surface-dark: #242423;
--color-sidebar-light: #F0EFEC;
--color-text-subtle: var(--color-text-subtle);
--color-accent: var(--color-brand-500);
--color-danger: var(--color-danger);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
--color-info: var(--color-info);
/* Static fallbacks (explicit per-mode usage if needed) */
--color-bg-light: #FCFBF9;
--color-bg-dark: #1a1a1a;
--color-surface-light: #ffffff;
--color-surface-dark: #262626;
--color-sidebar-light: #F4F1EC;
--color-sidebar-dark: #1F1F1E;
--color-border-light: #E6E4DD;
--color-border-dark: #333331;
--color-text-main-light: #383733;
--color-text-main-dark: #ECEBE8;
--color-text-muted-light: #75736E;
--color-text-muted-dark: #9E9D99;
--color-border-light: #e5e7eb;
--color-border-dark: #333333;
--color-text-main-light: #0a0a0a;
--color-text-main-dark: #ededed;
--color-text-muted-light: #6B7280;
--color-text-muted-dark: #9ca3af;
/* Radius */
--radius-brand: var(--radius-brand);
--radius-brand-lg: var(--radius-brand-lg);
/* Shadows */
--shadow-soft: var(--shadow-soft);
--shadow-warm: var(--shadow-warm);
--shadow-elevated: var(--shadow-elevated);
/* Font - macOS system fonts */
--font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
--shadow-elev: var(--shadow-elev);
--shadow-focus: var(--shadow-focus);
/* Font - Inter primary, Apple system fallback */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', system-ui, sans-serif;
}
/* Base styles */
/* Base */
body {
background-color: var(--color-bg);
color: var(--color-text-main);
@@ -85,38 +188,94 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Selection */
/* Selection - brand-tinted */
::selection {
background-color: rgba(217, 119, 87, 0.2);
background-color: rgba(229, 106, 74, 0.25);
color: var(--color-primary);
}
.dark ::selection {
background-color: rgba(229, 106, 74, 0.3);
color: var(--color-brand-300);
}
/* Custom scrollbar */
/* iOS keyboard accent */
input, textarea {
accent-color: var(--color-primary);
}
/* Scrollbar (custom-scrollbar utility kept for compat) */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 20px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
background-color: var(--color-primary);
}
/* Hero gradient */
/* Thin horizontal scrollbar - brand colored */
.scroll-thin-x {
scrollbar-width: thin;
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
}
.dark .scroll-thin-x {
scrollbar-color: rgba(229, 106, 74, 0.55) transparent;
}
.scroll-thin-x::-webkit-scrollbar { height: 3px; }
.scroll-thin-x::-webkit-scrollbar-track { background: transparent; }
.scroll-thin-x::-webkit-scrollbar-thumb {
background: rgba(229, 106, 74, 0.55);
border-radius: 3px;
}
.dark .scroll-thin-x::-webkit-scrollbar-thumb {
background: rgba(229, 106, 74, 0.55);
}
/* Reusable elevated card */
.card-soft {
background-color: var(--color-surface);
box-shadow: var(--shadow-soft);
border-radius: var(--radius-brand-lg);
}
.card-elev {
background-color: var(--color-surface);
box-shadow: var(--shadow-elev);
border-radius: var(--radius-brand-lg);
}
/* Hero gradient (compat) */
.bg-hero-gradient {
background: linear-gradient(180deg, #F5F1ED 0%, #FEFCFB 100%);
background: linear-gradient(180deg, var(--color-bg-alt) 0%, var(--color-bg) 100%);
}
.dark .bg-hero-gradient {
background: linear-gradient(180deg, #1F1F1E 0%, #191918 100%);
/* macOS Vibrancy */
.bg-vibrancy {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.72);
}
.dark .bg-vibrancy {
background: rgba(38, 38, 38, 0.72);
}
/* macOS Traffic Lights */
.traffic-lights {
display: flex;
gap: 8px;
}
.traffic-light {
width: 12px;
height: 12px;
border-radius: 50%;
}
.traffic-light.red { background: #FF5F56; }
.traffic-light.yellow { background: #FFBD2E; }
.traffic-light.green { background: #27C93F; }
/* Material Symbols */
.material-symbols-outlined {
@@ -136,86 +295,117 @@ body {
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.material-symbols-outlined.fill-1 {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* Animations */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
/* Disable text selection on buttons */
button {
-webkit-user-select: none;
user-select: none;
}
.animate-spin {
animation: spin 1s linear infinite;
/* ============================================================
Animations
============================================================ */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin { animation: spin 1s linear infinite; }
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes border-glow {
0%, 100% {
box-shadow: 0 0 5px rgba(217, 119, 87, 0.3), 0 0 10px rgba(217, 119, 87, 0.2);
border-color: rgba(217, 119, 87, 0.5);
box-shadow: 0 0 5px rgba(229, 106, 74, 0.3), 0 0 10px rgba(229, 106, 74, 0.2);
border-color: rgba(229, 106, 74, 0.5);
}
50% {
box-shadow: 0 0 10px rgba(217, 119, 87, 0.5), 0 0 20px rgba(217, 119, 87, 0.3);
border-color: rgba(217, 119, 87, 0.8);
box-shadow: 0 0 10px rgba(229, 106, 74, 0.5), 0 0 20px rgba(229, 106, 74, 0.3);
border-color: rgba(229, 106, 74, 0.8);
}
}
.animate-border-glow { animation: border-glow 2s ease-in-out infinite; }
.animate-border-glow {
animation: border-glow 2s ease-in-out infinite;
@keyframes slideInFromRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInFromTop {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.slide-in-right { animation: slideInFromRight 0.25s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
.fade-in { animation: fadeIn 0.2s ease-out forwards; }
.slide-in-top { animation: slideInFromTop 0.18s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
@keyframes pulseGlow {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}
.animate-pulse-glow { animation: pulseGlow 3s ease-in-out infinite; }
/* CTA shimmer + glow pulse */
@keyframes ctaShimmer {
0% { transform: translateX(-120%) skewX(-20deg); }
100% { transform: translateX(220%) skewX(-20deg); }
}
@keyframes ctaGlowPulse {
0%, 100% { box-shadow: 0 8px 24px -8px rgba(229, 106, 74, 0.45), 0 0 0 0 rgba(229, 106, 74, 0.5); }
50% { box-shadow: 0 12px 32px -8px rgba(229, 106, 74, 0.6), 0 0 0 8px rgba(229, 106, 74, 0); }
}
.btn-cta {
position: relative;
overflow: hidden;
isolation: isolate;
animation: ctaGlowPulse 2.4s ease-in-out infinite;
}
.btn-cta::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.35), transparent);
animation: ctaShimmer 2.8s ease-in-out infinite;
pointer-events: none;
z-index: 1;
}
.btn-cta > * { position: relative; z-index: 2; }
/* Dot Grid Background Pattern */
.dot-grid-bg {
background-color: var(--color-bg);
background-image:
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.10) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.06) 0%, transparent 40%);
}
.dark .dot-grid-bg {
background-image:
radial-gradient(circle at 15% 20%, rgba(229, 106, 74, 0.18) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(229, 106, 74, 0.10) 0%, transparent 40%);
}
/* macOS Vibrancy/Blur Effect */
.bg-vibrancy {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.72);
/* Landing-style faint grid overlay (use absolute pos inside relative parent) */
.landing-grid {
background-image:
linear-gradient(to right, var(--color-accent) 1px, transparent 1px),
linear-gradient(to bottom, var(--color-accent) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.08;
}
.dark .bg-vibrancy {
background: rgba(30, 30, 30, 0.72);
}
/* macOS Traffic Lights */
.traffic-lights {
display: flex;
gap: 8px;
}
.traffic-light {
width: 12px;
height: 12px;
border-radius: 50%;
}
.traffic-light.red {
background: #FF5F56;
}
.traffic-light.yellow {
background: #FFBD2E;
}
.traffic-light.green {
background: #27C93F;
.dark .landing-grid {
opacity: 0.04;
}
/* Changelog markdown body */
@@ -226,9 +416,7 @@ body {
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.changelog-body h1:first-child {
margin-top: 0;
}
.changelog-body h1:first-child { margin-top: 0; }
.changelog-body h2 {
font-size: 1.05rem;
font-weight: 600;
@@ -245,14 +433,8 @@ body {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.changelog-body li {
margin: 0.25rem 0;
line-height: 1.6;
}
.changelog-body p {
margin: 0.5rem 0;
line-height: 1.6;
}
.changelog-body li { margin: 0.25rem 0; line-height: 1.6; }
.changelog-body p { margin: 0.5rem 0; line-height: 1.6; }
.changelog-body code {
background: var(--color-bg-alt);
padding: 0.1rem 0.35rem;
+4 -2
View File
@@ -82,8 +82,10 @@ export default function LoginPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-md">
<div className="min-h-screen flex items-center justify-center bg-bg p-4 relative overflow-hidden">
{/* Faint grid background */}
<div className="landing-grid absolute inset-0 pointer-events-none" aria-hidden="true" />
<div className="relative z-10 w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
<p className="text-text-muted">Enter your password to access the dashboard</p>