rebuild(stage-4): deliver shared shell and account interfaces

This commit is contained in:
leefer
2026-07-30 02:08:20 +08:00
parent d1c658d8ef
commit 3b75bf2d63
47 changed files with 3042 additions and 99 deletions
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
type Membership = {
status: string;
active: boolean;
is_permanent: boolean;
expires_at: string | null;
remaining_days: number | null;
daily_limit: number;
used_today: number;
remaining_today: number | null;
quota_exempt: boolean;
smart_access: boolean;
description: string;
usage_package_available: boolean;
};
const membership = ref<Membership | null>(null);
const errorMessage = ref("");
const statusLabel = computed(() => {
if (!membership.value) return "";
if (membership.value.status === "disabled") return "已停用";
return membership.value.active ? "已开通" : "未开通";
});
onMounted(async () => {
try {
membership.value = await api.get<Membership>("/account/membership");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "会员状态读取失败。";
}
});
</script>
<template>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
<p v-else-if="!membership" class="muted panel-loading">正在读取会员状态</p>
<div v-else class="membership-panel">
<p class="notice">{{ membership.description }}</p>
<dl class="status-grid">
<div><dt>开通状态</dt><dd>{{ statusLabel }}</dd></div>
<div><dt>到期时间</dt><dd class="numeric">{{ membership.is_permanent ? "永久" : membership.expires_at?.slice(0, 10) || "" }}</dd></div>
<div><dt>剩余时长</dt><dd class="numeric">{{ membership.is_permanent ? "永久" : membership.remaining_days === null ? "" : `${membership.remaining_days}` }}</dd></div>
<div><dt>今日用量</dt><dd class="numeric">{{ membership.quota_exempt ? `${membership.used_today} · 不限额` : `${membership.used_today} / ${membership.daily_limit}` }}</dd></div>
<div><dt>今日剩余</dt><dd class="numeric">{{ membership.quota_exempt ? "不限额" : membership.remaining_today }}</dd></div>
</dl>
<div class="comparison-table" role="table" aria-label="会员功能对比">
<div class="comparison-row comparison-head" role="row"><span>功能</span><span>普通用户</span><span>会员用户</span></div>
<div class="comparison-row" role="row"><span>行情与复盘</span><span>可用</span><span>可用</span></div>
<div class="comparison-row" role="row"><span>智能选股</span><span>锁定</span><span>可用</span></div>
<div class="comparison-row" role="row"><span>问师问天</span><span>锁定</span><span>可用</span></div>
<div class="comparison-row" role="row"><span>复盘助手</span><span>锁定</span><span>可用</span></div>
</div>
<div class="usage-package">
<div><strong>智能分析加油包</strong><p>每日用量不足时可补充调用额度</p></div>
<button class="btn" type="button" disabled>暂不可用</button>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from "vue";
import { api } from "../api/client";
import { useUiStore } from "../stores/ui";
const ui = useUiStore();
const currentPassword = ref("");
const newPassword = ref("");
const confirmation = ref("");
const saving = ref(false);
const errorMessage = ref("");
async function submit(): Promise<void> {
saving.value = true;
errorMessage.value = "";
try {
await api.patch("/account/password", {
current_password: currentPassword.value,
new_password: newPassword.value,
confirmation: confirmation.value,
});
currentPassword.value = "";
newPassword.value = "";
confirmation.value = "";
ui.closeDialog();
ui.showToast("密码已修改");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "密码修改失败。";
} finally {
saving.value = false;
}
}
</script>
<template>
<form class="form-grid" @submit.prevent="submit">
<div class="field"><label for="current-password">当前密码</label><input id="current-password" v-model="currentPassword" class="input" type="password" autocomplete="current-password" required /></div>
<div class="field"><label for="new-password">新密码</label><input id="new-password" v-model="newPassword" class="input" type="password" autocomplete="new-password" required /></div>
<div class="field"><label for="password-confirmation">确认新密码</label><input id="password-confirmation" v-model="confirmation" class="input" type="password" autocomplete="new-password" required /></div>
<p class="faint password-hint">密码长度8至128位至少包含字母数字符号中的两类</p>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
<div class="form-actions"><button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? "保存中" : "修改密码" }}</button></div>
</form>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useUiStore } from "../stores/ui";
type Profile = {
configured: boolean;
birth_date: string | null;
birth_time: string | null;
gender: "male" | "female" | null;
privacy_notice: string;
};
const ui = useUiStore();
const loading = ref(true);
const saving = ref(false);
const errorMessage = ref("");
const privacyNotice = ref("原始出生资料加密保存,仅当前账号可见。");
const birthDate = ref("");
const birthTime = ref("");
const gender = ref<"male" | "female">("male");
const confirmDelete = ref(false);
function applyProfile(profile: Profile): void {
birthDate.value = profile.birth_date ?? "";
birthTime.value = profile.birth_time?.slice(0, 5) ?? "";
gender.value = profile.gender ?? "male";
privacyNotice.value = profile.privacy_notice;
}
async function load(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
applyProfile(await api.get<Profile>("/account/profile"));
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "个人资料读取失败。";
} finally {
loading.value = false;
}
}
async function save(): Promise<void> {
saving.value = true;
errorMessage.value = "";
try {
applyProfile(
await api.put<Profile>("/account/profile", {
birth_date: birthDate.value,
birth_time: birthTime.value,
gender: gender.value,
}),
);
ui.showToast("个人资料已保存");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "保存失败,请稍后重试。";
} finally {
saving.value = false;
}
}
async function remove(): Promise<void> {
if (!confirmDelete.value) {
confirmDelete.value = true;
return;
}
errorMessage.value = "";
try {
await api.delete("/account/profile");
birthDate.value = "";
birthTime.value = "";
gender.value = "male";
confirmDelete.value = false;
ui.showToast("个人出生资料已删除");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "删除失败,请稍后重试。";
}
}
onMounted(load);
</script>
<template>
<p class="notice">{{ privacyNotice }}</p>
<p v-if="loading" class="muted panel-loading">正在读取个人资料</p>
<form v-else class="form-grid panel-form" @submit.prevent="save">
<div class="field">
<label for="birth-date">出生日期</label>
<input id="birth-date" v-model="birthDate" class="input" type="date" required />
</div>
<div class="field">
<label for="birth-time">出生时间</label>
<input id="birth-time" v-model="birthTime" class="input" type="time" required />
</div>
<div class="field">
<label for="gender">性别</label>
<select id="gender" v-model="gender" class="select">
<option value="male"></option>
<option value="female"></option>
</select>
</div>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
<div class="form-actions">
<button class="btn" type="button" :disabled="!birthDate" @click="remove">{{ confirmDelete ? "确认删除" : "删除资料" }}</button>
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? "保存中" : "保存" }}</button>
</div>
</form>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ref } from "vue";
const query = ref("");
</script>
<template>
<div class="search-panel">
<label class="sr-only" for="global-search">搜索股票板块题材或指数</label>
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
<div class="search-categories" aria-label="搜索结果分类">
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
</div>
<div class="search-empty">
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
<span>Ctrl+K</span>
</div>
</div>
</template>
@@ -45,4 +45,23 @@ describe("api client", () => {
requestId: "abc",
});
});
it("adds the readable CSRF cookie to write requests", async () => {
vi.stubGlobal("document", { cookie: "xiaobai_csrf=csrf-token-123" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await api.patch("/account/password", { value: "changed" });
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
const headers = request.headers as Headers;
expect(request.method).toBe("PATCH");
expect(headers.get("X-CSRF-Token")).toBe("csrf-token-123");
expect(headers.get("Content-Type")).toBe("application/json");
});
});
+33 -1
View File
@@ -18,10 +18,19 @@ export class ApiError extends Error {
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const csrfToken = readCookie("xiaobai_csrf");
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
if (init?.body) {
headers.set("Content-Type", "application/json");
}
if (init?.method && init.method !== "GET" && csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
const response = await fetch(`/api${path}`, {
credentials: "same-origin",
headers: { Accept: "application/json", ...init?.headers },
...init,
headers,
});
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload;
if (!response.ok) {
@@ -35,8 +44,31 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return payload;
}
function readCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const prefix = `${encodeURIComponent(name)}=`;
const item = document.cookie.split("; ").find((value) => value.startsWith(prefix));
return item ? decodeURIComponent(item.slice(prefix.length)) : undefined;
}
function json(method: string, body?: unknown): RequestInit {
return { method, body: body === undefined ? undefined : JSON.stringify(body) };
}
export const api = {
get<T>(path: string): Promise<T> {
return request<T>(path);
},
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, json("POST", body));
},
put<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, json("PUT", body));
},
patch<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, json("PATCH", body));
},
delete<T>(path: string): Promise<T> {
return request<T>(path, json("DELETE"));
},
};
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = withDefaults(defineProps<{ title: string; wide?: boolean; closeOnBackdrop?: boolean }>(), {
wide: false,
closeOnBackdrop: true,
});
const emit = defineEmits<{ close: [] }>();
const panel = ref<HTMLElement | null>(null);
const previouslyFocused = document.activeElement as HTMLElement | null;
function focusableElements(): HTMLElement[] {
if (!panel.value) return [];
return Array.from(
panel.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
),
);
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") {
emit("close");
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements();
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function backdrop(event: MouseEvent): void {
if (props.closeOnBackdrop && event.target === event.currentTarget) emit("close");
}
onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
await nextTick();
focusableElements()[0]?.focus();
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeydown);
previouslyFocused?.focus();
});
</script>
<template>
<Teleport to="body">
<div class="dialog-backdrop" @mousedown="backdrop">
<section ref="panel" class="dialog" :class="{ 'dialog-wide': wide }" role="dialog" aria-modal="true" :aria-label="title">
<header class="dialog-header">
<h2 class="dialog-title">{{ title }}</h2>
<button class="icon-button dialog-close" type="button" aria-label="关闭" title="关闭" @click="emit('close')">×</button>
</header>
<div class="dialog-body"><slot /></div>
</section>
</div>
</Teleport>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import ProfilePanel from "../account/ProfilePanel.vue";
import MembershipPanel from "../account/MembershipPanel.vue";
import PasswordPanel from "../account/PasswordPanel.vue";
import SearchPanel from "../account/SearchPanel.vue";
import { useUiStore } from "../stores/ui";
import BaseDialog from "./BaseDialog.vue";
const ui = useUiStore();
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码", search: "全局搜索" } as const;
</script>
<template>
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="ui.dialog === 'membership' || ui.dialog === 'search'" @close="ui.closeDialog">
<ProfilePanel v-if="ui.dialog === 'profile'" />
<MembershipPanel v-else-if="ui.dialog === 'membership'" />
<PasswordPanel v-else-if="ui.dialog === 'password'" />
<SearchPanel v-else />
</BaseDialog>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ title: string; description: string }>();
</script>
<template>
<div class="empty-state">
<span class="empty-mark" aria-hidden="true">·</span>
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { useUiStore } from "../stores/ui";
const ui = useUiStore();
</script>
<template>
<Teleport to="body">
<div v-if="ui.toast" class="toast" role="status" aria-live="polite">{{ ui.toast }}</div>
</Teleport>
</template>
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import { ref } from "vue";
function shanghaiDate(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
export const useMarketStore = defineStore("market", () => {
const selectedDate = ref(shanghaiDate());
function moveDate(offset: number): void {
const date = new Date(`${selectedDate.value}T12:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + offset);
selectedDate.value = date.toISOString().slice(0, 10);
}
return { selectedDate, moveDate };
});
@@ -0,0 +1,72 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { api, ApiError } from "../api/client";
export type AccountIdentity = {
id: number;
username: string;
is_admin: boolean;
membership_status: string;
membership_active: boolean;
smart_access: boolean;
badges: string[];
};
type AuthResponse = {
account: AccountIdentity;
csrf_token: string;
};
export const useSessionStore = defineStore("session", () => {
const account = ref<AccountIdentity | null>(null);
const initialized = ref(false);
const busy = ref(false);
const isAdmin = computed(() => account.value?.is_admin === true);
const isMember = computed(() => account.value?.membership_active === true);
async function restore(): Promise<void> {
try {
account.value = await api.get<AccountIdentity>("/auth/session");
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
throw error;
}
account.value = null;
} finally {
initialized.value = true;
}
}
async function authenticate(mode: "login" | "register", username: string, password: string) {
busy.value = true;
try {
const response = await api.post<AuthResponse>(`/auth/${mode}`, { username, password });
account.value = response.account;
} finally {
busy.value = false;
}
}
async function endSession(action: "logout" | "switch-account"): Promise<void> {
await api.post(`/auth/${action}`);
account.value = null;
}
async function refreshIdentity(): Promise<void> {
account.value = await api.get<AccountIdentity>("/auth/session");
}
return {
account,
initialized,
busy,
isAdmin,
isMember,
restore,
authenticate,
endSession,
refreshIdentity,
};
});
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export type DialogName = "profile" | "membership" | "password" | "search" | null;
export type Theme = "light" | "dark";
const THEME_KEY = "xiaobai-theme";
export function preferredTheme(): Theme {
const stored = localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark") return stored;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme: Theme): void {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
export const useUiStore = defineStore("ui", () => {
const theme = ref<Theme>(preferredTheme());
const dialog = ref<DialogName>(null);
const toast = ref("");
let toastTimer: ReturnType<typeof setTimeout> | undefined;
function setTheme(next: Theme): void {
theme.value = next;
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
}
function toggleTheme(): void {
setTheme(theme.value === "light" ? "dark" : "light");
}
function openDialog(name: Exclude<DialogName, null>): void {
dialog.value = name;
}
function closeDialog(): void {
dialog.value = null;
}
function showToast(message: string): void {
toast.value = message;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.value = "";
}, 2200);
}
return { theme, dialog, toast, setTheme, toggleTheme, openDialog, closeDialog, showToast };
});
+137
View File
@@ -0,0 +1,137 @@
.panel-loading {
padding: var(--s-22) 0;
text-align: center;
}
.panel-form {
padding-top: var(--s-14);
}
.membership-panel {
display: grid;
gap: var(--s-14);
}
.status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s-1);
margin: 0;
overflow: hidden;
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
background: var(--color-divider);
}
.status-grid > div {
padding: var(--s-10) var(--s-12);
background: var(--color-surface);
}
.status-grid dt {
color: var(--color-text-faint);
font-size: var(--font-11);
}
.status-grid dd {
margin: var(--s-4) 0 0;
font-size: var(--font-14);
font-weight: var(--weight-700);
}
.comparison-table {
overflow: hidden;
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
}
.comparison-row {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-200) var(--s-200);
border-bottom: var(--s-1) solid var(--color-divider);
}
.comparison-row:last-child {
border-bottom: 0;
}
.comparison-row span {
padding: var(--s-8) var(--s-12);
font-size: var(--font-12-5);
}
.comparison-row span:not(:first-child) {
text-align: center;
}
.comparison-head {
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-12);
font-weight: var(--weight-600);
}
.usage-package {
display: flex;
align-items: center;
gap: var(--s-12);
padding: var(--s-12);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
}
.usage-package div {
flex: 1;
}
.usage-package strong {
font-size: var(--font-13);
}
.usage-package p,
.password-hint {
margin-top: var(--s-4);
font-size: var(--font-11-5);
}
.search-panel {
display: grid;
gap: var(--s-12);
}
.search-input {
min-height: var(--s-44);
}
.search-categories {
display: flex;
gap: var(--s-8);
padding-bottom: var(--s-10);
border-bottom: var(--s-1) solid var(--color-divider);
}
.search-categories span {
padding: var(--s-4) var(--s-9);
border-radius: var(--tag-radius);
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-11);
}
.search-empty {
min-height: var(--s-200);
display: grid;
align-content: center;
justify-items: center;
gap: var(--s-8);
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.search-empty span {
padding: var(--s-2) var(--s-6);
border: var(--s-1) solid var(--color-border);
border-radius: var(--radius-4);
color: var(--color-text-faint);
font-size: var(--font-11);
}
+69
View File
@@ -0,0 +1,69 @@
.auth-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--s-16);
background: var(--color-canvas);
}
.auth-panel {
width: min(100%, var(--s-400));
overflow: hidden;
}
.auth-brand {
display: flex;
align-items: center;
gap: var(--s-12);
padding: var(--s-22) var(--s-26) var(--s-16);
}
.auth-brand h1 {
font-size: var(--font-17);
font-weight: var(--weight-800);
}
.auth-brand p {
margin-top: var(--s-4);
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.auth-tabs {
display: flex;
padding: 0 var(--s-26);
border-bottom: var(--s-1) solid var(--color-divider);
}
.auth-tabs button {
flex: 1;
padding: var(--s-10) var(--s-14);
border-bottom: var(--s-2) solid var(--c-transparent);
color: var(--color-text-secondary);
background: var(--c-transparent);
}
.auth-tabs button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: var(--weight-600);
}
.auth-form {
display: grid;
gap: var(--s-14);
padding: var(--s-22) var(--s-26) var(--s-16);
}
.auth-submit {
width: 100%;
margin-top: var(--s-4);
}
.auth-risk {
padding: 0 var(--s-26) var(--s-22);
color: var(--color-text-faint);
font-size: var(--font-11);
line-height: var(--s-18);
text-align: center;
}
+78 -3
View File
@@ -5,20 +5,95 @@
html,
body,
#app {
min-width: 320px;
min-width: var(--s-320);
min-height: 100%;
margin: 0;
}
html {
background: var(--color-canvas);
}
body {
min-height: 100vh;
color: var(--text-primary);
background: var(--canvas);
overflow-x: hidden;
color: var(--color-text);
background: var(--color-canvas);
font-family: var(--font-sans);
font-size: var(--font-13);
font-weight: var(--weight-400);
letter-spacing: 0;
}
button,
input,
select,
textarea {
color: inherit;
font: inherit;
letter-spacing: 0;
}
button,
a,
input,
select,
textarea {
outline: none;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
box-shadow: var(--focus-ring);
}
button {
border: 0;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3,
p {
margin: 0;
}
.sr-only {
position: absolute;
width: var(--s-1);
height: var(--s-1);
padding: 0;
margin: calc(var(--s-1) * -1);
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.numeric {
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: var(--s-1) !important;
animation-iteration-count: 1 !important;
transition-duration: var(--s-1) !important;
}
}
@@ -0,0 +1,332 @@
.btn {
min-height: var(--control-height);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-6);
padding: var(--s-6) var(--s-13);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface);
font-size: var(--font-13);
font-weight: var(--weight-500);
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.btn:hover:not(:disabled) {
color: var(--color-primary);
border-color: var(--color-primary-border);
}
.btn-primary {
color: var(--c-gray-050);
border-color: var(--color-primary);
background: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
color: var(--c-gray-050);
border-color: var(--color-primary-hover);
background: var(--color-primary-hover);
}
.btn-ghost {
border-color: var(--c-transparent);
background: var(--c-transparent);
}
.btn-small {
min-height: var(--s-28);
padding: var(--s-4) var(--s-9);
font-size: var(--font-12);
}
.btn:disabled,
.is-disabled {
opacity: var(--opacity-disabled);
}
.icon-button {
width: var(--s-32);
height: var(--s-32);
display: inline-grid;
place-items: center;
padding: 0;
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--color-surface);
font-size: var(--font-14);
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.icon-button:hover {
color: var(--color-primary);
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
}
.card {
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.card-header {
min-height: var(--s-40);
display: flex;
align-items: center;
gap: var(--s-8);
padding: var(--s-11) var(--s-14);
border-bottom: var(--s-1) solid var(--color-divider);
}
.card-header h2,
.card-header h3 {
font-size: var(--font-14);
font-weight: var(--weight-700);
}
.card-body {
padding: var(--s-14) var(--s-16);
}
.muted {
color: var(--color-text-secondary);
}
.faint {
color: var(--color-text-faint);
}
.up {
color: var(--color-up);
}
.down {
color: var(--color-down);
}
.warning {
color: var(--color-warning);
}
.tag {
display: inline-flex;
align-items: center;
min-height: var(--s-20);
padding: var(--s-1) var(--s-7);
border: var(--s-1) solid var(--color-border);
border-radius: var(--tag-radius);
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-11);
}
.tag-admin {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-soft);
}
.tag-vip {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-soft);
font-weight: var(--weight-800);
}
.field {
display: grid;
gap: var(--s-6);
}
.field label,
.field-label {
color: var(--color-text-secondary);
font-size: var(--font-12);
font-weight: var(--weight-600);
}
.input,
.select,
.textarea {
width: 100%;
min-height: var(--s-36);
padding: var(--s-8) var(--s-12);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface);
font-size: var(--font-13);
}
.textarea {
min-height: var(--s-64);
resize: vertical;
}
.input::placeholder,
.textarea::placeholder {
color: var(--color-text-faint);
}
.input:hover,
.select:hover,
.textarea:hover {
border-color: var(--color-primary-border);
}
.form-grid {
display: grid;
gap: var(--s-12);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--s-8);
padding-top: var(--s-4);
}
.field-error {
color: var(--color-up);
font-size: var(--font-11-5);
}
.notice {
padding: var(--s-10) var(--s-12);
border: var(--s-1) solid var(--color-primary-border);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--color-primary-soft);
font-size: var(--font-12);
line-height: var(--s-18);
}
.notice-warning {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-soft);
}
.membership-lock {
display: flex;
align-items: center;
gap: var(--s-12);
margin-bottom: var(--s-12);
}
.membership-lock span {
flex: 1;
}
.locked-content {
pointer-events: none;
opacity: var(--opacity-muted);
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-dialog);
display: grid;
place-items: center;
padding: var(--s-16);
background: var(--color-overlay);
}
.dialog {
width: min(100%, var(--s-dialog));
max-height: calc(100vh - var(--s-34));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface-raised);
box-shadow: var(--shadow-float);
}
.dialog-wide {
width: min(100%, var(--s-dialog-wide));
}
.dialog-header {
min-height: var(--s-46);
display: flex;
align-items: center;
gap: var(--s-8);
padding: var(--s-10) var(--s-14);
border-bottom: var(--s-1) solid var(--color-divider);
}
.dialog-title {
font-size: var(--font-14);
font-weight: var(--weight-700);
}
.dialog-close {
margin-left: auto;
}
.dialog-body {
overflow: auto;
padding: var(--s-14) var(--s-16) var(--s-16);
}
.toast {
position: fixed;
left: 50%;
bottom: var(--s-64);
z-index: var(--z-toast);
max-width: min(var(--s-400), calc(100vw - var(--s-32)));
padding: var(--s-10) var(--s-14);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface-raised);
box-shadow: var(--shadow-float);
font-size: var(--font-12-5);
transform: translateX(-50%);
}
.empty-state {
min-height: var(--s-200);
display: grid;
align-content: center;
justify-items: center;
gap: var(--s-8);
padding: var(--s-44) var(--s-16);
text-align: center;
}
.empty-mark {
width: var(--s-30);
height: var(--s-30);
display: grid;
place-items: center;
border: var(--s-1) solid var(--color-border);
border-radius: var(--radius-round);
color: var(--color-text-faint);
background: var(--color-surface-muted);
}
.empty-state h2 {
color: var(--color-text-secondary);
font-size: var(--font-14);
font-weight: var(--weight-600);
}
.empty-state p {
max-width: var(--s-400);
color: var(--color-text-secondary);
font-size: var(--font-12);
line-height: var(--s-18);
}
+152
View File
@@ -0,0 +1,152 @@
@media (max-width: 1023px) {
.dialog-backdrop {
align-items: end;
padding: 0;
}
.dialog,
.dialog-wide {
width: 100%;
max-height: calc(100vh - var(--s-44));
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.btn,
.icon-button {
min-height: var(--touch-height);
}
.icon-button {
width: var(--touch-height);
}
.membership-lock {
align-items: stretch;
flex-direction: column;
}
.app-shell {
padding-left: 0;
padding-bottom: var(--shell-mobile-nav-height);
}
.sidebar,
.statusbar,
.desktop-only,
.market-details {
display: none;
}
.topbar {
gap: var(--s-8);
padding: 0 var(--s-10);
}
.topbar-title {
font-size: var(--font-13);
}
.identity-cluster .tag,
.account-name,
.date-control .icon-button {
display: none;
}
.mobile-account-label {
display: inline;
}
.market-strip-row {
gap: var(--s-12);
overflow-x: auto;
padding-right: var(--s-10);
padding-left: var(--s-10);
white-space: nowrap;
}
.market-toggle {
display: none;
}
.page-frame {
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height));
padding: var(--s-12) var(--s-10);
}
.page-header {
align-items: flex-start;
gap: var(--s-4);
flex-direction: column;
}
.mobile-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: var(--z-mobile-nav);
height: var(--shell-mobile-nav-height);
display: grid;
grid-template-columns: repeat(5, 1fr);
padding-bottom: env(safe-area-inset-bottom);
border-top: var(--s-1) solid var(--color-border);
background: var(--color-surface);
}
.mobile-nav-item {
min-width: 0;
display: grid;
place-items: center;
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.mobile-nav-item.router-link-active {
color: var(--color-primary);
font-weight: var(--weight-600);
}
.status-grid {
grid-template-columns: 1fr 1fr;
}
.comparison-row {
grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64);
}
.auth-view {
align-items: start;
padding-top: var(--s-34);
}
.auth-panel {
box-shadow: none;
}
.system-header,
.system-header > div:first-child {
align-items: flex-start;
flex-direction: column;
}
.system-tabs {
width: 100%;
margin-left: 0;
overflow-x: auto;
}
.system-grid,
.model-grid {
grid-template-columns: 1fr;
}
.credential-row,
.selection-grid {
grid-template-columns: 1fr;
align-items: stretch;
}
.member-editor {
position: static;
}
}
+327
View File
@@ -0,0 +1,327 @@
.app-loading {
min-height: 100vh;
display: grid;
place-items: center;
color: var(--color-text-secondary);
background: var(--color-canvas);
}
.loading-stack {
display: grid;
justify-items: center;
gap: var(--s-12);
}
.app-shell {
min-height: 100vh;
padding-left: var(--shell-sidebar-width);
padding-bottom: var(--shell-status-height);
background: var(--color-canvas);
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: var(--z-sidebar);
width: var(--shell-sidebar-width);
display: flex;
flex-direction: column;
border-right: var(--s-1) solid var(--color-border);
background: var(--color-surface);
}
.brand {
height: var(--shell-topbar-height);
display: flex;
align-items: center;
gap: var(--s-8);
padding: 0 var(--s-16);
border-bottom: var(--s-1) solid var(--color-divider);
}
.brand-mark {
width: var(--s-26);
height: var(--s-26);
display: grid;
place-items: center;
border-radius: var(--control-radius);
color: var(--c-gray-050);
background: var(--color-primary);
font-size: var(--font-14);
font-weight: var(--weight-700);
}
.brand-name {
font-size: var(--font-15);
font-weight: var(--weight-700);
}
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: var(--s-8);
}
.nav-group-label {
padding: var(--s-12) var(--s-10) var(--s-4);
color: var(--color-text-faint);
font-size: var(--font-11);
}
.nav-item {
min-height: var(--s-32);
display: flex;
align-items: center;
gap: var(--s-8);
margin-bottom: var(--s-1);
padding: var(--s-6) var(--s-10);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
font-size: var(--font-13);
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.nav-item:hover {
color: var(--color-text);
background: var(--color-surface-muted);
}
.nav-item.router-link-active {
color: var(--color-primary);
background: var(--color-primary-soft);
font-weight: var(--weight-600);
}
.nav-mark {
width: var(--s-20);
color: var(--color-text-faint);
font-size: var(--font-11);
text-align: center;
}
.nav-item.router-link-active .nav-mark {
color: var(--color-primary);
}
.shell-main {
min-width: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: var(--z-topbar);
height: var(--shell-topbar-height);
display: flex;
align-items: center;
gap: var(--s-10);
padding: 0 var(--s-16);
border-bottom: var(--s-1) solid var(--color-border);
background: var(--color-surface);
}
.topbar-title {
min-width: 0;
font-size: var(--font-14);
font-weight: var(--weight-700);
white-space: nowrap;
}
.topbar-spacer {
flex: 1;
}
.date-control {
display: inline-flex;
align-items: center;
gap: var(--s-4);
}
.date-input {
height: var(--s-32);
padding: var(--s-4) var(--s-8);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface);
font-size: var(--font-12);
font-variant-numeric: tabular-nums;
}
.identity-cluster {
display: inline-flex;
align-items: center;
gap: var(--s-6);
}
.account-menu-wrap {
position: relative;
}
.account-trigger {
max-width: var(--s-200);
}
.account-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-account-label {
display: none;
}
.account-menu {
position: absolute;
top: calc(100% + var(--s-8));
right: 0;
z-index: var(--z-popover);
width: var(--s-200);
padding: var(--s-6);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
background: var(--color-surface-raised);
box-shadow: var(--shadow-float);
}
.account-menu button {
width: 100%;
min-height: var(--s-32);
padding: var(--s-6) var(--s-10);
border-radius: var(--radius-5);
color: var(--color-text-secondary);
background: var(--c-transparent);
text-align: left;
}
.account-menu button:hover {
color: var(--color-text);
background: var(--color-surface-muted);
}
.market-strip {
border-bottom: var(--s-1) solid var(--color-border);
background: var(--color-surface);
}
.market-strip-row {
min-height: var(--shell-summary-height);
display: flex;
align-items: center;
gap: var(--s-18);
padding: var(--s-6) var(--s-16);
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.market-emotion {
display: inline-flex;
align-items: center;
gap: var(--s-6);
color: var(--color-text-secondary);
}
.emotion-dot {
width: var(--s-8);
height: var(--s-8);
border-radius: var(--radius-round);
background: var(--color-text-faint);
animation: emotion-pulse 2.2s var(--ease-standard) infinite;
}
@keyframes emotion-pulse {
50% {
opacity: var(--opacity-muted);
transform: scale(var(--scale-pulse));
}
}
.market-toggle {
margin-left: auto;
color: var(--color-primary);
background: var(--c-transparent);
font-size: var(--font-12);
}
.market-details {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
border-top: var(--s-1) solid var(--color-divider);
background: var(--color-divider);
gap: var(--s-1);
}
.market-cell {
min-height: var(--s-64);
padding: var(--s-10) var(--s-16);
background: var(--color-surface);
}
.market-cell-label {
color: var(--color-text-faint);
font-size: var(--font-11);
}
.market-cell-value {
min-height: var(--s-24);
padding-top: var(--s-4);
font-size: var(--font-18);
font-weight: var(--weight-700);
font-variant-numeric: tabular-nums;
}
.page-frame {
width: min(100%, var(--s-content-max));
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-status-height));
margin: 0 auto;
padding: var(--page-padding-y) var(--page-padding-x);
}
.page-header {
display: flex;
align-items: baseline;
gap: var(--s-12);
margin-bottom: var(--s-12);
}
.page-header h1 {
font-size: var(--font-17);
font-weight: var(--weight-800);
}
.page-subtitle {
color: var(--color-text-faint);
font-size: var(--font-12);
}
.statusbar {
position: fixed;
right: 0;
bottom: 0;
left: var(--shell-sidebar-width);
z-index: var(--z-status);
height: var(--shell-status-height);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--s-12);
padding: 0 var(--s-16);
border-top: var(--s-1) solid var(--color-border);
color: var(--color-text-faint);
background: var(--color-surface);
font-size: var(--font-11);
}
.statusbar-center {
color: var(--color-text-secondary);
}
.statusbar-right {
text-align: right;
}
.mobile-nav {
display: none;
}
+206
View File
@@ -0,0 +1,206 @@
.system-header {
align-items: center;
}
.system-header > div:first-child {
display: flex;
align-items: baseline;
gap: var(--s-12);
}
.system-tabs {
display: flex;
align-self: stretch;
margin-left: auto;
border-bottom: var(--s-1) solid var(--color-divider);
}
.system-tabs button {
padding: var(--s-10) var(--s-14);
border-bottom: var(--s-2) solid var(--c-transparent);
color: var(--color-text-secondary);
background: var(--c-transparent);
font-size: var(--font-13);
}
.system-tabs button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: var(--weight-600);
}
.system-stack {
display: grid;
gap: var(--layout-gap);
}
.system-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-360);
align-items: start;
gap: var(--layout-gap);
}
.model-grid {
grid-template-columns: var(--s-360) minmax(0, 1fr);
}
.credential-list {
display: grid;
gap: var(--s-12);
}
.credential-row {
display: grid;
grid-template-columns: var(--s-200) minmax(0, 1fr) auto;
align-items: end;
gap: var(--s-10);
}
.credential-meta {
display: grid;
gap: var(--s-4);
}
.credential-meta strong {
font-size: var(--font-13);
}
.credential-meta span {
font-size: var(--font-11);
}
.disabled-row,
.selected-account {
display: flex;
align-items: center;
gap: var(--s-12);
}
.disabled-row p,
.selected-account strong {
flex: 1;
}
.model-list {
max-height: calc(100vh - var(--s-260));
overflow: auto;
padding: var(--s-6);
}
.model-row {
width: 100%;
min-height: var(--s-44);
display: flex;
align-items: center;
gap: var(--s-8);
padding: var(--s-8) var(--s-10);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--c-transparent);
text-align: left;
}
.model-row:hover,
.model-row.active {
background: var(--color-primary-soft);
}
.model-row > span:first-child {
min-width: 0;
display: grid;
gap: var(--s-2);
flex: 1;
}
.model-row strong {
overflow: hidden;
font-size: var(--font-13);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-row small {
overflow: hidden;
color: var(--color-text-faint);
font-size: var(--font-11);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tags {
display: flex;
gap: var(--s-4);
}
.selection-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: end;
gap: var(--s-10);
}
.table-wrap {
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-12-5);
}
.data-table th {
position: sticky;
top: 0;
z-index: var(--z-table);
padding: var(--s-8) var(--s-12);
border-bottom: var(--s-1) solid var(--color-border);
color: var(--color-text-secondary);
background: var(--color-surface-muted);
font-size: var(--font-12);
font-weight: var(--weight-600);
text-align: left;
white-space: nowrap;
}
.data-table td {
padding: var(--s-9) var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
white-space: nowrap;
}
.data-table th.num,
.data-table td.num {
text-align: right;
}
.sort-button {
padding: 0;
color: inherit;
background: var(--c-transparent);
font: inherit;
}
.sort-button span {
color: var(--color-primary);
}
.data-table tbody tr {
cursor: pointer;
}
.data-table tbody tr:hover,
.data-table tbody tr.selected {
background: var(--color-primary-soft);
}
.name-cell {
font-size: var(--font-13);
font-weight: var(--weight-700);
}
.member-editor {
position: sticky;
top: calc(var(--shell-topbar-height) + var(--shell-summary-height) + var(--s-14));
}
+182 -28
View File
@@ -1,37 +1,191 @@
:root {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
color: #1f2937;
background: #f4f5f7;
--canvas: #f4f5f7;
--surface: #ffffff;
--border: #e5e7eb;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--primary: #2563eb;
--warning: #b45309;
/* Primitive color tokens */
--c-blue-600: #2563eb;
--c-blue-700: #1d4ed8;
--c-blue-050: #eff4ff;
--c-blue-200: #c7d8fb;
--c-red-600: #e04536;
--c-red-050: #fdecea;
--c-green-600: #16a34a;
--c-green-050: #e9f7ee;
--c-amber-700: #b45309;
--c-amber-050: #fdf3e3;
--c-gray-950: #121416;
--c-gray-900: #1b1e21;
--c-gray-850: #202428;
--c-gray-800: #24282d;
--c-gray-700: #343a40;
--c-gray-650: #474f57;
--c-gray-100: #f4f5f7;
--c-gray-075: #f8fafc;
--c-gray-050: #ffffff;
--c-text-900: #1f2937;
--c-text-700: #374151;
--c-text-600: #4b5563;
--c-text-500: #6b7280;
--c-text-400: #9ca3af;
--c-line-200: #e5e7eb;
--c-line-100: #eef0f3;
--c-night-text-900: #e8eaed;
--c-night-text-600: #adb5bd;
--c-night-text-400: #7f8993;
--c-night-blue: #6ca8e8;
--c-night-blue-hover: #8bbcf0;
--c-night-blue-soft: #23364a;
--c-night-red: #f06d73;
--c-night-red-soft: #40262a;
--c-night-green: #43bc8a;
--c-night-green-soft: #1d382f;
--c-night-amber: #e2ad58;
--c-night-amber-soft: #3d3220;
--c-transparent: transparent;
--space-8: 8px;
--space-16: 16px;
--space-22: 22px;
--radius-card: 10px;
--font-page-title: 17px;
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.05);
/* Primitive dimensions */
--s-1: 1px;
--s-2: 2px;
--s-4: 4px;
--s-6: 6px;
--s-7: 7px;
--s-8: 8px;
--s-9: 9px;
--s-10: 10px;
--s-11: 11px;
--s-12: 12px;
--s-13: 13px;
--s-14: 14px;
--s-16: 16px;
--s-18: 18px;
--s-20: 20px;
--s-22: 22px;
--s-24: 24px;
--s-26: 26px;
--s-28: 28px;
--s-30: 30px;
--s-32: 32px;
--s-34: 34px;
--s-36: 36px;
--s-40: 40px;
--s-44: 44px;
--s-46: 46px;
--s-56: 56px;
--s-64: 64px;
--s-200: 200px;
--s-260: 260px;
--s-320: 320px;
--s-360: 360px;
--s-400: 400px;
--s-dialog: 512px;
--s-dialog-wide: 768px;
--s-content-max: 2200px;
--s-field: 340px;
/* Primitive typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-serif: "Kaiti SC", "STKaiti", "KaiTi", "STSong", "SimSun", serif;
--font-10-5: 10.5px;
--font-11: 11px;
--font-11-5: 11.5px;
--font-12: 12px;
--font-12-5: 12.5px;
--font-13: 13px;
--font-14: 14px;
--font-15: 15px;
--font-17: 17px;
--font-18: 18px;
--weight-400: 400;
--weight-500: 500;
--weight-600: 600;
--weight-700: 700;
--weight-800: 800;
/* Primitive effects */
--radius-4: 4px;
--radius-5: 5px;
--radius-6: 6px;
--radius-7: 7px;
--radius-10: 10px;
--radius-round: 999px;
--shadow-card-light: 0 1px 2px rgba(16, 24, 40, 0.05);
--shadow-card-dark: 0 1px 2px rgba(0, 0, 0, 0.28);
--shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18);
--duration-fast: 160ms;
--duration-normal: 220ms;
--ease-standard: ease;
--opacity-disabled: 0.52;
--opacity-muted: 0.72;
--scale-pulse: 0.84;
--z-table: 2;
--z-topbar: 50;
--z-status: 55;
--z-sidebar: 60;
--z-mobile-nav: 70;
--z-popover: 100;
--z-dialog: 200;
--z-toast: 240;
/* Semantic tokens */
--color-canvas: var(--c-gray-100);
--color-surface: var(--c-gray-050);
--color-surface-muted: var(--c-gray-075);
--color-surface-raised: var(--c-gray-050);
--color-text: var(--c-text-900);
--color-text-secondary: var(--c-text-500);
--color-text-faint: var(--c-text-400);
--color-border: var(--c-line-200);
--color-divider: var(--c-line-100);
--color-primary: var(--c-blue-600);
--color-primary-hover: var(--c-blue-700);
--color-primary-soft: var(--c-blue-050);
--color-primary-border: var(--c-blue-200);
--color-up: var(--c-red-600);
--color-up-soft: var(--c-red-050);
--color-down: var(--c-green-600);
--color-down-soft: var(--c-green-050);
--color-warning: var(--c-amber-700);
--color-warning-soft: var(--c-amber-050);
--color-overlay: rgba(18, 20, 22, 0.46);
--shadow-card: var(--shadow-card-light);
/* Component tokens */
--shell-sidebar-width: var(--s-200);
--shell-topbar-height: var(--s-46);
--shell-summary-height: var(--s-36);
--shell-status-height: var(--s-30);
--shell-mobile-nav-height: var(--s-56);
--page-padding-y: var(--s-14);
--page-padding-x: var(--s-16);
--layout-gap: var(--s-12);
--control-height: var(--s-32);
--touch-height: var(--s-44);
--card-radius: var(--radius-10);
--control-radius: var(--radius-7);
--tag-radius: var(--radius-5);
--focus-ring: 0 0 0 var(--s-2) var(--color-canvas), 0 0 0 var(--s-4) var(--color-primary);
}
:root[data-theme="dark"] {
color-scheme: dark;
color: #e8eaed;
background: #121416;
--canvas: #121416;
--surface: #1b1e21;
--border: #343a40;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--primary: #6ca8e8;
--warning: #e2ad58;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.28);
--color-canvas: var(--c-gray-950);
--color-surface: var(--c-gray-900);
--color-surface-muted: var(--c-gray-850);
--color-surface-raised: var(--c-gray-800);
--color-text: var(--c-night-text-900);
--color-text-secondary: var(--c-night-text-600);
--color-text-faint: var(--c-night-text-400);
--color-border: var(--c-gray-700);
--color-divider: var(--c-gray-700);
--color-primary: var(--c-night-blue);
--color-primary-hover: var(--c-night-blue-hover);
--color-primary-soft: var(--c-night-blue-soft);
--color-primary-border: var(--c-gray-650);
--color-up: var(--c-night-red);
--color-up-soft: var(--c-night-red-soft);
--color-down: var(--c-night-green);
--color-down-soft: var(--c-night-green-soft);
--color-warning: var(--c-night-amber);
--color-warning-soft: var(--c-night-amber-soft);
--color-overlay: rgba(0, 0, 0, 0.64);
--shadow-card: var(--shadow-card-dark);
}