Build MVP with Pi installer and atomic updates
This commit is contained in:
+378
@@ -0,0 +1,378 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowClockwise,
|
||||
CheckCircle,
|
||||
Cpu,
|
||||
Gauge,
|
||||
HardDrives,
|
||||
House,
|
||||
Gear,
|
||||
GitBranch,
|
||||
DownloadSimple,
|
||||
SignOut,
|
||||
TerminalWindow,
|
||||
WarningCircle,
|
||||
WifiHigh
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
ApiError,
|
||||
getAuthState,
|
||||
getStatus,
|
||||
getUpdateStatus,
|
||||
post,
|
||||
type AuthState,
|
||||
type SystemStatus,
|
||||
type UpdateStatus
|
||||
} from "./api";
|
||||
|
||||
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
||||
|
||||
export function App() {
|
||||
const [screen, setScreen] = useState<Screen>("loading");
|
||||
const [auth, setAuth] = useState<AuthState | null>(null);
|
||||
const [fatalError, setFatalError] = useState("");
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
const state = await getAuthState();
|
||||
setAuth(state);
|
||||
setScreen(state.setupRequired ? "setup" : state.user ? "dashboard" : "login");
|
||||
} catch (error) {
|
||||
setFatalError(error instanceof Error ? error.message : "The companion service is unreachable");
|
||||
setScreen("fatal");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => void refreshAuth(), [refreshAuth]);
|
||||
|
||||
if (screen === "loading") return <BootScreen />;
|
||||
if (screen === "fatal") return <ConnectionError message={fatalError} retry={() => void refreshAuth()} />;
|
||||
if (!auth) return null;
|
||||
if (screen === "setup") return <AccountScreen mode="setup" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
||||
if (screen === "login") return <AccountScreen mode="login" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
||||
return <Dashboard auth={auth} onLoggedOut={refreshAuth} />;
|
||||
}
|
||||
|
||||
function BootScreen() {
|
||||
return (
|
||||
<main className="center-screen" aria-live="polite">
|
||||
<div className="boot-mark"><Gauge size={44} weight="duotone" /></div>
|
||||
<p className="eyebrow">Pi Car Companion</p>
|
||||
<h1>Connecting to your Pi</h1>
|
||||
<div className="loading-line" aria-hidden="true"><span /></div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionError({ message, retry }: { message: string; retry: () => void }) {
|
||||
return (
|
||||
<main className="center-screen error-screen">
|
||||
<WarningCircle size={52} weight="duotone" aria-hidden="true" />
|
||||
<p className="eyebrow">Connection unavailable</p>
|
||||
<h1>The Pi did not respond</h1>
|
||||
<p className="screen-copy">{message}. Check power and network access, then reconnect.</p>
|
||||
<button className="primary-button" onClick={retry}><ArrowClockwise size={22} /> Reconnect</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountScreen({
|
||||
mode,
|
||||
csrfToken,
|
||||
onComplete
|
||||
}: {
|
||||
mode: "setup" | "login";
|
||||
csrfToken: string;
|
||||
onComplete: () => Promise<void>;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const setup = mode === "setup";
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (setup) await post("/api/auth/setup", { username, password }, csrfToken);
|
||||
await post("/api/auth/login", { username, password }, csrfToken);
|
||||
await onComplete();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : "The request could not be completed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="account-layout">
|
||||
<section className="account-context">
|
||||
<div className="brand"><Gauge size={32} weight="duotone" /><span>Pi Car Companion</span></div>
|
||||
<div>
|
||||
<p className="eyebrow">{setup ? "First run" : "Local access"}</p>
|
||||
<h1>{setup ? "Secure your companion." : "Welcome back."}</h1>
|
||||
<p>{setup ? "Create the only initial administrator. There are no default credentials." : "Sign in to view live Pi status and controlled actions."}</p>
|
||||
</div>
|
||||
<p className="local-note"><WifiHigh size={22} /> Credentials stay on this Pi.</p>
|
||||
</section>
|
||||
<section className="account-form-wrap">
|
||||
<form className="account-form" onSubmit={(event) => void submit(event)}>
|
||||
<h2>{setup ? "Create administrator" : "Sign in"}</h2>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={48} required autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" autoComplete={setup ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} maxLength={256} required />
|
||||
{setup && <small>Use at least 12 characters.</small>}
|
||||
</label>
|
||||
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||
<button className="primary-button" type="submit" disabled={submitting}>
|
||||
{submitting ? "Working..." : setup ? "Create account" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
|
||||
const [page, setPage] = useState<"home" | "settings">("home");
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setStatus(await getStatus());
|
||||
setError("");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Status could not be loaded");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const events = new EventSource("/api/events");
|
||||
events.addEventListener("open", () => setConnection("live"));
|
||||
events.addEventListener("status", (event) => {
|
||||
setStatus(JSON.parse((event as MessageEvent<string>).data) as SystemStatus);
|
||||
setConnection("live");
|
||||
setError("");
|
||||
});
|
||||
events.addEventListener("error", () => setConnection("offline"));
|
||||
return () => events.close();
|
||||
}, [refresh]);
|
||||
|
||||
async function logout() {
|
||||
await post("/api/auth/logout", {}, auth.csrfToken);
|
||||
await onLoggedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
||||
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
|
||||
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
||||
</nav>
|
||||
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
|
||||
</aside>
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className={`connection-state ${connection}`}>
|
||||
<span aria-hidden="true" /> {connection === "live" ? "Live" : connection === "connecting" ? "Connecting" : "Reconnecting"}
|
||||
</div>
|
||||
{page === "home" && <button className="secondary-button" onClick={() => void refresh()} disabled={refreshing}>
|
||||
<ArrowClockwise size={22} className={refreshing ? "spin" : ""} /> Refresh
|
||||
</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{page === "home" ? <>
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||
</> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const running = update?.state === "checking" || update?.state === "building";
|
||||
|
||||
const refreshUpdate = useCallback(async () => {
|
||||
try {
|
||||
setUpdate(await getUpdateStatus());
|
||||
setError("");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Update status is unavailable");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUpdate();
|
||||
const interval = window.setInterval(() => void refreshUpdate(), 3_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refreshUpdate]);
|
||||
|
||||
async function startUpdate() {
|
||||
setConfirming(false);
|
||||
setRequesting(true);
|
||||
setError("");
|
||||
try {
|
||||
await post("/api/system/update", {}, csrfToken);
|
||||
await refreshUpdate();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : "The update could not be started");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
|
||||
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<section className="settings-panel">
|
||||
<div className="settings-title">
|
||||
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
||||
<div><h2>Software update</h2><p>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></div>
|
||||
</div>
|
||||
|
||||
<dl className="update-facts">
|
||||
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
|
||||
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
|
||||
<div><dt>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="update-message" aria-live="polite">
|
||||
{running && <ArrowClockwise size={22} className="spin" />}
|
||||
{!running && update?.state === "failed" && <WarningCircle size={22} />}
|
||||
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
|
||||
<span>{update?.message ?? "Reading update status..."}</span>
|
||||
</div>
|
||||
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
||||
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</button>
|
||||
</div>
|
||||
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-panel compact-panel">
|
||||
<h2>Service behavior</h2>
|
||||
<dl className="behavior-list">
|
||||
<div><dt>Start on boot</dt><dd>{update?.supported ? "Enabled by systemd" : "Available after install"}</dd></div>
|
||||
<div><dt>Failure recovery</dt><dd>{update?.supported ? "Automatic restart" : "Available after install"}</dd></div>
|
||||
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
||||
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<h2 id="update-dialog-title">Install the latest version?</h2>
|
||||
<p>The dashboard may disconnect briefly when the service restarts. The previous version remains active if verification fails.</p>
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
<button className="primary-button" onClick={() => void startUpdate()}>Install update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusContent({ status }: { status: SystemStatus }) {
|
||||
const memoryPercent = percent(status.memory.usedBytes, status.memory.totalBytes);
|
||||
const diskPercent = status.disk.available ? percent(status.disk.value.usedBytes, status.disk.value.totalBytes) : null;
|
||||
const primaryAddress = status.network.interfaces[0]?.address;
|
||||
const temperature = status.cpu.temperatureCelsius.available
|
||||
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
|
||||
: "Unavailable";
|
||||
|
||||
return (
|
||||
<div className="status-layout">
|
||||
<section className="health-summary">
|
||||
<div className="summary-heading">
|
||||
<div className="health-icon"><CheckCircle size={38} weight="fill" /></div>
|
||||
<div><p>Companion service</p><h2>Running normally</h2></div>
|
||||
</div>
|
||||
<dl className="summary-details">
|
||||
<div><dt>Host uptime</dt><dd>{formatUptime(status.uptimeSeconds)}</dd></div>
|
||||
<div><dt>Service uptime</dt><dd>{formatUptime(status.service.processUptimeSeconds)}</dd></div>
|
||||
<div><dt>Last update</dt><dd>{new Date(status.collectedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="System metrics">
|
||||
<Metric icon={<Cpu size={28} />} label="CPU temperature" value={temperature} note={`Load ${status.cpu.loadAverage[0]?.toFixed(2) ?? "Unavailable"}`} />
|
||||
<Metric icon={<Gauge size={28} />} label="Memory used" value={`${memoryPercent}%`} note={`${formatBytes(status.memory.availableBytes)} available`} meter={memoryPercent} />
|
||||
<Metric icon={<HardDrives size={28} />} label="Disk used" value={diskPercent === null ? "Unavailable" : `${diskPercent}%`} note={status.disk.available ? `${formatBytes(status.disk.value.availableBytes)} available` : status.disk.reason} meter={diskPercent ?? undefined} />
|
||||
<Metric icon={<WifiHigh size={28} />} label="Local network" value={primaryAddress ?? "Unavailable"} note={status.network.interfaces[0]?.name ?? status.network.interfaceReason ?? "No active interface"} />
|
||||
</section>
|
||||
|
||||
<section className="system-strip">
|
||||
<div><span>Operating system</span><strong>{status.operatingSystem}</strong></div>
|
||||
<div><span>Processor</span><strong>{status.cpu.model}</strong></div>
|
||||
<div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon, label, value, note, meter }: { icon: React.ReactNode; label: string; value: string; note: string; meter?: number | undefined }) {
|
||||
return (
|
||||
<article className="metric">
|
||||
<div className="metric-label">{icon}<span>{label}</span></div>
|
||||
<strong className="metric-value">{value}</strong>
|
||||
<p>{note}</p>
|
||||
{meter !== undefined && <div className="meter" aria-label={`${label}: ${meter}%`}><span style={{ width: `${meter}%` }} /></div>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return <div className="skeleton-grid" aria-label="Loading system status"><div /><div /><div /><div /><div /></div>;
|
||||
}
|
||||
|
||||
function percent(used: number, total: number): number {
|
||||
return total > 0 ? Math.round((used / total) * 100) : 0;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
||||
return `${value.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export type User = { id: number; username: string; role: "admin" };
|
||||
|
||||
export type AuthState = {
|
||||
setupRequired: boolean;
|
||||
user: User | null;
|
||||
csrfToken: string;
|
||||
};
|
||||
|
||||
export type Availability<T> =
|
||||
| { available: true; value: T }
|
||||
| { available: false; reason: string };
|
||||
|
||||
export type SystemStatus = {
|
||||
collectedAt: string;
|
||||
hostname: string;
|
||||
uptimeSeconds: number;
|
||||
operatingSystem: string;
|
||||
cpu: {
|
||||
model: string;
|
||||
loadAverage: number[];
|
||||
temperatureCelsius: Availability<number>;
|
||||
};
|
||||
memory: { totalBytes: number; usedBytes: number; availableBytes: number };
|
||||
disk: Availability<{ totalBytes: number; usedBytes: number; availableBytes: number; mount: string }>;
|
||||
network: {
|
||||
interfaces: Array<{ name: string; address: string; family: string }>;
|
||||
interfaceReason: string | null;
|
||||
wifi: Availability<string>;
|
||||
};
|
||||
service: { state: "healthy"; processUptimeSeconds: number };
|
||||
};
|
||||
|
||||
export type UpdateStatus = {
|
||||
state: "idle" | "checking" | "building" | "current" | "success" | "failed";
|
||||
message: string;
|
||||
fromRevision: string | null;
|
||||
toRevision: string | null;
|
||||
updatedAt: string;
|
||||
installedRevision: string | null;
|
||||
supported: boolean;
|
||||
};
|
||||
|
||||
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json().catch(() => ({}))) as T & ErrorResponse;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(data.error?.message ?? "The request failed", response.status, data.error?.code ?? "REQUEST_FAILED");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAuthState(): Promise<AuthState> {
|
||||
return parseResponse<AuthState>(await fetch("/api/auth/state", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body: unknown, csrfToken: string): Promise<T> {
|
||||
return parseResponse<T>(
|
||||
await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<SystemStatus> {
|
||||
return parseResponse<SystemStatus>(await fetch("/api/status", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||
return parseResponse<UpdateStatus>(await fetch("/api/system/update", { credentials: "same-origin" }));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,177 @@
|
||||
:root {
|
||||
font-family: "Aptos", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
|
||||
color: #f0f2eb;
|
||||
background: #10130f;
|
||||
font-synthesis: none;
|
||||
--bg: #10130f;
|
||||
--surface: #171b16;
|
||||
--surface-raised: #1d221c;
|
||||
--line: #30372d;
|
||||
--text: #f0f2eb;
|
||||
--muted: #aeb7a7;
|
||||
--accent: #b9e769;
|
||||
--accent-ink: #172008;
|
||||
--danger: #ff9b8d;
|
||||
--radius: 14px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { min-height: 100dvh; background: var(--bg); }
|
||||
button, input { font: inherit; }
|
||||
button { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, input:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: clamp(2rem, 3vw, 3.15rem); line-height: 1; letter-spacing: -0.045em; }
|
||||
h2 { letter-spacing: -0.025em; }
|
||||
|
||||
.eyebrow { margin-bottom: 12px; color: var(--accent); font: 700 0.76rem/1.2 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; font-weight: 750; letter-spacing: -0.02em; }
|
||||
.brand svg, .boot-mark svg { color: var(--accent); }
|
||||
|
||||
.center-screen { min-height: 100dvh; display: grid; place-content: center; justify-items: center; padding: 32px; text-align: center; }
|
||||
.center-screen h1 { margin-bottom: 28px; }
|
||||
.boot-mark { display: grid; place-items: center; width: 74px; height: 74px; margin-bottom: 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.loading-line { width: min(320px, 70vw); height: 3px; overflow: hidden; border-radius: 2px; background: var(--line); }
|
||||
.loading-line span { display: block; width: 45%; height: 100%; background: var(--accent); animation: load 1.2s ease-in-out infinite alternate; }
|
||||
@keyframes load { from { transform: translateX(-20%); } to { transform: translateX(145%); } }
|
||||
.error-screen svg { color: var(--danger); margin-bottom: 22px; }
|
||||
.screen-copy { max-width: 520px; color: var(--muted); line-height: 1.6; }
|
||||
|
||||
.primary-button, .secondary-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; }
|
||||
.primary-button { color: var(--accent-ink); background: var(--accent); }
|
||||
.secondary-button { color: var(--text); background: var(--surface-raised); border: 1px solid var(--line); }
|
||||
.primary-button:hover { background: #c8f17f; }
|
||||
.secondary-button:hover { background: #262c24; }
|
||||
.primary-button:active, .secondary-button:active, .nav-item:active { transform: scale(0.98); }
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.account-layout { min-height: 100dvh; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr); }
|
||||
.account-context { display: flex; flex-direction: column; justify-content: space-between; min-height: 100dvh; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--line); background: radial-gradient(circle at 20% 55%, rgba(185, 231, 105, 0.08), transparent 33%), var(--bg); }
|
||||
.account-context h1 { max-width: 760px; margin-bottom: 22px; font-size: clamp(3rem, 6vw, 6.4rem); }
|
||||
.account-context > div > p:not(.eyebrow) { max-width: 560px; color: var(--muted); font-size: 1.15rem; line-height: 1.6; }
|
||||
.local-note { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--muted); }
|
||||
.account-form-wrap { display: grid; place-items: center; padding: 42px; background: var(--surface); }
|
||||
.account-form { width: min(100%, 460px); }
|
||||
.account-form h2 { margin-bottom: 36px; font-size: 1.8rem; }
|
||||
.account-form label { display: grid; gap: 9px; margin-bottom: 22px; color: #dce1d7; font-weight: 650; }
|
||||
.account-form input { min-height: 60px; width: 100%; padding: 0 16px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 10px; }
|
||||
.account-form small { color: var(--muted); font-weight: 450; }
|
||||
.account-form .primary-button { width: 100%; margin-top: 10px; }
|
||||
.form-error, .status-error { display: flex; align-items: center; gap: 10px; padding: 14px 16px; margin-bottom: 16px; color: #ffd5cf; background: #321d19; border: 1px solid #6b332a; border-radius: 10px; }
|
||||
|
||||
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.sidebar { position: sticky; top: 0; height: 100dvh; display: flex; flex-direction: column; padding: 28px 18px 18px; border-right: 1px solid var(--line); background: #121610; }
|
||||
.brand-compact { padding: 0 12px 30px; }
|
||||
.sidebar nav { display: grid; gap: 8px; }
|
||||
.nav-item { min-height: 58px; width: 100%; display: flex; align-items: center; gap: 13px; padding: 0 14px; color: var(--muted); background: transparent; border: 0; border-radius: 10px; font-weight: 700; text-align: left; cursor: pointer; }
|
||||
.nav-item.active { color: var(--accent-ink); background: var(--accent); }
|
||||
.nav-item small { margin-left: auto; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.nav-item.logout { margin-top: auto; }
|
||||
|
||||
.dashboard { min-width: 0; padding: clamp(26px, 3.4vw, 52px); }
|
||||
.dashboard-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 30px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.connection-state { min-height: 48px; display: flex; align-items: center; gap: 9px; padding: 0 15px; color: var(--muted); font-weight: 700; }
|
||||
.connection-state span { width: 9px; height: 9px; border-radius: 50%; background: #86907f; }
|
||||
.connection-state.live span { background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.1); }
|
||||
.connection-state.offline span { background: var(--danger); }
|
||||
.status-error { margin: 0 0 20px; }
|
||||
.status-error button { margin-left: auto; color: #ffe6e2; background: transparent; border: 0; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
.status-layout { display: grid; gap: 18px; }
|
||||
.health-summary { display: grid; grid-template-columns: minmax(340px, 1.2fr) minmax(460px, 1fr); align-items: center; min-height: 150px; padding: 26px 30px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.summary-heading { display: flex; align-items: center; gap: 18px; }
|
||||
.health-icon { width: 64px; height: 64px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; }
|
||||
.summary-heading p { margin: 0 0 5px; color: var(--muted); }
|
||||
.summary-heading h2 { margin: 0; font-size: 1.65rem; }
|
||||
.summary-details { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; }
|
||||
.summary-details div { padding-left: 22px; border-left: 1px solid var(--line); }
|
||||
.summary-details dt, .system-strip span { margin-bottom: 7px; color: var(--muted); font-size: 0.8rem; }
|
||||
.summary-details dd { margin: 0; font: 700 1.05rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
|
||||
.metric { min-width: 0; padding: 22px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.metric-label { display: flex; align-items: center; gap: 10px; color: var(--muted); }
|
||||
.metric-label svg { color: var(--accent); flex: none; }
|
||||
.metric-value { display: block; overflow: hidden; margin: 25px 0 7px; font: 750 clamp(1.55rem, 2vw, 2.35rem)/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.06em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric p { min-height: 2.6em; margin: 0; color: var(--muted); font-size: 0.83rem; line-height: 1.35; }
|
||||
.meter { height: 3px; margin-top: 17px; background: var(--line); }
|
||||
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||
.system-strip { display: grid; grid-template-columns: 1fr 1.4fr 1.2fr; gap: 32px; padding: 22px 28px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.system-strip div { min-width: 0; display: grid; }
|
||||
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; }
|
||||
.settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.settings-title { display: flex; align-items: flex-start; gap: 18px; }
|
||||
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
|
||||
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.settings-icon { flex: none; width: 56px; height: 56px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; }
|
||||
.update-facts { display: grid; grid-template-columns: 0.8fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.update-facts div { display: grid; gap: 7px; }
|
||||
.update-facts dt, .behavior-list dt { color: var(--muted); font-size: 0.8rem; }
|
||||
.update-facts dd, .behavior-list dd { display: flex; align-items: center; gap: 7px; margin: 0; font-weight: 700; }
|
||||
.update-facts code { font-family: ui-monospace, "Cascadia Code", monospace; color: #dce4d6; }
|
||||
.update-state.failed { color: var(--danger); }
|
||||
.update-state.success, .update-state.current, .update-state.building, .update-state.checking { color: var(--accent); }
|
||||
.update-message { min-height: 56px; display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #dce4d6; background: #121610; border-radius: 10px; }
|
||||
.update-message svg { flex: none; color: var(--accent); }
|
||||
.update-message .ph-warning-circle { color: var(--danger); }
|
||||
.settings-actions { display: flex; gap: 12px; margin-top: 22px; }
|
||||
.settings-note { max-width: 680px; margin: 20px 0 0; color: var(--muted); font-size: 0.83rem; line-height: 1.5; }
|
||||
.behavior-list { display: grid; gap: 24px; margin: 26px 0 0; }
|
||||
.behavior-list div { display: grid; gap: 7px; }
|
||||
.behavior-list dd { color: #dce4d6; }
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 24px; background: rgba(8, 10, 8, 0.78); }
|
||||
.confirm-dialog { width: min(520px, 100%); padding: 28px; background: var(--surface-raised); border: 1px solid #465043; border-radius: var(--radius); box-shadow: 0 24px 80px rgba(3, 5, 3, 0.45); }
|
||||
.confirm-dialog h2 { margin-bottom: 12px; font-size: 1.6rem; }
|
||||
.confirm-dialog p { color: var(--muted); line-height: 1.6; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 28px; }
|
||||
|
||||
.skeleton-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
.skeleton-grid div { min-height: 210px; border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 30%, var(--surface-raised) 50%, var(--surface) 70%); background-size: 220% 100%; animation: shimmer 1.4s ease infinite; }
|
||||
.skeleton-grid div:first-child { grid-column: 1 / -1; min-height: 150px; }
|
||||
@keyframes shimmer { to { background-position-x: -220%; } }
|
||||
.spin { animation: spin 800ms linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell { grid-template-columns: 86px minmax(0, 1fr); }
|
||||
.brand-compact span, .nav-item span, .nav-item small { display: none; }
|
||||
.brand-compact, .nav-item { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
.health-summary { grid-template-columns: 1fr; gap: 24px; }
|
||||
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.account-layout { grid-template-columns: 1fr; }
|
||||
.account-context { min-height: auto; gap: 64px; padding: 28px 22px 38px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.account-context h1 { font-size: 3rem; }
|
||||
.account-form-wrap { padding: 38px 22px; }
|
||||
.app-shell { display: block; padding-bottom: 76px; }
|
||||
.sidebar { position: fixed; top: auto; bottom: 0; z-index: 10; width: 100%; height: 72px; flex-direction: row; padding: 8px 12px; border-top: 1px solid var(--line); border-right: 0; }
|
||||
.brand-compact { display: none; }
|
||||
.sidebar nav { display: flex; flex: 1; }
|
||||
.nav-item { width: 72px; min-height: 56px; }
|
||||
.nav-item.logout { margin: 0 0 0 auto; }
|
||||
.dashboard { padding: 26px 18px; }
|
||||
.dashboard-header { align-items: flex-start; }
|
||||
.connection-state { padding: 0; }
|
||||
.connection-state:not(.offline) { font-size: 0; }
|
||||
.secondary-button { width: 56px; padding: 0; font-size: 0; }
|
||||
.health-summary { min-width: 0; padding: 22px; }
|
||||
.summary-details { grid-template-columns: 1fr; gap: 16px; }
|
||||
.summary-details div { padding: 0; border: 0; }
|
||||
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
|
||||
.update-facts { grid-template-columns: 1fr; }
|
||||
.settings-actions, .dialog-actions { flex-direction: column; }
|
||||
.settings-actions button, .dialog-actions button { width: 100%; }
|
||||
.skeleton-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
Reference in New Issue
Block a user