refactor: extract UI components and redesign desktop client interface with improved visual hierarchy

Extract App.tsx logic into reusable components: AppHeader, ResourcePanel, StatusCard, StatTile, and ActionButton. Replace inline markup with component composition and props-based data flow.

Redesign visual system with enhanced gradients, refined color palette, and improved spacing. Update app-shell grid layout with 18px gaps and 1140px max width. Add radial gradient overlays and linear background
This commit is contained in:
2026-03-18 11:42:34 +01:00
parent 74d8fc28cc
commit 1ddcbf0b14
7 changed files with 681 additions and 265 deletions

View File

@@ -1,5 +1,8 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { AppHeader } from "./components/AppHeader";
import { ResourcePanel } from "./components/ResourcePanel";
import { StatusCard } from "./components/StatusCard";
type EnrollmentState = { type EnrollmentState = {
assignedIp: string; assignedIp: string;
@@ -191,40 +194,23 @@ export function App() {
return ( return (
<div className="client-shell"> <div className="client-shell">
<div className="app-frame"> <div className="app-shell">
<div className="top-strip"> <AppHeader
<div className="brand-lockup"> connected={connected}
<img src="/icon.png" alt="NexaVPN" /> enrolled={Boolean(state)}
<div className="brand-copy"> onLogout={resetEnrollment}
<p className="eyebrow">NexaVPN</p> onSync={onSyncProfile}
<h1>VPN Client</h1> onToggleConnection={toggleConnection}
<p>{state ? "Private access client" : "Sign in to add this device"}</p> syncing={syncing}
</div> />
</div>
<div className="top-actions">
{state ? (
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
{syncing ? "Syncing..." : "Sync"}
</button>
) : null}
{state ? (
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
Logout
</button>
) : null}
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
</button>
</div>
</div>
<div className="body-grid"> <div className="body-grid">
{!state ? ( {!state ? (
<section className="login-panel"> <section className="login-card">
<div className="surface-header"> <div className="panel-head">
<div> <div>
<p className="eyebrow">Sign in</p> <p className="section-eyebrow">Sign in</p>
<h3>Provision device</h3> <h2>Provision device</h2>
</div> </div>
</div> </div>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
@@ -241,75 +227,31 @@ export function App() {
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /> <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label> </label>
{error ? <div className="error">{error}</div> : null} {error ? <div className="error">{error}</div> : null}
<div className="login-card-actions"> <div className="login-actions">
<button className="shell-button" disabled={loading} type="submit"> <button className="action-button action-button-primary" disabled={loading} type="submit">
{loading ? "Provisioning..." : "Sign in"} {loading ? "Provisioning..." : "Sign in"}
</button> </button>
</div> </div>
</form> </form>
</section> </section>
) : ( ) : (
<section className="status-panel"> <div className="main-column">
<div className="status-top"> <StatusCard
<div> accessLabel={profileLabel}
<p className="eyebrow">Status</p> assignedIp={state.assignedIp}
<h3>{connected ? "Connected" : "Disconnected"}</h3> connected={connected}
<div className="status-state"> gatewayEndpoint={state.gatewayEndpoint}
<span className={`status-dot ${connected ? "online" : ""}`} /> lastSyncTime={state.lastSyncTime}
{connected ? "Tunnel active" : "Ready"} />
</div>
</div>
</div>
<div className="surface">
<div className="surface-header">
<div>
<p className="eyebrow">Connection</p>
<h4>Overview</h4>
</div>
</div>
<div className="status-grid">
<div className="detail-card">
<span>Assigned VPN IP</span>
<strong>{state.assignedIp}</strong>
</div>
<div className="detail-card">
<span>Gateway endpoint</span>
<strong>{state.gatewayEndpoint}</strong>
</div>
<div className="detail-card">
<span>Access</span>
<strong>{profileLabel}</strong>
</div>
<div className="detail-card">
<span>Last sync</span>
<strong>{state.lastSyncTime}</strong>
</div>
</div>
</div>
{error ? <div className="error">{error}</div> : null} {error ? <div className="error">{error}</div> : null}
</section> </div>
)} )}
<aside className="status-panel"> <ResourcePanel
<div className="surface-header"> onReset={resetEnrollment}
<div> profileLabel={profileLabel}
<p className="eyebrow">Access</p> resources={state?.resources ?? []}
<h4>Resources</h4> />
</div>
</div>
<ul className="resource-stack">
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
<li key={resource}>{resource}</li>
))}
</ul>
{state ? (
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
Neu einbinden
</button>
) : null}
</aside>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
type ActionButtonProps = {
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit";
disabled?: boolean;
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export function ActionButton({
children,
onClick,
type = "button",
disabled = false,
variant = "secondary"
}: ActionButtonProps) {
return (
<button
className={`action-button action-button-${variant}`}
disabled={disabled}
onClick={onClick}
type={type}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,56 @@
import { ActionButton } from "./ActionButton";
type AppHeaderProps = {
enrolled: boolean;
connected: boolean;
syncing: boolean;
onSync: () => void;
onLogout: () => void;
onToggleConnection: () => void;
};
export function AppHeader({
enrolled,
connected,
syncing,
onSync,
onLogout,
onToggleConnection
}: AppHeaderProps) {
return (
<header className="app-header">
<div className="brand-block">
<div className="brand-icon-shell">
<img className="brand-icon" src="/icon.png" alt="NexaVPN" />
</div>
<div className="brand-text">
<p className="section-eyebrow">NexaVPN</p>
<h1>Secure Access Client</h1>
<p className="brand-subtitle">
Enterprise-grade private network access with policy-based resources.
</p>
</div>
</div>
<div className="header-actions">
{enrolled ? (
<>
<ActionButton disabled={syncing} onClick={onSync} variant="secondary">
{syncing ? "Syncing..." : "Sync"}
</ActionButton>
<ActionButton onClick={onLogout} variant="ghost">
Logout
</ActionButton>
</>
) : null}
<ActionButton
disabled={!enrolled}
onClick={onToggleConnection}
variant={connected ? "danger" : "primary"}
>
{!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"}
</ActionButton>
</div>
</header>
);
}

View File

@@ -0,0 +1,49 @@
import { ActionButton } from "./ActionButton";
function ResourceListItem({ value }: { value: string }) {
return (
<li className="resource-item">
<span className="resource-item-dot" />
<span className="resource-item-text">{value}</span>
</li>
);
}
type ResourcePanelProps = {
resources: string[];
profileLabel: string;
onReset: () => void;
};
export function ResourcePanel({ resources, profileLabel, onReset }: ResourcePanelProps) {
const effectiveResources = resources.length > 0 ? resources : ["Keine Ressourcen zugewiesen"];
return (
<aside className="resource-panel">
<div className="panel-head">
<div>
<p className="section-eyebrow">Access</p>
<h2>Ressourcen</h2>
</div>
<span className="resource-count">{resources.length}</span>
</div>
<div className="resource-meta">
<span className="resource-meta-label">Zugriffsprofil</span>
<strong>{profileLabel}</strong>
</div>
<ul className="resource-list">
{effectiveResources.map((resource) => (
<ResourceListItem key={resource} value={resource} />
))}
</ul>
<div className="resource-footer">
<ActionButton onClick={onReset} variant="secondary">
Neu einbinden
</ActionButton>
</div>
</aside>
);
}

View File

@@ -0,0 +1,17 @@
type StatTileProps = {
label: string;
value: string;
icon: React.ReactNode;
};
export function StatTile({ label, value, icon }: StatTileProps) {
return (
<div className="stat-tile">
<div className="stat-tile-icon">{icon}</div>
<div className="stat-tile-copy">
<span>{label}</span>
<strong>{value}</strong>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { StatTile } from "./StatTile";
function DotGridIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20">
<circle cx="5" cy="5" r="2" />
<circle cx="15" cy="5" r="2" />
<circle cx="5" cy="15" r="2" />
<circle cx="15" cy="15" r="2" />
</svg>
);
}
function ShieldIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20">
<path d="M10 2.5 4.5 4.7v4.2c0 3.4 2.1 6.5 5.5 8.6 3.4-2.1 5.5-5.2 5.5-8.6V4.7Z" />
</svg>
);
}
function NetworkIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20">
<path d="M10 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM4 14.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 0a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6.1 14l2.2-3.1m5.6 3.1-2.2-3.1" />
</svg>
);
}
function ClockIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20">
<path d="M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14Zm0 3v4l2.8 1.7" />
</svg>
);
}
type StatusCardProps = {
connected: boolean;
assignedIp: string;
gatewayEndpoint: string;
accessLabel: string;
lastSyncTime: string;
};
export function StatusCard({
connected,
assignedIp,
gatewayEndpoint,
accessLabel,
lastSyncTime
}: StatusCardProps) {
return (
<section className="status-card">
<div className="status-hero">
<div>
<p className="section-eyebrow">Status</p>
<h2>{connected ? "Connected" : "Disconnected"}</h2>
<p className="status-support">
{connected ? "Secure tunnel established" : "Client is ready to establish a secure tunnel"}
</p>
</div>
<div className={`status-pill ${connected ? "is-online" : ""}`}>
<span className="status-pulse" />
{connected ? "Tunnel active" : "Ready"}
</div>
</div>
<div className="status-details">
<div className="panel-head panel-head-compact">
<div>
<p className="section-eyebrow">Connection</p>
<h3>Overview</h3>
</div>
</div>
<div className="stat-grid">
<StatTile icon={<ShieldIcon />} label="Assigned VPN IP" value={assignedIp} />
<StatTile icon={<NetworkIcon />} label="Gateway endpoint" value={gatewayEndpoint} />
<StatTile icon={<DotGridIcon />} label="Access mode" value={accessLabel} />
<StatTile icon={<ClockIcon />} label="Last sync" value={lastSyncTime} />
</div>
</div>
</section>
);
}

View File

@@ -1,10 +1,10 @@
:root { :root {
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif; font-family: "Segoe UI Variable", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
color: #eef4ff; color: #eef6ff;
background: background:
radial-gradient(circle at 12% 10%, rgba(50, 196, 167, 0.2), transparent 24%), radial-gradient(circle at 12% 12%, rgba(45, 204, 188, 0.18), transparent 24%),
radial-gradient(circle at 90% 18%, rgba(89, 133, 255, 0.16), transparent 22%), radial-gradient(circle at 88% 16%, rgba(64, 116, 255, 0.16), transparent 22%),
linear-gradient(180deg, #07101c 0%, #0c1524 100%); linear-gradient(180deg, #06101d 0%, #0a1423 52%, #0c1523 100%);
} }
* { * {
@@ -14,7 +14,8 @@
html, html,
body, body,
#root { #root {
height: 100vh; width: 100%;
height: 100%;
} }
body { body {
@@ -28,292 +29,529 @@ input {
} }
.client-shell { .client-shell {
height: 100vh; width: 100%;
padding: 12px; height: 100%;
padding: 18px;
overflow: hidden; overflow: hidden;
} }
.app-frame { .app-shell {
width: min(920px, 100%); width: min(1140px, 100%);
height: calc(100vh - 24px); height: 100%;
margin: 0 auto; margin: 0 auto;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
gap: 10px; gap: 18px;
overflow: hidden;
} }
.top-strip { .app-header,
.status-card,
.resource-panel,
.login-card,
.error {
background: linear-gradient(180deg, rgba(14, 24, 40, 0.88) 0%, rgba(11, 19, 33, 0.9) 100%);
border: 1px solid rgba(142, 174, 226, 0.13);
box-shadow: 0 28px 80px rgba(2, 8, 18, 0.34);
}
.app-header {
border-radius: 28px;
padding: 22px 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 24px;
min-height: 112px;
} }
.brand-lockup { .brand-block {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.brand-icon-shell {
width: 64px;
height: 64px;
border-radius: 20px;
display: grid;
place-items: center;
background: linear-gradient(180deg, rgba(17, 45, 64, 0.92) 0%, rgba(11, 24, 36, 0.88) 100%);
border: 1px solid rgba(108, 211, 200, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.brand-icon {
width: 48px;
height: 48px;
}
.brand-text {
min-width: 0;
display: grid;
gap: 4px;
}
.section-eyebrow {
margin: 0;
color: #73dfc0;
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.72rem;
font-weight: 700;
}
.brand-text h1,
.panel-head h2,
.status-hero h2,
.panel-head h3 {
margin: 0;
}
.brand-text h1 {
font-size: 2rem;
line-height: 1.05;
letter-spacing: -0.04em;
}
.brand-subtitle {
margin: 0;
color: #9fb3d4;
font-size: 0.98rem;
max-width: 620px;
}
.header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
}
.brand-lockup img {
width: 44px;
height: 44px;
border-radius: 12px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
}
.brand-copy {
display: grid;
gap: 2px;
}
.eyebrow {
margin: 0;
color: #75e3ba;
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.68rem;
}
.brand-copy h1,
.status-panel h3 {
margin: 0;
line-height: 1.1;
}
.brand-copy h1 {
font-size: 2rem;
}
.brand-copy p {
font-size: 0.98rem;
}
.brand-copy p,
.hero-copy p,
.status-panel p,
.surface-header p,
.detail-card span,
.profile-card span {
margin: 0;
color: #9eb1d1;
}
.top-actions {
display: flex;
gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.shell-button, .action-button {
.shell-button-secondary { border: 1px solid transparent;
border: 0;
border-radius: 999px; border-radius: 999px;
padding: 9px 15px; min-height: 44px;
padding: 0 18px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: 160ms ease; transition:
background-color 180ms ease,
border-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease;
} }
.shell-button { .action-button:hover:not(:disabled) {
background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%); transform: translateY(-1px);
color: #04131a;
} }
.shell-button-secondary { .action-button:active:not(:disabled) {
background: rgba(255, 255, 255, 0.04); transform: translateY(0);
color: #eef4ff;
border: 1px solid rgba(177, 197, 229, 0.16);
} }
.shell-button:disabled, .action-button:disabled {
.shell-button-secondary:disabled { opacity: 0.55;
opacity: 0.58;
cursor: default; cursor: default;
} }
.surface, .action-button-primary {
.status-panel, color: #04131a;
.login-panel { background: linear-gradient(135deg, #73e0c7 0%, #30b89a 100%);
background: rgba(11, 20, 35, 0.78); box-shadow: 0 12px 28px rgba(43, 188, 151, 0.18);
border: 1px solid rgba(177, 197, 229, 0.12); }
box-shadow: 0 24px 70px rgba(2, 8, 18, 0.32);
backdrop-filter: blur(18px); .action-button-secondary {
color: #eef6ff;
background: rgba(255, 255, 255, 0.04);
border-color: rgba(176, 198, 228, 0.14);
}
.action-button-secondary:hover:not(:disabled),
.action-button-ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(176, 198, 228, 0.22);
}
.action-button-ghost {
color: #c0d1ea;
background: transparent;
border-color: rgba(176, 198, 228, 0.08);
}
.action-button-danger {
color: #f8fbff;
background: linear-gradient(135deg, #1f4a54 0%, #107a6e 100%);
border-color: rgba(117, 225, 204, 0.22);
box-shadow: 0 10px 28px rgba(17, 137, 121, 0.18);
} }
.body-grid { .body-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 250px;
gap: 12px;
min-height: 0; min-height: 0;
overflow: hidden; display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
gap: 18px;
align-items: start; align-items: start;
} }
.login-panel, .main-column {
.status-panel {
border-radius: 28px;
padding: 14px;
display: grid;
gap: 10px;
min-height: 0; min-height: 0;
overflow: hidden; display: grid;
grid-template-rows: auto auto;
gap: 14px;
} }
.status-panel > p { .status-card {
margin: 0; min-height: 0;
color: #9eb1d1; border-radius: 30px;
line-height: 1.5; padding: 22px;
display: grid;
gap: 18px;
} }
.status-top { .status-hero {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 16px;
} }
.status-state { .status-hero h2 {
font-size: 2rem;
line-height: 1.02;
letter-spacing: -0.04em;
}
.status-support {
margin: 8px 0 0;
color: #9db3d6;
font-size: 0.98rem;
}
.status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 6px 10px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.045);
color: #c8d6ee; color: #d7e3f5;
border: 1px solid rgba(176, 198, 228, 0.1);
white-space: nowrap;
} }
.status-dot { .status-pill.is-online {
background: rgba(46, 188, 156, 0.12);
border-color: rgba(111, 226, 194, 0.16);
}
.status-pulse {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 999px; border-radius: 999px;
background: #ff8a7d; background: #ff8e84;
box-shadow: 0 0 0 0 rgba(255, 142, 132, 0.34);
} }
.status-dot.online { .status-pill.is-online .status-pulse {
background: #74e0b8; background: #73dfc0;
animation: pulse 1.8s infinite;
} }
.surface { @keyframes pulse {
border-radius: 24px; 0% {
padding: 12px; box-shadow: 0 0 0 0 rgba(115, 223, 192, 0.36);
}
70% {
box-shadow: 0 0 0 10px rgba(115, 223, 192, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(115, 223, 192, 0);
}
}
.status-details {
min-height: 0;
display: grid; display: grid;
gap: 10px; gap: 14px;
padding: 16px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(154, 181, 228, 0.08);
} }
.surface-header { .panel-head {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.surface-header h4 { .panel-head h2 {
margin: 0; font-size: 1.3rem;
font-size: 0.95rem; line-height: 1.1;
} }
.status-grid, .panel-head h3 {
.profile-grid { font-size: 1rem;
line-height: 1.1;
}
.panel-head-compact {
align-items: center;
}
.stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 12px;
align-content: start;
} }
.detail-card, .stat-tile {
.profile-card { min-height: 86px;
padding: 10px 12px; padding: 14px;
border-radius: 16px; border-radius: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(177, 197, 229, 0.1);
display: grid; display: grid;
gap: 4px; grid-template-columns: 34px 1fr;
min-height: 72px; gap: 12px;
align-items: start;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(154, 181, 228, 0.08);
transition:
border-color 180ms ease,
background-color 180ms ease,
transform 180ms ease;
} }
.detail-card strong, .stat-tile:hover {
.profile-card strong { transform: translateY(-1px);
font-size: 0.92rem; border-color: rgba(118, 218, 200, 0.16);
word-break: break-word; background: rgba(255, 255, 255, 0.05);
} }
.detail-card span { .stat-tile-icon {
font-size: 0.9rem; width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: linear-gradient(180deg, rgba(30, 71, 88, 0.75) 0%, rgba(17, 31, 48, 0.72) 100%);
border: 1px solid rgba(111, 226, 194, 0.12);
} }
.resource-stack { .stat-tile-icon svg {
margin: 0; width: 16px;
padding: 0; height: 16px;
list-style: none; fill: none;
stroke: #80e4cc;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.stat-tile-copy {
min-width: 0;
display: grid; display: grid;
gap: 6px; gap: 6px;
align-content: start;
} }
.resource-stack li { .stat-tile-copy span {
padding: 10px 12px; color: #8fa6ca;
border-radius: 14px; font-size: 0.78rem;
background: rgba(255, 255, 255, 0.03); text-transform: uppercase;
border: 1px solid rgba(177, 197, 229, 0.1); letter-spacing: 0.08em;
color: #eef4ff; }
.stat-tile-copy strong {
color: #f4f8ff;
font-size: 1.02rem;
line-height: 1.25;
letter-spacing: -0.015em;
word-break: break-word; word-break: break-word;
min-height: 44px;
display: flex;
align-items: center;
font-size: 0.94rem;
} }
.login-panel form { .resource-panel,
.login-card {
min-height: 0;
border-radius: 30px;
padding: 20px;
display: grid; display: grid;
gap: 16px; gap: 16px;
} }
.login-panel label { .resource-panel {
grid-template-rows: auto auto 1fr auto;
}
.resource-count {
min-width: 34px;
height: 34px;
padding: 0 10px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #80e4cc;
background: rgba(23, 62, 71, 0.8);
border: 1px solid rgba(111, 226, 194, 0.12);
}
.resource-meta {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(154, 181, 228, 0.08);
}
.resource-meta-label {
color: #8fa6ca;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.resource-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
align-content: start;
gap: 10px;
min-height: 0;
}
.resource-item {
min-height: 54px;
padding: 12px 14px;
border-radius: 18px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(154, 181, 228, 0.08);
}
.resource-item-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #73dfc0;
box-shadow: 0 0 0 4px rgba(115, 223, 192, 0.08);
flex: none;
}
.resource-item-text {
font-weight: 600;
color: #eff6ff;
word-break: break-word;
}
.resource-footer {
display: flex;
}
.resource-footer .action-button {
width: 100%;
}
.login-card form {
display: grid;
gap: 16px;
}
.login-card label {
display: grid; display: grid;
gap: 8px; gap: 8px;
color: #c2cfe5; color: #c2cfe5;
} }
.login-panel input { .login-card input {
border: 1px solid rgba(177, 197, 229, 0.16); border: 1px solid rgba(177, 197, 229, 0.16);
background: rgba(7, 14, 27, 0.9); background: rgba(7, 14, 27, 0.9);
color: #f5f7fb; color: #f5f7fb;
border-radius: 16px; border-radius: 16px;
padding: 15px 16px; padding: 14px 16px;
} }
.login-card-actions { .login-actions {
display: flex; display: flex;
gap: 12px;
flex-wrap: wrap;
} }
.error { .error {
padding: 10px 12px; padding: 12px 14px;
border-radius: 16px; border-radius: 18px;
background: rgba(255, 115, 115, 0.08); background: rgba(255, 115, 115, 0.08);
border: 1px solid rgba(255, 115, 115, 0.16); border-color: rgba(255, 115, 115, 0.15);
color: #ffc3c3; color: #ffc3c3;
white-space: pre-wrap; white-space: pre-wrap;
font-size: 0.95rem; }
@media (max-width: 980px) {
.client-shell {
padding: 14px;
}
.app-shell {
width: 100%;
gap: 14px;
}
.app-header {
padding: 18px;
}
.brand-text h1 {
font-size: 1.8rem;
}
.body-grid {
grid-template-columns: minmax(0, 1fr) 260px;
gap: 14px;
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.body-grid { body {
grid-template-columns: 1fr; overflow: auto;
} }
.top-strip, .client-shell {
.status-top, height: auto;
.surface-header { min-height: 100%;
align-items: flex-start; overflow: visible;
}
.app-shell {
height: auto;
grid-template-rows: auto;
}
.app-header,
.status-hero,
.panel-head {
flex-direction: column; flex-direction: column;
align-items: flex-start;
} }
.status-grid, .header-actions,
.profile-grid { .resource-footer,
.login-actions {
width: 100%;
}
.header-actions .action-button,
.resource-footer .action-button,
.login-actions .action-button {
width: 100%;
}
.body-grid,
.stat-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }