feat(sync): 添加 Vue TypeScript 管理控制台

This commit is contained in:
2026-09-09 20:59:43 +08:00
parent 28f7d9f4e6
commit 88799ddee1
21 changed files with 1760 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { SyncApi, type Device, type ServiceStatus, type Vault } from './api'
const api = new SyncApi()
const service = reactive<ServiceStatus>({ health: false, ready: false, protocol: 1, maxObjectSize: 100 * 1024 * 1024 })
const checking = ref(true)
const signedIn = ref(false)
const busy = ref(false)
const username = ref('')
const password = ref('')
const deviceName = ref('OpenNexus Web Console')
const sessionLabel = ref('')
const newVaultName = ref('')
const vaults = ref<Vault[]>([])
const devices = ref<Device[]>([])
const toast = ref('')
const toastError = ref(false)
let toastTimer: number | undefined
let statusTimer: number | undefined
const used = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.used || 0), 0))
const quota = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.quota || 0), 0))
const activeDevices = computed(() => devices.value.filter(device => !device.revoked).length)
function formatBytes(value: number) {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let size = Number(value) || 0
let unit = 0
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1 }
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
}
function shortId(value: string) { return `${value.slice(0, 12)}` }
function usagePercent(vault: Vault) { return Math.min(100, vault.quota ? vault.used / vault.quota * 100 : 0) }
function firstCharacter(value: string, fallback: string) { return value.trim().slice(0, 1).toUpperCase() || fallback }
function notify(text: string, error = false) {
toast.value = text
toastError.value = error
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => { toast.value = '' }, 4800)
}
async function refreshStatus() {
checking.value = true
try { Object.assign(service, await api.status()) }
finally { checking.value = false }
}
async function loadAccount() {
const [vaultPayload, devicePayload] = await Promise.all([api.vaults(), api.devices()])
vaults.value = vaultPayload.items
devices.value = devicePayload.items
}
async function refreshAccount() {
if (busy.value) return
busy.value = true
try {
await loadAccount()
notify('账户数据已刷新')
} catch (error) {
if (!api.signedIn) leaveConsole()
notify(error instanceof Error ? error.message : 'REFRESH_FAILED', true)
} finally { busy.value = false }
}
function leaveConsole() {
api.clear()
signedIn.value = false
password.value = ''
vaults.value = []
devices.value = []
}
async function signIn() {
if (busy.value) return
busy.value = true
const secret = password.value
const account = username.value.trim()
const device = deviceName.value.trim()
password.value = ''
try {
await api.login(account, secret, device)
sessionLabel.value = `${account} · ${device}`
signedIn.value = true
await loadAccount()
notify('设备会话已建立')
} catch (error) {
leaveConsole()
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
} finally { busy.value = false }
}
async function createVault() {
const name = newVaultName.value.trim()
if (!name || busy.value) return
busy.value = true
try {
await api.createVault(name)
newVaultName.value = ''
await loadAccount()
notify(`已创建 Vault${name}`)
} catch (error) {
notify(error instanceof Error ? error.message : 'CREATE_VAULT_FAILED', true)
} finally { busy.value = false }
}
async function revokeDevice(device: Device) {
if (!window.confirm(`撤销设备“${device.name}”?该设备的下一次请求会被拒绝。`)) return
busy.value = true
try {
await api.revokeDevice(device.id)
if (device.id === api.deviceId) {
leaveConsole()
notify('当前设备已撤销')
} else {
await loadAccount()
notify(`已撤销设备:${device.name}`)
}
} catch (error) {
notify(error instanceof Error ? error.message : 'REVOKE_FAILED', true)
} finally { busy.value = false }
}
async function logout() {
if (busy.value) return
busy.value = true
try { await api.logout() }
finally {
leaveConsole()
busy.value = false
notify('会话已退出')
}
}
onMounted(() => {
void refreshStatus()
statusTimer = window.setInterval(() => { void refreshStatus() }, 15_000)
})
onBeforeUnmount(() => {
window.clearInterval(statusTimer)
window.clearTimeout(toastTimer)
leaveConsole()
})
</script>
<template>
<div class="ambient ambient-one" aria-hidden="true" />
<div class="ambient ambient-two" aria-hidden="true" />
<header class="topbar">
<a class="brand" href="/console" aria-label="OpenNexus Sync Console">
<span class="brand-mark" aria-hidden="true"><i /><i /><i /></span>
<span><strong>OpenNexus</strong><small>Sync Console</small></span>
</a>
<div class="service-strip" aria-label="服务状态">
<span class="status-chip" :class="{ ok: service.health, bad: !checking && !service.health }"><i /><b>服务</b><em>{{ checking ? '检测中' : service.health ? '在线' : '离线' }}</em></span>
<span class="status-chip" :class="{ ok: service.ready, bad: !checking && !service.ready }"><i /><b>依赖</b><em>{{ checking ? '检测中' : service.ready ? '就绪' : '不可用' }}</em></span>
<button class="icon-button" type="button" :disabled="checking" aria-label="刷新服务状态" title="刷新服务状态" @click="refreshStatus">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
</button>
</div>
</header>
<main>
<section v-if="!signedIn" class="hero">
<div class="hero-copy">
<div class="eyebrow"><span /> Self-hosted workspace sync</div>
<h1>让知识在设备之间<br><em>安静地抵达</em></h1>
<p>OpenNexus Sync 为每个 Vault 保留完整修订历史附件完整性校验和设备级撤销此控制台只连接当前服务器不在浏览器持久保存密码或令牌</p>
<div class="protocol-grid" aria-label="协议能力">
<article><strong>v{{ service.protocol }}</strong><span>同步协议</span></article>
<article><strong>{{ formatBytes(service.maxObjectSize) }}</strong><span>单对象上限</span></article>
<article><strong>SHA-256</strong><span>内容完整性</span></article>
</div>
</div>
<section class="login-card" aria-labelledby="login-title">
<div class="card-glow" aria-hidden="true" />
<div class="card-heading">
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
<div><p>设备会话</p><h2 id="login-title">登录 Sync Server</h2></div>
</div>
<form @submit.prevent="signIn">
<label>账户<input v-model="username" autocomplete="username" maxlength="80" required placeholder="输入 Sync 账户"></label>
<label>密码<input v-model="password" type="password" autocomplete="current-password" minlength="12" maxlength="256" required placeholder="至少 12 个字符"></label>
<label>设备名称<input v-model="deviceName" autocomplete="off" maxlength="120" required></label>
<button class="primary-button" type="submit" :disabled="busy || !password"><span>{{ busy ? '正在建立会话…' : '建立安全会话' }}</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg></button>
</form>
<p class="privacy-note"><span></span>密码在发出请求前即从输入框清除会话令牌只保存在当前页面内存</p>
</section>
</section>
<section v-else class="console-view">
<div class="console-heading">
<div><div class="eyebrow"><span /> Connected workspace</div><h1>同步空间</h1><p>{{ sessionLabel }}</p></div>
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
</div>
<div class="metric-grid">
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
<article><span>设备</span><strong>{{ devices.length }}</strong><small>{{ activeDevices }} 台可访问</small></article>
<article><span>协议</span><strong>v{{ service.protocol }}</strong><small>历史长期保留</small></article>
</div>
<div class="content-grid">
<section class="surface">
<div class="surface-heading">
<div><p>Vaults</p><h2>远端知识库</h2></div>
<button class="icon-button" type="button" :disabled="busy" aria-label="刷新账户数据" title="刷新账户数据" @click="refreshAccount">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
</button>
</div>
<form class="create-form" @submit.prevent="createVault">
<label>创建新 Vault</label>
<div><input v-model="newVaultName" maxlength="120" required placeholder="例如:产品知识库"><button type="submit" :disabled="busy || !newVaultName.trim()">创建</button></div>
</form>
<div class="vault-list">
<div v-if="!vaults.length" class="empty-state">还没有远端 Vault</div>
<article v-for="vault in vaults" :key="vault.id" class="vault-item">
<div class="vault-symbol">N</div>
<div class="item-copy">
<strong>{{ vault.name }}</strong><small>{{ shortId(vault.id) }} · revision {{ vault.sequence }}</small>
<progress :value="usagePercent(vault)" max="100" />
</div>
<div class="item-side"><strong>{{ formatBytes(vault.used) }}</strong><small>of {{ formatBytes(vault.quota) }}</small></div>
</article>
</div>
</section>
<section class="surface">
<div class="surface-heading"><div><p>Devices</p><h2>设备会话</h2></div><span class="secure-badge">逐次鉴权</span></div>
<p class="surface-intro">撤销会让该设备的下一次请求立即失败Vault 内容和本地文件不会因此删除</p>
<div class="device-list">
<article v-for="device in devices" :key="device.id" class="device-item" :class="{ revoked: device.revoked }">
<div class="device-symbol">{{ firstCharacter(device.name, 'D') }}</div>
<div class="item-copy"><strong>{{ device.name }}</strong><small>{{ device.revoked ? '已撤销' : '可访问' }}<template v-if="device.id === api.deviceId"> · 当前设备</template></small></div>
<button v-if="!device.revoked" class="danger-button" type="button" :disabled="busy" @click="revokeDevice(device)">撤销</button>
</article>
</div>
</section>
</div>
</section>
</main>
<footer><span>OpenNexus Sync v1</span><span>Transport encryption · Device revocation · Immutable history</span></footer>
<div v-if="toast" class="toast" :class="{ error: toastError }" role="status" aria-live="polite">{{ toast }}</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
export interface ServiceStatus {
health: boolean
ready: boolean
protocol: number
maxObjectSize: number
}
export interface Session {
access_token: string
refresh_token: string
expires_in: number
device_id: string
}
export interface Vault {
id: string
name: string
sequence: number
used: number
quota: number
}
export interface Device {
id: string
name: string
revoked: number | boolean
}
interface ErrorPayload {
error?: { code?: string }
}
async function safeJson<T>(response: Response): Promise<T | null> {
try { return await response.json() as T }
catch { return null }
}
export class SyncApi {
private access = ''
private refresh = ''
deviceId = ''
get signedIn() { return Boolean(this.access) }
private async raw(path: string, init: RequestInit = {}, authenticate = true): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (authenticate && this.access) headers.set('Authorization', `Bearer ${this.access}`)
return fetch(path, { ...init, headers, cache: 'no-store', credentials: 'same-origin' })
}
private async rotate(): Promise<boolean> {
if (!this.refresh) return false
const response = await this.raw('/sync/v1/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: this.refresh }),
}, false)
if (!response.ok) return false
const session = await safeJson<Session>(response)
if (!session) return false
this.accept(session)
return true
}
private async request<T>(path: string, init: RequestInit = {}, allowRotate = true): Promise<T> {
let response = await this.raw(path, init)
if (response.status === 401 && allowRotate && await this.rotate()) response = await this.raw(path, init)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
if (response.status === 401) this.clear()
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
if (response.status === 204) return undefined as T
const payload = await safeJson<T>(response)
if (payload === null) throw new Error('INVALID_RESPONSE')
return payload
}
private accept(session: Session) {
this.access = session.access_token
this.refresh = session.refresh_token
this.deviceId = session.device_id
}
async status(): Promise<ServiceStatus> {
const [health, ready, handshake] = await Promise.allSettled([
this.raw('/health', {}, false),
this.raw('/ready', {}, false),
this.raw('/sync/v1/handshake?protocol=1', {}, false),
])
let protocol = 1
let maxObjectSize = 100 * 1024 * 1024
if (handshake.status === 'fulfilled' && handshake.value.ok) {
const payload = await safeJson<{ protocol: number; max_object_size: number }>(handshake.value)
protocol = payload?.protocol ?? protocol
maxObjectSize = payload?.max_object_size ?? maxObjectSize
}
return {
health: health.status === 'fulfilled' && health.value.ok,
ready: ready.status === 'fulfilled' && ready.value.ok,
protocol,
maxObjectSize,
}
}
async login(username: string, password: string, deviceName: string): Promise<void> {
const response = await this.raw('/sync/v1/auth/sessions', {
method: 'POST',
body: JSON.stringify({ username, password, device_name: deviceName }),
}, false)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
const session = await safeJson<Session>(response)
if (!session) throw new Error('INVALID_RESPONSE')
this.accept(session)
}
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
devices() { return this.request<{ items: Device[] }>('/sync/v1/devices') }
createVault(name: string) {
return this.request<{ vault_id: string; name: string }>('/sync/v1/vaults', {
method: 'POST', body: JSON.stringify({ name }),
})
}
revokeDevice(id: string) {
return this.request<void>(`/sync/v1/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
}
async logout(): Promise<void> {
try {
if (this.access) await this.request<void>('/sync/v1/auth/sessions', { method: 'DELETE' }, false)
} finally { this.clear() }
}
clear() {
this.access = ''
this.refresh = ''
this.deviceId = ''
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+175
View File
@@ -0,0 +1,175 @@
:root {
color-scheme: dark;
--bg: #06100d;
--surface: rgba(13, 29, 24, .84);
--surface-strong: #10241d;
--line: rgba(169, 222, 196, .15);
--line-strong: rgba(169, 222, 196, .28);
--text: #edf7f2;
--muted: #8fa99c;
--mint: #72e0ad;
--mint-bright: #a6f4cf;
--purple: #a991ff;
--danger: #ff8e8e;
--warning: #f5ca72;
--shadow: 0 26px 90px rgba(0, 0, 0, .34);
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { min-width: 320px; min-height: 100%; background: var(--bg); }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px),
radial-gradient(circle at 48% 0%, #15372c 0, var(--bg) 46%);
background-size: 52px 52px, 52px 52px, auto;
overflow-x: hidden;
}
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--mint); outline-offset: 3px; }
button:disabled { cursor: default; opacity: .5; }
.ambient { position: fixed; border-radius: 50%; filter: blur(80px); pointer-events: none; opacity: .16; }
.ambient-one { width: 420px; height: 420px; background: var(--mint); top: -220px; right: 8%; }
.ambient-two { width: 360px; height: 360px; background: var(--purple); bottom: -220px; left: -100px; opacity: .1; }
.topbar {
width: min(1280px, calc(100% - 48px));
height: 88px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--line);
position: relative;
z-index: 2;
}
.brand { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 13px; }
.brand > span:last-child { display: grid; gap: 1px; }
.brand strong { font-size: 17px; letter-spacing: -.02em; }
.brand small { color: var(--muted); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; position: relative; transform: rotate(30deg); }
.brand-mark i { position: absolute; display: block; border: 1.5px solid var(--mint); border-radius: 4px; }
.brand-mark i:nth-child(1) { width: 25px; height: 25px; opacity: .45; }
.brand-mark i:nth-child(2) { width: 17px; height: 17px; opacity: .72; }
.brand-mark i:nth-child(3) { width: 8px; height: 8px; background: var(--mint); box-shadow: 0 0 20px rgba(114,224,173,.8); }
.service-strip { display: flex; align-items: center; gap: 9px; }
.status-chip { min-height: 34px; padding: 0 12px; border: 1px solid var(--line); border-radius: 999px; display: flex; align-items: center; gap: 7px; background: rgba(7,18,15,.7); }
.status-chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; }
.status-chip b { color: var(--muted); font-size: 11px; font-weight: 500; }
.status-chip em { font-size: 11px; font-style: normal; }
.status-chip.ok i { background: var(--mint); }
.status-chip.bad i { background: var(--danger); }
.status-chip.bad em { color: var(--danger); }
.icon-button { width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 10px; display: grid; place-items: center; background: rgba(14,31,25,.7); cursor: pointer; transition: .18s ease; }
.icon-button:hover { border-color: var(--line-strong); background: var(--surface-strong); transform: translateY(-1px); }
.icon-button svg { width: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative; z-index: 1; }
.hero { min-height: calc(100vh - 150px); display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, .75fr); gap: clamp(48px, 8vw, 116px); align-items: center; padding: 64px 0 82px; }
.hero-copy { max-width: 680px; }
.eyebrow { color: var(--mint); font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; display: flex; align-items: center; gap: 10px; }
.eyebrow span { width: 24px; height: 1px; background: var(--mint); box-shadow: 0 0 10px var(--mint); }
.hero h1, .console-heading h1 { margin: 20px 0; letter-spacing: -.06em; line-height: .99; font-size: clamp(50px, 6.4vw, 86px); font-weight: 620; }
.hero h1 em { font-style: normal; color: transparent; background: linear-gradient(95deg, var(--mint-bright), #82d8c1 47%, var(--purple)); background-clip: text; -webkit-background-clip: text; }
.hero-copy > p { max-width: 620px; color: var(--muted); font-size: 16px; line-height: 1.85; }
.protocol-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 42px; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: rgba(8,22,17,.45); backdrop-filter: blur(15px); }
.protocol-grid article { padding: 19px 22px; display: grid; gap: 5px; border-right: 1px solid var(--line); }
.protocol-grid article:last-child { border-right: 0; }
.protocol-grid strong { font-size: 18px; font-weight: 600; }
.protocol-grid span { color: var(--muted); font-size: 11px; }
.login-card { padding: 31px; border: 1px solid var(--line-strong); border-radius: 24px; background: linear-gradient(145deg, rgba(18,40,32,.94), rgba(8,21,17,.9)); box-shadow: var(--shadow); position: relative; overflow: hidden; backdrop-filter: blur(25px); }
.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--mint); filter: blur(60px); opacity: .16; }
.card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; }
.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--mint); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.card-heading h2, .surface-heading h2 { margin: 0; font-size: 21px; letter-spacing: -.025em; }
.lock-mark { width: 43px; height: 43px; border-radius: 13px; display: grid; place-items: center; background: rgba(114,224,173,.1); color: var(--mint); border: 1px solid rgba(114,224,173,.18); }
.lock-mark svg { width: 21px; fill: none; stroke: currentColor; stroke-width: 1.7; }
form { position: relative; }
.login-card form { display: grid; gap: 17px; }
label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
input { width: 100%; color: var(--text); background: rgba(3,12,9,.58); border: 1px solid var(--line); border-radius: 11px; padding: 12px 13px; transition: border-color .18s, background .18s; }
input::placeholder { color: #5f786c; }
input:hover, input:focus { border-color: rgba(114,224,173,.46); background: rgba(3,12,9,.8); }
.primary-button { width: 100%; margin-top: 5px; padding: 13px 16px; border: 0; border-radius: 11px; display: flex; justify-content: center; align-items: center; gap: 8px; color: #062015; background: linear-gradient(100deg, var(--mint-bright), var(--mint)); font-weight: 750; cursor: pointer; box-shadow: 0 10px 35px rgba(114,224,173,.16); transition: .18s ease; }
.primary-button:hover { transform: translateY(-1px); filter: brightness(1.05); }
.primary-button svg { width: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
.privacy-note { margin: 18px 0 0; color: #718b7e; font-size: 10px; line-height: 1.5; display: flex; gap: 8px; }
.privacy-note span { color: var(--mint); font-size: 7px; margin-top: 3px; }
.console-view { padding: 64px 0 90px; min-height: calc(100vh - 150px); }
.console-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 38px; }
.console-heading h1 { font-size: clamp(42px, 5vw, 64px); margin: 13px 0 7px; }
.console-heading p { margin: 0; color: var(--muted); }
.secondary-button { padding: 10px 15px; border-radius: 10px; border: 1px solid var(--line-strong); background: transparent; cursor: pointer; }
.secondary-button:hover { border-color: var(--mint); color: var(--mint-bright); }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(8,22,17,.52); border-radius: 18px; overflow: hidden; margin-bottom: 22px; }
.metric-grid article { min-height: 134px; padding: 23px; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.metric-grid article:last-child { border-right: 0; }
.metric-grid span { color: var(--muted); font-size: 11px; }
.metric-grid strong { margin: 12px 0 8px; font-size: 27px; font-weight: 620; letter-spacing: -.04em; }
.metric-grid small { color: #668075; font-size: 10px; }
.content-grid { display: grid; grid-template-columns: 1.25fr .75fr; gap: 22px; }
.surface { border: 1px solid var(--line); border-radius: 18px; background: var(--surface); padding: 25px; backdrop-filter: blur(18px); }
.surface-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; }
.secure-badge { color: var(--mint); background: rgba(114,224,173,.08); border: 1px solid rgba(114,224,173,.18); padding: 6px 9px; border-radius: 999px; font-size: 9px; letter-spacing: .08em; }
.surface-intro { margin: -9px 0 19px; color: var(--muted); font-size: 12px; line-height: 1.65; }
.create-form { margin-bottom: 20px; padding: 16px; border-radius: 13px; background: rgba(4,15,11,.42); border: 1px solid var(--line); }
.create-form > div { display: flex; gap: 8px; margin-top: 8px; }
.create-form input { padding: 10px 12px; }
.create-form button { border: 0; border-radius: 9px; padding: 0 17px; color: #082016; background: var(--mint); font-weight: 700; cursor: pointer; }
.vault-list, .device-list { display: grid; gap: 9px; }
.vault-item, .device-item { min-width: 0; border: 1px solid var(--line); background: rgba(6,19,14,.48); border-radius: 13px; padding: 15px 16px; display: flex; align-items: center; gap: 13px; }
.vault-symbol, .device-symbol { width: 38px; height: 38px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 11px; background: rgba(114,224,173,.08); color: var(--mint); font-weight: 700; }
.device-symbol { color: var(--purple); background: rgba(169,145,255,.08); }
.item-copy { min-width: 0; flex: 1; }
.item-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.item-copy small { color: var(--muted); font-size: 10px; }
progress { display: block; width: 100%; height: 4px; margin-top: 9px; border: 0; border-radius: 99px; overflow: hidden; accent-color: var(--mint); }
progress::-webkit-progress-bar { background: rgba(255,255,255,.06); }
progress::-webkit-progress-value { background: linear-gradient(90deg, var(--mint), var(--purple)); }
.item-side { text-align: right; flex: 0 0 auto; }
.item-side strong { display: block; font-size: 12px; }
.item-side small { color: var(--muted); font-size: 9px; }
.danger-button { border: 1px solid rgba(255,142,142,.25); color: var(--danger); background: rgba(255,142,142,.05); border-radius: 8px; padding: 7px 9px; cursor: pointer; font-size: 10px; }
.danger-button:hover { background: rgba(255,142,142,.12); }
.revoked { opacity: .48; }
.empty-state { min-height: 100px; display: grid; place-items: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; font-size: 12px; }
footer { width: min(1280px, calc(100% - 48px)); min-height: 62px; margin: 0 auto; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; color: #597166; font-size: 9px; letter-spacing: .04em; position: relative; z-index: 1; }
.toast { position: fixed; z-index: 10; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 13px 16px; border: 1px solid var(--line-strong); border-radius: 11px; background: #142b23; box-shadow: var(--shadow); font-size: 12px; }
.toast.error { color: #ffd2d2; border-color: rgba(255,142,142,.35); background: #321a1a; }
@media (max-width: 920px) {
.hero { grid-template-columns: 1fr; padding-top: 76px; }
.hero-copy { max-width: none; }
.login-card { max-width: 560px; width: 100%; }
.content-grid { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.metric-grid article:nth-child(2) { border-right: 0; }
.metric-grid article:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
}
@media (max-width: 620px) {
.topbar, main, footer { width: min(100% - 28px, 1180px); }
.topbar { height: 74px; }
.brand small, .status-chip b, footer span:last-child { display: none; }
.status-chip { padding: 0 9px; }
.hero { padding: 52px 0 64px; gap: 45px; }
.hero h1 { font-size: 43px; }
.hero-copy > p { font-size: 14px; }
.protocol-grid article { padding: 15px 11px; }
.protocol-grid strong { font-size: 14px; }
.login-card, .surface { padding: 21px; }
.console-heading { align-items: flex-start; gap: 18px; }
.metric-grid { grid-template-columns: 1fr; }
.metric-grid article { min-height: 116px; border-right: 0; border-bottom: 1px solid var(--line); }
.metric-grid article:last-child { border-bottom: 0; }
.create-form > div { flex-direction: column; }
.create-form button { padding: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}