From 1f16a357213611a6d7f84e506064fc0d9c6317c6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 12:03:27 +0800 Subject: [变更性质] feat(frontend) [新增功能] round3 主题基建:shadcn preset CLI init(mira)+ app.css 重写 + theme.ts 双主题 [实现方案] 无 legacy 组件选择器的全新 app.css(mint/amber 双 token 组、--radius 0.75rem、保留布局原语);theme.ts 持久化 .theme-amber;utils.ts 本地 cn 实现 [影响范围] frontend/src/app.css、src/lib/theme.ts、utils.ts、package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app.css | 411 +++++++++++++++++----------------------------- frontend/src/lib/theme.ts | 28 ++++ frontend/src/lib/utils.ts | 6 +- 3 files changed, 184 insertions(+), 261 deletions(-) create mode 100644 frontend/src/lib/theme.ts (limited to 'frontend/src') diff --git a/frontend/src/app.css b/frontend/src/app.css index 11a8e4b..ae195ff 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,78 +1,138 @@ @import 'tailwindcss'; @import 'tw-animate-css'; -@import "shadcn-svelte/tailwind.css"; -@import "@fontsource-variable/inter"; +@import 'shadcn-svelte/tailwind.css'; +@import '@fontsource-variable/inter'; @custom-variant dark (&:is(.dark *)); -:root { - --bg: #f7fafb; - --surface: #ffffff; - --surface-2: #f1f5f6; - --border: #e2e8ea; +/* ==== round3 主题 sessions tokens ==== + 来源:用户在 shadcn 官网 create 的 Stripe 风格参考截图(preset b1D0dv72 CLI + 拉取成功但落的是 neutral 默认,故按截图色值重配 tokens)。 + 参考取样:mint 主色 #70DAB6 / 深一档 #58D3AA;amber 主色 #F0AE00。 + 卡片白底、radius 0.75rem、大字重数字 tabular-nums。默认 mint,.theme-amber 切换。 */ + +:root, +.dark { + /* shadcn tokens — white card, Stripe-ish neutrals */ + --radius: 0.75rem; + --background: #f7fafb; + --foreground: #1e2a30; + --card: #ffffff; + --card-foreground: #1e2a30; + --popover: #ffffff; + --popover-foreground: #1e2a30; + --primary: #0e3a30; + --primary-foreground: #ffffff; + --secondary: #eef3f4; + --secondary-foreground: #26414a; + --muted: #eef3f4; + --muted-foreground: #5c6c74; + --accent: #e3f7ef; + --accent-foreground: #0b5d47; + --destructive: #b91c1c; + --input: #d6e0e3; + --ring: #70dab6; + --border: #e2eaec; + /* mint demo(默认) */ + --brand: #58d3aa; + --brand-strong: #0f9f7a; + --brand-soft: #e3f7ef; + --brand-ink: #0b4a3a; + /* chart 家族(mint 梯度,贴近截图的柔和强调) */ + --chart-1: #58d3aa; + --chart-2: #0f9f7a; + --chart-3: #0b4a3a; + --chart-4: #c4b37a; + --chart-5: #94a6ac; + --sidebar: #ffffff; + --sidebar-foreground: #1e2a30; + --sidebar-border: #e2eaec; + /* legacy alias(迁移期语义映射,等价 token 而非旧组件样式) */ + --bg: var(--background); + --surface: var(--card); + --surface-2: var(--secondary); --border-strong: #cbd5d8; - --text: #1e2a30; - --text-2: #4b5a62; + --text: var(--foreground); + --text-2: var(--muted-foreground); --text-3: #7b8a92; - --teal: #0d9488; - --teal-strong: #0f766e; - --teal-soft: #e6f4f2; - --ink: #12312d; - --danger: #b91c1c; + --teal: var(--brand); + --teal-strong: var(--brand-strong); + --teal-soft: var(--brand-soft); + --ink: var(--brand-ink); --danger-soft: #fdf2f2; --warn: #92400e; --warn-soft: #fff9ec; --ok: #15803d; --ok-soft: #ecfdf3; - --radius: 0.375rem; --shadow: 0 1px 2px rgba(16, 42, 49, 0.05), 0 2px 8px rgba(16, 42, 49, 0.05); --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace; --font: -apple-system, 'SF Pro Text', BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; - --background: #f7fafb; - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: #0f766e; + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: var(--secondary); + --sidebar-accent-foreground: var(--foreground); + --sidebar-ring: var(--ring); +} + +/* amber demo:第二套 CSS 变量组(src/lib/theme.ts 切换 .theme-amber) */ +.theme-amber { + --primary: #b87700; --primary-foreground: #ffffff; - --secondary: #f1f5f6; - --secondary-foreground: oklch(0.205 0 0); - --muted: #f1f5f6; - --muted-foreground: oklch(0.556 0 0); - --accent: #f1f5f6; - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --input: #cbd5d8; - --ring: #0d9488; - --chart-1: #0f766e; - --chart-2: #b45309; - --chart-3: #1d4ed8; - --chart-4: #8b1d8b; - --chart-5: #64748b; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --accent: #fdf3d7; + --accent-foreground: #7a5200; + --ring: #f0ae00; + --brand: #f0ae00; + --brand-strong: #c08700; + --brand-soft: #fdf3d7; + --brand-ink: #5d3f00; + --chart-1: #f0ae00; + --chart-2: #b87700; + --chart-3: #6e4a00; + --chart-4: #58d3aa; + --chart-5: #94a6ac; +} + +/* legacy 名义色到主题的映射:amber 组同样生效 */ +.theme-amber { --teal: var(--brand); --teal-strong: var(--brand-strong); --teal-soft: var(--brand-soft); --ink: var(--brand-ink); } + +.dark { + --background: #10181b; + --foreground: #eef3f4; + --card: #16211f; + --card-foreground: #eef3f4; + --popover: #16201f; + --popover-foreground: #eef3f4; + --primary: #58d3aa; + --primary-foreground: #0b1a16; + --secondary: #1c2a27; + --secondary-foreground: #eef3f4; + --muted: #1c2a27; + --muted-foreground: #93a5ab; + --accent: #12332c; + --accent-foreground: #8aeccf; + --destructive: #e05252; + --input: #243437; + --ring: #58d3aa; + --border: #223433; + --border-strong: #2c4340; + --brand-soft: rgba(88, 211, 170, 0.14); + --text-2: #93a5ab; + --text-3: #7b8a92; + --danger-soft: rgba(185, 28, 28, 0.2); + --warn-soft: rgba(146, 64, 14, 0.2); + --ok: #4ade80; } * { box-sizing: border-box; } -/* round2 质感:150ms 全局过渡(背景/边框/阴影/位移/文字色) */ -a, button, input, select, textarea, table.data th, table.data td, -.card, [data-slot='card'], .badge, .navlink, .metric-card { +/* 全局 150ms 微过渡 */ +a, button, input, select, textarea, [data-slot='card'], .navlink { transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease, transform 150ms ease; } -/* 桌面端卡片 hover 轻微上浮阴影;触屏(hover none)不做位移避免黏连 */ @media (hover: hover) and (min-width: 768px) { - .card:hover, [data-slot='card']:hover { box-shadow: 0 4px 14px rgba(16, 42, 49, 0.08); transform: translateY(-1px); @@ -82,189 +142,63 @@ a, button, input, select, textarea, table.data th, table.data td, html, body { margin: 0; padding: 0; - background: var(--bg); - color: var(--text); + background: var(--background); + color: var(--foreground); font-family: var(--font); font-size: 14.5px; line-height: 1.55; -webkit-font-smoothing: antialiased; + background-color: var(--background); } h1, h2, h3, h4 { font-weight: 600; margin: 0; } -/* round2 字阶:拉开 h1/h2/正文/次要说明的区分 */ h1 { font-size: 1.55rem; letter-spacing: -0.02em; line-height: 1.25; } h2 { font-size: 1.18rem; letter-spacing: -0.01em; } h3 { font-size: 1.02rem; } -.hint { color: var(--text-3); font-size: 0.82rem; line-height: 1.45; } -a { color: var(--teal-strong); text-decoration: none; } + +a { color: var(--brand-strong); text-decoration: none; } a:hover { text-decoration: underline; } p { margin: 0.4rem 0; } button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } -.btn { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.45rem 0.95rem; - border-radius: var(--radius); - border: 1px solid var(--border-strong); - background: var(--surface); - color: var(--text); - cursor: pointer; - font-size: 0.92rem; - transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; - white-space: nowrap; -} -.btn:hover:not(:disabled) { background: var(--surface-2); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { - outline: 2px solid var(--teal); - outline-offset: 1px; -} -.btn.primary { - background: var(--teal-strong); - border-color: var(--teal-strong); - color: #fff; -} -.btn.primary:hover:not(:disabled) { background: #115e59; } -.btn.danger { color: var(--danger); border-color: #f2c7c7; } -.btn.danger:hover:not(:disabled) { background: var(--danger-soft); } -.btn.ghost { border-color: transparent; background: transparent; } -.btn.ghost:hover:not(:disabled) { background: var(--surface-2); } -.btn.small { padding: 0.25rem 0.6rem; font-size: 0.83rem; } -.btn[aria-pressed='true'] { background: var(--teal-soft); border-color: var(--teal); color: var(--ink); } - -label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; } -input, select, textarea.plain { - width: 100%; - padding: 0.45rem 0.65rem; - border: 1px solid var(--border-strong); - border-radius: 8px; - background: var(--surface); - font-size: 0.92rem; -} -input[type='checkbox'], input[type='radio'] { width: auto; } - -.card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem 1.15rem; - box-shadow: var(--shadow); -} -.card + .card { margin-top: 0.9rem; } - -table.data { - width: 100%; - border-collapse: collapse; - font-size: 0.88rem; -} -table.data th { - text-align: left; - font-weight: 600; - color: var(--text-2); - border-bottom: 1px solid var(--border-strong); - padding: 0.45rem 0.6rem; - white-space: nowrap; -} -table.data td { - border-bottom: 1px solid var(--border); - padding: 0.42rem 0.6rem; - vertical-align: top; -} -table.data tr:hover td { background: var(--surface-2); } -.num { font-family: var(--mono); font-variant-numeric: tabular-nums; } - -.badge { - display: inline-block; - padding: 0.1rem 0.5rem; - border-radius: 999px; - font-size: 0.76rem; - font-weight: 600; - border: 1px solid transparent; -} -.badge.teal { background: var(--teal-soft); color: var(--teal-strong); } -.badge.grey { background: var(--surface-2); color: var(--text-2); border-color: var(--border); } -.badge.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0ddb4; } -.badge.danger { background: var(--danger-soft); color: var(--danger); border-color: #f2c7c7; } -.badge.ok { background: var(--ok-soft); color: var(--ok); border-color: #c3ecd0; } - -.banner { - border-radius: var(--radius); - padding: 0.65rem 0.9rem; - font-size: 0.9rem; - border: 1px solid; - display: flex; - gap: 0.6rem; - align-items: flex-start; -} -.banner.error { background: var(--danger-soft); border-color: #f2c7c7; color: var(--danger); } -.banner.warn { background: var(--warn-soft); border-color: #f0ddb4; color: var(--warn); } -.banner.info { background: var(--teal-soft); border-color: #c8eae6; color: var(--teal-strong); } - +/* 布局原语(非组件 facade):排列结构工具 */ .stack { display: flex; flex-direction: column; gap: 0.75rem; } .row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .spread { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } +.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; } +.table-wrap { overflow-x: auto; min-width: 0; } +.card, .stack, .row, .spread { min-width: 0; } -.tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--border); -} -.tabs button { - padding: 0.55rem 1rem; - border: none; - background: none; - color: var(--text-2); - font-size: 0.95rem; - cursor: pointer; - border-bottom: 2px solid transparent; - border-radius: 6px 6px 0 0; -} -.tabs button:hover { background: var(--surface-2); } -.tabs button[aria-selected='true'] { - color: var(--teal-strong); - font-weight: 600; - border-bottom-color: var(--teal); -} - -/* metric tiles shared by the results card and other metric rows; without - these the six result metrics stacked vertically with large blank space */ -.metric-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 0.7rem; -} -.metric { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 0.6rem 0.85rem; - min-width: 0; -} +/* Semaphore 数字排印(Stripe 仪表感):大字重 + 等宽数字 */ +.num { font-variant-numeric: tabular-nums; font-family: var(--mono); } .mlabel { color: var(--text-3); font-size: 0.8rem; } .mvalue { - font-size: 1.15rem; + font-size: 1.5rem; font-weight: 700; - color: var(--text); + letter-spacing: -0.01em; + color: var(--foreground); font-variant-numeric: tabular-nums; + font-family: var(--mono); overflow-wrap: anywhere; } -.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; } -.table-wrap { overflow-x: auto; min-width: 0; } - -@media (max-width: 900px) { - .cols-2 { grid-template-columns: minmax(0, 1fr); } - .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } +.mvalue-sub { color: var(--text-3); font-size: 0.78rem; font-variant-numeric: tabular-nums; } +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.7rem; } +.run-split { display: inline-flex; gap: 0.5rem; align-items: baseline; } +.run-split i { font-style: normal; color: var(--text-3); font-size: 0.75rem; } -code, pre { - font-family: var(--mono); - font-size: 0.85rem; -} +.hint { color: var(--text-3); font-size: 0.82rem; line-height: 1.45; } +label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; } +input, select, textarea { font: inherit; color: inherit; } + +code, pre { font-family: var(--mono); font-size: 0.85rem; } pre { margin: 0; white-space: pre-wrap; word-break: break-all; } .sr-only { @@ -275,63 +209,12 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-all; } border: 0; } -/* ---- narrow-viewport wrapping (no global content hiding) ---- - Real provider error strings contain long unbreakable URLs; .spread/.stack - children would otherwise propagate min-content outwards and inflate the - document width on mobile. `overflow-wrap` keeps every word reachable while - wrapping; nothing is hidden — long tables get a local scroll container. */ -.card, .stack, .row, .spread, .banner { min-width: 0; } +/* narrow-viewport wrapping(保留:文档级防溢出行为,非组件样式) */ .banner { overflow-wrap: anywhere; } .banner .err-list { overflow-wrap: anywhere; } -.card table.data { min-width: 0; } - -@media (max-width: 900px) { - /* collapse multi-column forms: the dataset wizard grid3 + label min-widths - measured 523px min-content at 375px viewport (mobile-overflow-probe) */ - .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } - /* wrap table cells instead of nowrap inflating min-content; a truly wide - tabular block still gets its own local scrollbar (.scrollx), content - remains fully accessible without a document-level horizontal overflow */ - table.data th, table.data td { - white-space: normal; - overflow-wrap: anywhere; - } - .btn { white-space: normal; } -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: #2dd4bf; - --primary-foreground: #04211f; - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: #0f766e; - --chart-2: #b45309; - --chart-3: #1d4ed8; - --chart-4: #8b1d8b; - --chart-5: #64748b; - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} +.table.data, table.data { min-width: 0; } +.mono-invite { font-family: var(--mono); font-size: 0.8rem; word-break: break-all; } +.ul-plain { margin: 0.15rem 0 0; padding-left: 1.1rem; } @theme inline { --font-sans: 'Inter Variable', sans-serif; @@ -373,6 +256,7 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-all; } --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --radius-0.75rem: var(--radius); } @layer base { @@ -386,3 +270,14 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-all; } @apply font-sans; } } + +/* ---- 窄视口收敛(保留行为:不做全局隐藏,仅塌缩/换行 + 局部滚动) ---- */ +@media (max-width: 900px) { + .cols-2 { grid-template-columns: minmax(0, 1fr); } + .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } + .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } + table.data th, table.data td { + white-space: normal; + overflow-wrap: anywhere; + } +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts new file mode 100644 index 0000000..ad949a4 --- /dev/null +++ b/frontend/src/lib/theme.ts @@ -0,0 +1,28 @@ +// 主题切换(round3):默认 mint;.theme-amber 类挂 切到 amber 变量组。 +// 两套 token 都是 app.css 里的 CSS 变量组,本模块只负责持久化与挂类。 + +export type BrandTheme = 'mint' | 'amber'; +const KEY = 'sl-brand-theme'; + +export function currentTheme(): BrandTheme { + if (typeof document === 'undefined' || typeof localStorage === 'undefined') return 'mint'; + const stored = localStorage.getItem(KEY); + return stored === 'amber' ? 'amber' : 'mint'; +} + +export function applyTheme(t: BrandTheme): void { + if (typeof document === 'undefined') return; + if (t === 'amber') document.documentElement.classList.add('theme-amber'); + else document.documentElement.classList.remove('theme-amber'); + if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, t); +} + +export function toggleTheme(): BrandTheme { + const next: BrandTheme = currentTheme() === 'mint' ? 'amber' : 'mint'; + applyTheme(next); + return next; +} + +if (typeof document !== 'undefined') { + applyTheme(currentTheme()); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 55b3a91..8048253 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,7 +1,7 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; -export function cn(...inputs: ClassValue[]) { +export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } -- cgit v1.2.3