diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c971c3..46b1b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · versi > 时间戳精确到分钟(`YYYY-MM-DD HH:MM`,北京时间 UTC+8)。同一天多次改动按时间倒序——最新在最上。每段下面扁平列表,前缀标类型(`add` / `change` / `fix` / `test` / `chore`)。 +### 2026-08-23 20:55 + +- **change(web)**:将 3237 行 `App.css` 按原始级联顺序机械拆为 8 个样式文件,单文件最大 495 物理行;入口文件只保留有序导入 +- **add(quality)**:新增 CSS 500 物理行门禁并接入前端 lint,递归覆盖 `web/src/**/*.css`,同时补行数计算和超限识别测试 +- **chore(docs)**:贡献指南补充 CSS 行数、导入顺序和构建验证约束,持续交接文档同步当前处理状态 + ### 2026-08-23 20:39 - **fix(history)**:历史详情和公开分享页的已作答场景视频不再挤进 `80×80` 缩略框,改为内容区全宽 `16:9` 播放器;静态图片继续使用紧凑缩略图 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 947317f..da2459b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,7 @@ git checkout master && git pull - **页面的关键状态必须可被 URL 还原**:进入一个新视图 / 子状态(如练习的"结果 / 反馈页")时,URL 要跟着变(path 段或 query param),且刷新后能从 URL + 后端数据重建该状态——绝不能"刷新就回到初始态、结果没了"。数据已落库的(如 attempt)刷新时从库里重建,不要只存内存。 - **测试要是代码**:不靠 curl 一次性脚本。 +- **CSS 文件不超过 500 物理行**:`pnpm run lint:styles` 递归检查 `web/src/**/*.css`,并已接入 `pnpm run lint`。`App.css` 只负责按顺序导入 `styles/app/*.css`,后面的文件允许有意覆盖前面的选择器,拆分或重排时必须保持级联顺序并运行生产构建。 - **批量调 LLM / 文生图很贵**:默认一次 ≤5 个;先 `--dry-run` 验证文案,再花生图钱。详见 `docs/design/场景练习/scenario-taxonomy.md`。 - **文档随代码一起改**:`docs/业务/*.md` 记录**当前已实现**行为(一个模块一篇),任何对外行为变更必须在同一次 PR 里同步更新对应业务文档(没有就新建一篇)+ `CHANGELOG.md`;数据模型变更同步 `docs/design/schema.md`。设计稿(`docs/design/`)落地后回写进展。重要改动另在 `docs/changelog/<时间戳>-<名字>/` 追加一条详细记录(背景/权衡/验证,只增不改)。文档地图见 `docs/README.md`。 diff --git "a/docs/SpeakUp-\351\234\200\346\261\202\344\270\216\350\277\233\345\272\246\344\272\244\346\216\245-2026-08-23.md" "b/docs/SpeakUp-\351\234\200\346\261\202\344\270\216\350\277\233\345\272\246\344\272\244\346\216\245-2026-08-23.md" index 496f483..2764b8e 100644 --- "a/docs/SpeakUp-\351\234\200\346\261\202\344\270\216\350\277\233\345\272\246\344\272\244\346\216\245-2026-08-23.md" +++ "b/docs/SpeakUp-\351\234\200\346\261\202\344\270\216\350\277\233\345\272\246\344\272\244\346\216\245-2026-08-23.md" @@ -5,14 +5,14 @@ ## 一、当前基线 - 仓库:`/Users/mint/workspace/learning/speakup` -- 当前分支:`fix-history-video-layout` -- 当前分支基于 `master` 提交 `1adb149` 创建;分支最新提交以 `git rev-parse HEAD` 为准。 +- 当前分支:`refactor-split-web-styles` +- 当前分支基于已上线的 `master` 提交 `12e393c` 创建;分支最新提交以 `git rev-parse HEAD` 为准。 - 已上线功能 PR:[#164](https://github.com/makewheels/speakup/pull/164) - 部署版本探针修复 PR:[#165](https://github.com/makewheels/speakup/pull/165) - 线上版本此前已通过 `/api/version` 核对为当前 `master`,健康接口正常。 - 工作区有一份用户自己的未跟踪文档:`docs/multica-线上开发环境.md`。不要修改、暂存或删除。 -- 本交接文档是当前任务新增的持久文档;History 视频修复已提交并创建 [PR #166](https://github.com/makewheels/speakup/pull/166),待 CI、合并、部署和线上验证。 -- 当前任务修改文件:视频布局与回归测试、`CHANGELOG.md`、`docs/README.md`、`docs/业务/4-历史与分享.md` 和本文档;提交前以 `git status --short` 为准。 +- 本交接文档是当前任务新增的持久文档;History 视频修复 [PR #166](https://github.com/makewheels/speakup/pull/166) 已合并、由流水线部署并完成线上版本核验,功能通知工作流已成功发送。 +- 当前任务正在处理 CSS 拆分与 500 行门禁;提交前以 `git status --short` 为准。 ## 二、需求总表 @@ -25,12 +25,12 @@ | 5 | 差距卡与追问输入区重做 | 已完成并上线 | 类型与标题合并;核心三行固定为「你说 / 这样说 / 为什么」;例句折叠;移除 Meaning;追问区加宽。 | | 6 | 功能完成邮件通知 | 已完成基础设施 | 工作流、HTML/纯文本邮件和 Infisical OIDC 已上线;收件配置已纠正并完成无明文读回校验。 | | 7 | 发音、重音和近音词纠正方案调研 | 调研完成,未接入 | 见 `docs/评测/发音纠正选型调研.md`;未购买、未部署、未改产品链路。 | -| 8 | `App.css` 超长及 500 行门禁 | 待处理 | 已审计,必须拆分并补 CSS/物理行数门禁。 | +| 8 | `App.css` 超长及 500 行门禁 | **PR #167,待合并与上线** | 已按原级联顺序拆为 8 个文件,最大 495 行;CSS 物理行数检查已接入 lint,282 项测试通过。 | | 9 | 网站首次打开请求地理位置 | 待处理 | 已确认只为按日出日落切换主题;业务不需要,应移除。 | | 10 | 产品埋点、用户轨迹和统一 `trace_id` | 调研完成,待产品决定 | 当前没有完整产品埋点;尚未部署任何新系统。 | -| 11 | History/历史详情视频播放器太小、缩在左上角 | **PR #166,待合并与上线** | 视频已移出固定 `80×80` 的 hero 缩略框;自动化验证已通过,真实页面视觉检查仍待补。 | +| 11 | History/历史详情视频播放器太小、缩在左上角 | **已完成并上线** | PR #166 已合并;线上版本为 `12e393c`,健康接口和生产资源均已核验。真实设备视觉检查仍待补。 | -## 三、History 视频播放器:本地修复已完成 +## 三、History 视频播放器:已上线 ### 现象 @@ -66,23 +66,25 @@ - 浅色/深色均检查;播放、暂停、进度条和全屏按钮可用。 - 运行 `pnpm run lint`、相关测试、全量前端测试和 `pnpm run build`。 - 当前会话的浏览器控制能力未暴露,尚未完成真实页面视觉复现;后续执行者若有浏览器能力,必须补做视觉检查,不能只凭测试宣称 UI 已验证。 -- 已完成:本地修改、提交、推送和 PR #166。 -- 尚未完成:CI、合并、生产部署、线上烟测和真实页面视觉检查。 +- 已完成:本地修改、测试、PR #166、CI、合并、自动部署、线上版本/健康/静态资源核验和功能邮件通知。 +- 尚未完成:当前环境缺少浏览器控制入口,因此真实页面与手机宽度视觉检查未完成。 ## 四、CSS 拆分与 500 行门禁 -### 已确认事实 +### 原始问题与当前状态 -- `web/src/App.css` 当前仍有 **3200 多行**(本分支现为 3237 行)。 +- 原 `web/src/App.css` 有 3237 行;现已变为有序导入入口,原内容机械拆到 `web/src/styles/app/` 的 8 个文件中。 +- 8 个文件依次为 479、417、366、355、444、495、477、204 行,均不超过 500 物理行。 +- 机械拆分完成时,文件按顺序拼接后的 SHA-256 与原 `App.css` 完全一致;随后只中性化了一条旧注释,CSS 规则和顺序未变,Vite 构建产出的 CSS 资源文件名也保持不变。 - `web/eslint.config.js` 只匹配 `**/*.{js,jsx}`;CSS 完全不受 ESLint 管理。 - `max-lines` 虽配置为 500,但跳过空行和注释,并非严格物理行数。 -- `pnpm exec eslint src/App.css` 只提示没有匹配配置,退出码仍为 0。 +- 已新增 `web/scripts/check-style-lines.js`,对 `web/src/**/*.css` 执行严格物理行数检查,并通过 `pnpm run lint` 强制执行。 - `PracticePage.jsx` 物理 576 行,但按现规则有效行 496,因此仍通过。 -- 如果采用“所有代码文件物理行数不超过 500”的严格口径,还要处理数个超长测试文件,不能只拆 CSS。 +- 本次门禁专门覆盖 CSS;如果采用“所有 JS/JSX 也按物理行不超过 500”的更严格口径,还需单独拆分 `PracticePage.jsx` 和两个测试文件,不能伪装成已经完成。 -### 安全拆分顺序 +### 已实施拆分顺序 -第一步只做机械切片,保持原始级联顺序,不同时删除旧样式。建议由一个 `styles/index.css` 按顺序导入: +本次只做机械切片,保持原始级联顺序,不同时删除旧样式。由 `App.css` 按顺序导入: - tokens/theme - shell/primitives @@ -101,17 +103,18 @@ - free practice - 现有 `practice-preferences.css` 继续最后加载 -每个文件必须小于 500 物理行。重点保护 `.detail-hero`、`.chip`、`.su-btn`、`.active`、`.loading` 等依赖后写覆盖的选择器。 +每个文件必须不超过 500 物理行。重点保护 `.detail-hero`、`.chip`、`.su-btn`、`.active`、`.loading` 等依赖后写覆盖的选择器。 第二步再通过浏览器 CSS Coverage 和 JSX 动态类名审计删除约 490 行疑似旧样式;不要把“拆分”和“删除”放在同一提交。 -### 门禁建议 +### 已实施门禁与后续 -- 新增前端源码物理行数检查,覆盖 `web/src/**/*.{js,jsx,css}`。 -- 将检查接入 `pnpm run lint` 与 CI。 +- 已新增 CSS 物理行数检查,覆盖 `web/src/**/*.css`。 +- 已将检查接入 `pnpm run lint`,因此现有前端 CI 自动执行。 - CSS 语法/规则另接 Stylelint;不要简单把 CSS 后缀加入 ESLint 的 JS 配置。 -- 在 `CONTRIBUTING.md` 写清楚本地命令和严格口径。 +- 已在 `CONTRIBUTING.md` 写清本地命令、严格口径和级联顺序约束。 - 现有 23 个 warning 尚未清零,不能直接加 `--max-warnings=0` 而不先治理。 +- 已提交并创建 [PR #167](https://github.com/makewheels/speakup/pull/167),待 CI、合并、自动部署与线上静态资源核验。 ## 五、移除地理位置权限 @@ -199,8 +202,8 @@ ## 十、建议执行顺序 -1. 等待 PR #166 的 CI,通过后合并并只由流水线部署;随后核对线上版本并尽可能补真实页面视觉验证。 -2. 单独 PR 做 `App.css` 机械拆分和物理 500 行门禁。 +1. 完成 CSS 拆分分支的全量前端验证、PR、CI、合并、自动部署和线上资源核验。 +2. 如要把 JS/JSX 也改成严格物理 500 行,另开 PR 拆分 3 个既存超长文件。 3. 单独 PR 移除地理位置请求、改为跟随系统主题。 4. 由产品明确选择埋点目标后,再写事件字典和隐私方案;未决定前不部署分析系统。 5. 轮换邮件授权码并更新 Infisical,完成一次明确授权后的真实通知验证。 diff --git "a/docs/changelog/20260823-2055-\345\211\215\347\253\257\346\240\267\345\274\217\346\213\206\345\210\206/README.md" "b/docs/changelog/20260823-2055-\345\211\215\347\253\257\346\240\267\345\274\217\346\213\206\345\210\206/README.md" new file mode 100644 index 0000000..0c49d83 --- /dev/null +++ "b/docs/changelog/20260823-2055-\345\211\215\347\253\257\346\240\267\345\274\217\346\213\206\345\210\206/README.md" @@ -0,0 +1,36 @@ +# 前端样式拆分 + +- 时间:2026-08-23 20:55(北京) +- 关联 PR:#167 + +## 背景 + +前端所有主要样式长期集中在 3237 行的 `App.css` 中。现有 ESLint 只检查 JS/JSX,无法阻止 CSS 文件继续增长,也让修改局部页面样式时难以确认级联影响范围。 + +## 方案与权衡 + +- 按现有顶层章节边界把 `App.css` 机械拆成 8 个文件,由原入口按固定顺序导入; +- 不在同一次改动中删除疑似废弃选择器或重新组织规则,优先保证级联顺序不变; +- 每个拆分文件不超过 500 物理行,最大文件为 495 行; +- 新增独立 Node 检查脚本,递归扫描 `web/src/**/*.css`,500 行允许、501 行失败; +- 将检查接入 `pnpm run lint`,复用现有 CI,无需再引入样式工具依赖; +- 新增自动化测试覆盖末尾换行计数、递归扫描、忽略非 CSS 文件和超限识别。 + +机械切片完成时,8 个文件按顺序拼接的 SHA-256 与原文件完全一致。随后只中性化了一条旧注释,CSS 规则及其顺序没有变化。Vite 构建产出的 CSS 资源文件名也与拆分前一致。 + +本轮门禁只解决用户指出的 CSS 问题。JS/JSX 继续使用 ESLint 的 500 有效行规则;现有 3 个物理行超过 500 的 JS/JSX 文件如需改成严格物理行口径,应另开 PR,避免把逻辑重构混入纯样式迁移。 + +## 验证 + +- CSS 行数检查扫描 11 个文件,全部不超过 500 行; +- 门禁测试 2 项通过; +- 前端全量测试:26 个文件、282 项通过; +- 覆盖率:Statements 88.03%、Branches 81.86%、Functions 86.11%、Lines 91.66%; +- ESLint:0 error,保留 23 个既存 warning; +- Vite 生产构建通过,CSS 资源文件名与拆分前一致; +- `git diff --check` 通过; +- 独立只读复核确认切点均位于顶层规则之间,导入路径和原始级联顺序正确。 + +## 修复与后续 + +后续删除旧 CSS 前,应先结合真实页面走查和 CSS Coverage 独立处理,不要把删除与本次机械拆分混为一项。若继续推进全源码严格 500 物理行,需要先拆分既存的超长页面与测试文件,再扩大门禁范围。 diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 8739668..3c25f63 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -38,6 +38,7 @@ | 目录 | 内容 | |------|------| +| [20260823-2055-前端样式拆分](20260823-2055-前端样式拆分/README.md) | App.css 机械拆分与 CSS 500 物理行门禁 | | [20260823-2040-历史视频播放器](20260823-2040-历史视频播放器/README.md) | History 详情与公开分享页的已作答视频改为全宽播放器 | | [20260823-1952-结果页笔记与独立标准答案](20260823-1952-结果页笔记与独立标准答案/README.md) | 手动笔记、结果分享、隔离标准答案、差距卡、通知与发音调研 | | [20260819-1014-结果页与自由录入](20260819-1014-结果页与自由录入/README.md) | 结果页打磨(深色配色/gap 字段/改名/自动笔记)+ 自由录入模式 + 文档重构 | diff --git a/web/eslint.config.js b/web/eslint.config.js index d7f2bc2..c4b762c 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -24,4 +24,10 @@ export default defineConfig([ complexity: ['warn', 15], }, }, + { + files: ['scripts/**/*.js'], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/web/package.json b/web/package.json index 4aea789..de8c313 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "pnpm run lint:styles && eslint .", + "lint:styles": "node scripts/check-style-lines.js", "preview": "vite preview", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/web/scripts/check-style-lines.js b/web/scripts/check-style-lines.js new file mode 100644 index 0000000..466ccb1 --- /dev/null +++ b/web/scripts/check-style-lines.js @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const MAX_STYLE_LINES = 500; + +export function countPhysicalLines(source) { + if (!source) return 0; + const lines = source.split(/\r\n|\r|\n/).length; + return /(?:\r\n|\r|\n)$/.test(source) ? lines - 1 : lines; +} + +async function listCssFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await listCssFiles(entryPath)); + } else if (entry.isFile() && entry.name.endsWith(".css")) { + files.push(entryPath); + } + } + + return files.sort(); +} + +export async function inspectStyleLines(rootDirectory, limit = MAX_STYLE_LINES) { + const files = await listCssFiles(rootDirectory); + const oversized = []; + + for (const file of files) { + const source = await readFile(file, "utf8"); + const lines = countPhysicalLines(source); + if (lines > limit) { + oversized.push({ + file: path.relative(rootDirectory, file), + lines, + }); + } + } + + return { files, oversized }; +} + +async function main() { + const sourceRoot = fileURLToPath(new URL("../src/", import.meta.url)); + const { files, oversized } = await inspectStyleLines(sourceRoot); + + if (oversized.length > 0) { + console.error(`CSS physical line limit exceeded (max ${MAX_STYLE_LINES}):`); + for (const item of oversized) { + console.error(`- ${item.file}: ${item.lines} lines`); + } + process.exitCode = 1; + return; + } + + console.log(`CSS line check passed: ${files.length} files <= ${MAX_STYLE_LINES} physical lines`); +} + +const invokedUrl = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ""; + +if (import.meta.url === invokedUrl) { + await main(); +} diff --git a/web/scripts/check-style-lines.test.js b/web/scripts/check-style-lines.test.js new file mode 100644 index 0000000..ce70f25 --- /dev/null +++ b/web/scripts/check-style-lines.test.js @@ -0,0 +1,50 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { countPhysicalLines, inspectStyleLines } from "./check-style-lines.js"; + +const createdDirectories = []; + +async function createTaskTempDirectory() { + const directory = await mkdtemp(path.join(tmpdir(), "speakup-style-lines-")); + createdDirectories.push(directory); + return directory; +} + +afterEach(async () => { + const tempRoot = `${path.resolve(tmpdir())}${path.sep}`; + for (const directory of createdDirectories.splice(0)) { + const resolved = path.resolve(directory); + if (!resolved.startsWith(tempRoot) || !path.basename(resolved).startsWith("speakup-style-lines-")) { + throw new Error(`Refusing to clean unexpected path: ${resolved}`); + } + await rm(resolved, { recursive: true, force: true }); + } +}); + +describe("check-style-lines", () => { + it("counts physical lines without inventing a line after the final newline", () => { + expect(countPhysicalLines("")).toBe(0); + expect(countPhysicalLines("a")).toBe(1); + expect(countPhysicalLines("a\n")).toBe(1); + expect(countPhysicalLines("a\n\n")).toBe(2); + expect(countPhysicalLines("a\r\nb")).toBe(2); + }); + + it("reports only CSS files above the configured limit", async () => { + const root = await createTaskTempDirectory(); + await mkdir(path.join(root, "nested")); + await writeFile(path.join(root, "ok.css"), "a {}\n".repeat(3)); + await writeFile(path.join(root, "nested", "too-long.css"), "a {}\n".repeat(6)); + await writeFile(path.join(root, "ignored.js"), "x\n".repeat(20)); + + const result = await inspectStyleLines(root, 5); + + expect(result.files).toHaveLength(2); + expect(result.oversized).toEqual([ + { file: path.join("nested", "too-long.css"), lines: 6 }, + ]); + }); +}); diff --git a/web/src/App.css b/web/src/App.css index 15dd577..477e3b7 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,3237 +1,9 @@ -/* ────────────────────────────────────────────── - SpeakUp · design tokens (ported from Claude Design) - ────────────────────────────────────────────── */ - -:root { - --ff-serif: "Newsreader", "Source Serif Pro", Georgia, serif; - --ff-sans: "Geist", "Helvetica Neue", Helvetica, system-ui, sans-serif; - --ff-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --ff-cn: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", system-ui, sans-serif; - /* 学习内容统一英文字体,反馈/复习里不再 serif/mono 混排 */ - --ff-en: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; - - --bg: oklch(0.985 0.005 75); - --bg-elev: #ffffff; - --bg-mute: oklch(0.955 0.006 75); - --bg-deep: oklch(0.925 0.008 75); - - --ink: oklch(0.20 0.012 60); - --ink-2: oklch(0.42 0.010 60); - --ink-3: oklch(0.62 0.008 60); - --ink-4: oklch(0.78 0.006 60); - --rule: oklch(0.90 0.008 60); - - --accent: oklch(0.45 0.085 245); - --accent-soft:oklch(0.94 0.025 245); - --warn: oklch(0.52 0.135 25); - --warn-soft: oklch(0.95 0.025 25); - --ok: oklch(0.50 0.085 155); - --ok-soft: oklch(0.95 0.025 155); - - /* 录音按钮专用红:两种主题下都保持红色(录音键的通用语义), - 不复用 --ink(深色主题下 ink 反转为浅色,按钮会显得像被置灰禁用) */ - --rec: oklch(0.55 0.20 27); - --rec-glow: oklch(0.55 0.20 27 / 0.35); - - /* gap 序号徽章:深底白字。不能复用 --ink 做底——深色主题下 ink 反转为浅色, - 会变成"浅灰底 + 白字"看不清 */ - --gap-num-bg: oklch(0.20 0.012 60); - --gap-num-ink:#fff; - - /* 「试着用上」提示条(默认浅色值,深色主题覆盖) */ - --hint-bg: oklch(0.97 0.03 90); - --hint-rule: oklch(0.88 0.06 90); -} - -/* ────────────────────────────────────────────── - 深色主题:按日出日落自动切换(见 lib/theme.js), - 只覆盖语义 token,组件内引用 token 处自动跟随 - ────────────────────────────────────────────── */ -:root[data-theme="dark"] { - color-scheme: dark; - - --bg: oklch(0.20 0.008 60); - --bg-elev: oklch(0.25 0.010 60); - --bg-mute: oklch(0.28 0.011 60); - --bg-deep: oklch(0.16 0.008 60); - - --ink: oklch(0.93 0.006 75); - --ink-2: oklch(0.76 0.008 75); - --ink-3: oklch(0.58 0.008 75); - --ink-4: oklch(0.44 0.008 75); - --rule: oklch(0.34 0.010 60); - - --accent: oklch(0.72 0.10 245); - --accent-soft:oklch(0.33 0.05 245); - --warn: oklch(0.70 0.13 25); - --warn-soft: oklch(0.33 0.05 25); - --ok: oklch(0.72 0.10 155); - --ok-soft: oklch(0.33 0.04 155); - - /* 深色下录音红略提亮,保证在深底上的对比度 */ - --rec: oklch(0.63 0.19 27); - --rec-glow: oklch(0.63 0.19 27 / 0.35); - - /* 深色下序号徽章用中灰底 + 白字,避免"浅底白字"看不清 */ - --gap-num-bg: oklch(0.46 0.008 75); - --gap-num-ink:#fff; - - --hint-bg: oklch(0.28 0.02 80); - --hint-rule: oklch(0.38 0.03 80); -} - -/* 图片占位条纹 & 半透明标签:深色下压低亮度 */ -:root[data-theme="dark"] .su-img, -:root[data-theme="dark"] .mistake-row .thumb, -:root[data-theme="dark"] .history-thumb, -:root[data-theme="dark"] .detail-hero-placeholder, -:root[data-theme="dark"] .review-card-img { - background: - repeating-linear-gradient( - 135deg, - oklch(0.27 0.012 70) 0 12px, - oklch(0.30 0.008 70) 12px 24px - ); -} -:root[data-theme="dark"] .su-img.loading { - background: - linear-gradient(110deg, oklch(0.27 0.01 70) 8%, oklch(0.31 0.008 70) 18%, oklch(0.27 0.01 70) 33%); - background-size: 200% 100%; -} -:root[data-theme="dark"] .media-loading { background: rgba(0, 0, 0, 0.6); color: var(--ink-2); } - -* { box-sizing: border-box; } - -html, body, #root { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--ink); - font-family: var(--ff-sans), var(--ff-cn); - font-size: 15px; - line-height: 1.45; - letter-spacing: -0.005em; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -button { font-family: inherit; } - -/* ────────────────────────────────────────────── - SHELL · mobile-first, 720px cap for desktop - ────────────────────────────────────────────── */ - -.app-shell { - max-width: 720px; - margin: 0 auto; - min-height: 100dvh; - display: flex; - flex-direction: column; - background: var(--bg); -} - -.app-main { - flex: 1; - padding: 24px 18px 100px; -} - -/* ────────────────────────────────────────────── - BOTTOM TAB BAR - ────────────────────────────────────────────── */ - -.su-tabbar { - position: fixed; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: 720px; - height: 84px; - padding: 8px 0 calc(env(safe-area-inset-bottom, 0px) + 8px); - background: var(--bg); - border-top: 1px solid var(--rule); - display: flex; - align-items: stretch; - z-index: 100; -} - -.su-tab { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - font-family: var(--ff-cn); - font-size: 11px; - letter-spacing: 0.04em; - color: var(--ink-3); - cursor: pointer; - position: relative; - text-decoration: none; -} - -.su-tab.active { color: var(--ink); } - -.su-tab .badge { - position: absolute; - top: 6px; - left: calc(50% + 10px); - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 8px; - background: var(--warn); - color: #fff; - font-family: var(--ff-sans); - font-size: 10px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - letter-spacing: 0; -} - -/* ────────────────────────────────────────────── - TYPOGRAPHY helpers - ────────────────────────────────────────────── */ - -.eyebrow { - font-family: var(--ff-mono); - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--ink-3); -} - -.serif { - font-family: var(--ff-serif); - font-weight: 400; - letter-spacing: -0.015em; -} - -.mono { font-family: var(--ff-mono); } - -/* ────────────────────────────────────────────── - BUTTONS - ────────────────────────────────────────────── */ - -.su-btn { - font-family: var(--ff-cn); - font-size: 15px; - font-weight: 500; - letter-spacing: 0.02em; - border: 0; - background: transparent; - cursor: pointer; - padding: 0; - color: var(--ink); - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.su-btn-primary { - height: 52px; - border-radius: 12px; - background: var(--ink); - color: var(--bg); - width: 100%; -} - -.su-btn-primary[disabled], -.su-btn-primary.disabled { - background: var(--ink-4); - color: var(--bg); - cursor: not-allowed; -} - -.su-btn-secondary { - height: 52px; - border-radius: 12px; - background: transparent; - border: 1px solid var(--rule); - color: var(--ink); - width: 100%; -} - -.su-btn-tertiary { - height: 44px; - border-radius: 10px; - background: var(--bg-mute); - color: var(--ink-2); - padding: 0 16px; -} - -.su-btn-danger { - height: 48px; - border-radius: 12px; - background: transparent; - border: 1px solid var(--rule); - color: var(--warn); - width: 100%; -} - -/* ────────────────────────────────────────────── - CARDS - ────────────────────────────────────────────── */ - -.su-card { - background: var(--bg-elev); - border: 1px solid var(--rule); - border-radius: 14px; - padding: 16px; -} - -.su-card .card-eyebrow { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; -} - -.su-card .en { - font-family: var(--ff-serif); - font-size: 17px; - line-height: 1.55; - letter-spacing: -0.01em; - color: var(--ink); -} - -/* ────────────────────────────────────────────── - IMAGE + skeleton - ────────────────────────────────────────────── */ - -.su-img { - width: calc(100% - 20px); - aspect-ratio: 1 / 1; - border-radius: 18px; - position: relative; - overflow: hidden; - margin: 0 auto 16px; - background: - repeating-linear-gradient( - 135deg, - oklch(0.93 0.013 70) 0 12px, - oklch(0.96 0.008 70) 12px 24px - ); - display: flex; - align-items: center; - justify-content: center; -} - -.su-img.loading { - background: - linear-gradient(110deg, oklch(0.93 0.01 70) 8%, oklch(0.96 0.008 70) 18%, oklch(0.93 0.01 70) 33%); - background-size: 200% 100%; - animation: shimmer 1.6s linear infinite; -} - -@keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -.su-img img, -.su-img video { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.su-img .caption { - position: absolute; - left: 12px; - bottom: 12px; - font-family: var(--ff-mono); - font-size: 11px; - letter-spacing: 0.06em; - color: var(--ink-3); - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(6px); - padding: 4px 10px; - border-radius: 999px; -} - -.su-skip { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - height: 42px; - margin-top: 22px; - padding: 0 22px; - border: 1px solid var(--rule); - border-radius: 999px; - background: var(--bg-elev); - color: var(--ink-2); - font-family: var(--ff-cn); - font-size: 15px; - font-weight: 600; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - transition: transform 0.12s, background 0.12s, color 0.12s; -} -.su-skip:hover { background: var(--accent-soft); color: var(--accent); } -.su-skip:active { transform: scale(0.97); } - -/* ────────────────────────────────────────────── - RECORD button - ────────────────────────────────────────────── */ - -.su-rec-wrap { - display: flex; - flex-direction: column; - align-items: center; - margin-top: 8px; -} - -.su-rec { - width: 88px; - height: 88px; - border-radius: 50%; - background: var(--rec); - color: var(--bg); - border: 0; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: transform .15s ease; - touch-action: manipulation; - user-select: none; -} - -.su-rec:active { transform: scale(0.97); } -.su-rec:disabled { opacity: 0.4; cursor: not-allowed; } - -.su-rec.recording { - background: var(--rec); - animation: pulse 1.4s ease-out infinite; -} - -@keyframes pulse { - 0%, 100% { box-shadow: 0 0 0 0 var(--rec-glow); } - 50% { box-shadow: 0 0 0 14px oklch(0.55 0.20 27 / 0); } -} - -/* 录音中:暂停(左) + 完成(中大) + 重录(右) 一行 */ -.su-rec-row { - display: flex; - align-items: center; - justify-content: center; - gap: 26px; -} - -.su-rec-side { - width: 48px; - height: 48px; - border-radius: 50%; - border: 1.5px solid var(--rule); - background: var(--bg-elev); - color: var(--ink-2); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: transform .15s ease; - touch-action: manipulation; - user-select: none; -} - -.su-rec-side:active { transform: scale(0.93); } - -/* 暂停态:大按钮灰色、停掉呼吸动画 */ -.su-rec.recording.paused { - background: var(--ink-3); - animation: none; -} - -.su-rec-label { - font-family: var(--ff-cn); - font-size: 14px; - color: var(--ink-2); - font-weight: 700; - letter-spacing: 0; - text-align: center; - margin-top: 12px; -} - -.su-rec-hint { - font-family: var(--ff-cn); - font-size: 12px; - color: var(--ink-3); - text-align: center; - margin-top: 4px; - letter-spacing: 0; -} - -.su-prompt { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-2); - text-align: center; - margin: 18px 0 14px; - letter-spacing: 0; -} - -/* ────────────────────────────────────────────── - TRANSCRIPT - ────────────────────────────────────────────── */ - -.su-transcript { - min-height: 88px; - width: 100%; - border-radius: 12px; - background: var(--bg-mute); - border: 1px solid var(--rule); - padding: 14px 16px; - font-family: var(--ff-serif); - font-size: 17px; - line-height: 1.55; - letter-spacing: -0.01em; - color: var(--ink); -} - -.su-transcript.empty { - color: var(--ink-4); - font-family: var(--ff-cn); - font-size: 14px; -} - -.su-transcript-input { - box-sizing: border-box; - display: block; - min-height: 112px; - resize: vertical; -} - -.su-transcript-fallback { - color: var(--warn); - font-family: var(--ff-cn); - font-size: 13px; - line-height: 1.55; - margin: 8px auto 0; - max-width: 640px; - text-align: center; -} - -.su-transcript .live-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--warn); - margin-left: 4px; - vertical-align: middle; - animation: blink 1s ease-in-out infinite; -} - -@keyframes blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.2; } -} - -.su-rec-meta { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-top: 14px; -} - -.su-rec-meta .rec-dot { - font-family: var(--ff-mono); - font-size: 13px; - color: var(--rec); - letter-spacing: 0.04em; -} - -.su-rec-meta .rec-dot.paused { color: var(--ink-3); } - -.su-rec-meta .elapsed { - font-family: var(--ff-mono); - font-size: 13px; - color: var(--ink-2); -} - -/* ────────────────────────────────────────────── - GAP rows (3-col grid: original → better, why below) - ────────────────────────────────────────────── */ - -.su-corr { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 10px; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid var(--rule); -} - -.su-corr:last-child { border-bottom: 0; } - -.su-corr .from { - font-family: var(--ff-mono); - font-size: 15px; - color: var(--warn); - text-decoration: line-through; - text-decoration-color: var(--warn); - text-decoration-thickness: 1px; -} - -.su-corr .arrow { - font-family: var(--ff-mono); - color: var(--ink-3); - font-size: 16px; -} - -.su-corr .to { - font-family: var(--ff-mono); - font-size: 15px; - color: var(--accent); - font-weight: 600; -} - -.su-corr .reason { - grid-column: 1 / -1; - font-family: var(--ff-cn); - font-size: 14px; - color: var(--ink-2); - padding-top: 4px; - line-height: 1.6; -} - -.su-corr .example { - grid-column: 1 / -1; - font-family: var(--ff-serif); - font-size: 14px; - color: var(--accent); - font-style: italic; - padding-top: 2px; - line-height: 1.55; -} - -.su-corr .reason .cat { - font-family: var(--ff-mono); - font-size: 11px; - color: var(--ink-4); - letter-spacing: 0.08em; - text-transform: uppercase; - margin-right: 6px; -} - -/* ────────────────────────────────────────────── - CHIPS - ────────────────────────────────────────────── */ - -.chip { - display: inline-flex; - align-items: center; - height: 26px; - padding: 0 10px; - border-radius: 999px; - background: var(--bg-deep); - color: var(--ink); - font-family: var(--ff-mono); - font-size: 12px; - letter-spacing: 0.01em; -} - -.chip.accent { background: var(--accent-soft); color: var(--accent); } -.chip.warn { background: var(--warn-soft); color: var(--warn); } -.chip.ok { background: var(--ok-soft); color: var(--ok); } - -.dot { - display: inline-block; - width: 4px; height: 4px; - border-radius: 50%; - background: var(--ink-4); - margin: 0 6px; - vertical-align: middle; -} - -.hr { height: 1px; background: var(--rule); margin: 18px 0; border: 0; } - -/* ────────────────────────────────────────────── - PAGE: Login - ────────────────────────────────────────────── */ - -.login-page { - display: flex; - flex-direction: column; - min-height: 100dvh; - padding: 0 28px; -} - -.login-page .brand { - margin-top: 96px; - margin-bottom: 56px; -} - -.login-page .brand-eyebrow { - margin-bottom: 14px; -} - -.login-page h1 { - font-family: var(--ff-serif); - font-size: 48px; - font-weight: 400; - line-height: 1.02; - margin: 0; - letter-spacing: -0.025em; -} - -.login-page .subtitle { - font-family: var(--ff-cn); - font-size: 15px; - color: var(--ink-2); - margin: 14px 0 0; - line-height: 1.55; - max-width: 280px; - letter-spacing: 0; -} - -.login-field { - display: block; -} - -.login-field-row { - display: flex; - align-items: center; - gap: 10px; - border-bottom: 1px solid var(--ink); - padding-bottom: 10px; -} - -.login-field-row.error { border-bottom-color: var(--warn); } - -.login-field-row .cc { - font-family: var(--ff-mono); - font-size: 18px; - color: var(--ink-2); -} - -.login-field-row input { - flex: 1; - font-family: var(--ff-mono); - font-size: 22px; - color: var(--ink); - letter-spacing: 0.04em; - border: 0; - outline: 0; - background: transparent; - padding: 0; -} - -.login-field-row input::placeholder { color: var(--ink-4); } - -.login-page .error-text { - font-family: var(--ff-cn); - font-size: 12px; - color: var(--warn); - margin-top: 8px; - letter-spacing: 0; -} - -.login-page .hint { - font-family: var(--ff-cn); - font-size: 12px; - color: var(--ink-3); - margin-top: 18px; - line-height: 1.55; - letter-spacing: 0; -} - -.login-page .spacer { flex: 1; } - -.login-page .submit { margin-bottom: 22px; } - -.login-page .footer-note { - font-family: var(--ff-mono); - font-size: 10px; - color: var(--ink-3); - text-align: center; - letter-spacing: 0.1em; - text-transform: uppercase; - margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px); -} - -/* ────────────────────────────────────────────── - PAGE: Practice - ────────────────────────────────────────────── */ - -.practice-page .topbar { - display: flex; - justify-content: flex-end; - align-items: center; - margin-bottom: 12px; - min-height: 24px; -} - -.practice-page .topbar .scene-tag { - font-family: var(--ff-mono); - font-size: 11px; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--ink-3); -} - -.practice-page .due-strip { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: var(--bg-mute); - border: 1px solid var(--rule); - border-radius: 10px; - margin-bottom: 16px; -} - -.practice-page .due-strip .text { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-2); -} - -.practice-page .due-strip .text strong { color: var(--ink); } - -.practice-page .due-strip .action { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--accent); - cursor: pointer; - background: transparent; - border: 0; - padding: 0; -} - -.actions-row { - display: flex; - gap: 10px; - margin-top: 8px; -} - -/* ────────────────────────────────────────────── - PAGE: Feedback (inside practice) - ────────────────────────────────────────────── */ - -.fb-page .scene-thumb { - display: flex; - gap: 14px; - align-items: center; - margin-bottom: 18px; -} - -.fb-page .scene-thumb img { - width: 64px; - height: 64px; - border-radius: 12px; - object-fit: cover; - flex: 0 0 auto; -} - -.fb-page .scene-thumb .info { display: flex; flex-direction: column; gap: 2px; } - -.fb-page .scene-thumb .topic { - font-family: var(--ff-mono); - font-size: 13px; - color: var(--ink); -} - -.fb-page .scene-thumb .when { - font-family: var(--ff-cn); - font-size: 11px; - color: var(--ink-3); -} - -.fb-page .summary-card { - background: var(--ink); - color: var(--bg); - border-radius: 16px; - padding: 20px; - margin: 6px 0 14px; -} - -.fb-page .summary-card .eyebrow { - color: oklch(0.78 0.005 60); - margin-bottom: 8px; -} - -.fb-page .summary-card .text { - font-family: var(--ff-serif); - font-size: 20px; - line-height: 1.4; - letter-spacing: -0.01em; - margin: 0; -} - -.fb-page .section-title { - font-family: var(--ff-cn); - font-size: 13px; - font-weight: 600; - color: var(--ink); - margin: 18px 0 6px; - letter-spacing: 0; -} - -.fb-page .section-title .count { - color: var(--ink-3); - font-weight: 400; - margin-left: 4px; -} - -.fb-page .native-card { border-color: var(--accent); border-width: 1.5px; } - -.fb-page .you-card { background: var(--bg-mute); } -.fb-page .you-card .en { color: var(--ink-2); } - -.fb-page .actions-stack { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 24px; -} - -/* ────────────────────────────────────────────── - PAGE: Review (复习) - ────────────────────────────────────────────── */ - -.review-page .page-head { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 10px; -} - -.review-page .page-head h2 { - font-family: var(--ff-cn); - font-size: 22px; - font-weight: 600; - letter-spacing: -0.01em; - margin: 0; - color: var(--ink); -} - -.su-summary { - background: var(--ink); - color: var(--bg); - border-radius: 16px; - padding: 20px; - margin: 6px 0 18px; -} - -.su-summary .row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} - -.su-summary .eyebrow { color: oklch(0.78 0.005 60); } - -.su-summary .big { - font-family: var(--ff-serif); - font-size: 36px; - line-height: 1; - letter-spacing: -0.02em; - margin-top: 6px; -} - -.su-summary .unit { - font-size: 14px; - margin-left: 6px; - font-family: var(--ff-cn); - color: oklch(0.78 0.005 60); -} - -.su-summary .start-btn { - margin-top: 16px; - width: 100%; - height: 44px; - border-radius: 10px; - border: 1px solid oklch(0.45 0.01 60); - background: transparent; - color: var(--bg); - font-family: var(--ff-cn); - font-size: 14px; - letter-spacing: 0.04em; - cursor: pointer; -} - -.filter-tabs { - display: flex; - gap: 18px; - border-bottom: 1px solid var(--rule); - margin-bottom: 4px; -} - -.filter-tabs .tab { - padding: 10px 0 12px; - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-3); - font-weight: 400; - border-bottom: 1.5px solid transparent; - margin-bottom: -1px; - cursor: pointer; - letter-spacing: 0.04em; - background: transparent; - border-left: 0; - border-right: 0; - border-top: 0; -} - -.filter-tabs .tab.active { - color: var(--ink); - font-weight: 600; - border-bottom: 1.5px solid var(--ink); -} - -.filter-tabs .tab .n { - color: var(--ink-4); - font-size: 11px; - margin-left: 4px; -} - -.mistake-row { - display: flex; - gap: 12px; - padding: 14px 0; - border-bottom: 1px solid var(--rule); - align-items: flex-start; -} - -.mistake-row:last-child { border-bottom: 0; } - -.mistake-row .thumb { - width: 56px; - height: 56px; - border-radius: 10px; - flex: 0 0 auto; - object-fit: cover; - background: - repeating-linear-gradient( - 135deg, - oklch(0.93 0.013 70) 0 6px, - oklch(0.96 0.008 70) 6px 12px - ); -} - -.mistake-row .body { flex: 1 1 auto; min-width: 0; } - -.mistake-row .corr-inline { - font-family: var(--ff-mono); - font-size: 13px; - line-height: 1.5; - letter-spacing: -0.005em; - word-break: break-word; -} - -.mistake-row .corr-inline .strike { - color: var(--ink-3); - text-decoration: line-through; - text-decoration-color: var(--warn); -} - -.mistake-row .corr-inline .to { - color: var(--ink); - font-weight: 500; -} - -.mistake-row .corr-inline .arrow { color: var(--ink-4); margin: 0 4px; } - -.mistake-row .meta { - margin-top: 6px; - font-family: var(--ff-cn); - font-size: 11px; - color: var(--ink-3); - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - -.mistake-row .delete-btn { - background: none; - border: 0; - color: var(--ink-4); - cursor: pointer; - padding: 4px; - flex: 0 0 auto; -} - -.mistake-row .delete-btn:hover { color: var(--warn); } - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 50dvh; - text-align: center; - padding: 40px 20px; -} - -.empty-state .icon-box { - width: 72px; - height: 72px; - border-radius: 18px; - border: 1px dashed var(--ink-4); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 18px; -} - -.empty-state .title { - font-family: var(--ff-serif); - font-size: 20px; - color: var(--ink); - margin: 0; -} - -.empty-state .sub { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-3); - margin: 8px 0 24px; - letter-spacing: 0; -} - -.list-end { - font-family: var(--ff-mono); - font-size: 10px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-4); - text-align: center; - margin: 24px 0; -} - -/* ────────────────────────────────────────────── - PAGE: Review session (flashcard) - ────────────────────────────────────────────── */ - -.review-session-page .head { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-3); -} - -.review-session-page .exit { - background: transparent; - border: 1px solid var(--rule); - padding: 6px 14px; - border-radius: 8px; - font-size: 13px; - cursor: pointer; - color: var(--ink-2); -} - -.flashcard { - background: var(--bg-elev); - border: 1px solid var(--rule); - border-radius: 16px; - padding: 48px 28px; - text-align: center; - cursor: pointer; - min-height: 320px; - display: flex; - flex-direction: column; - justify-content: center; -} - -.flashcard .front-text { - font-family: var(--ff-mono); - font-size: 22px; - color: var(--ink-2); - text-decoration: line-through; - text-decoration-color: var(--warn); - margin: 0 0 16px; - line-height: 1.4; -} - -.flashcard .tap-hint { - font-family: var(--ff-cn); - font-size: 12px; - color: var(--ink-4); - letter-spacing: 0.04em; -} - -.flashcard .back-target { - font-family: var(--ff-mono); - font-size: 22px; - color: var(--ink); - font-weight: 500; - margin: 0 0 14px; - line-height: 1.4; -} - -.flashcard .back-note { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-2); - line-height: 1.6; - margin: 0 0 12px; -} - -.flashcard .back-context { - font-family: var(--ff-serif); - font-size: 14px; - color: var(--ink-3); - font-style: italic; - margin: 0 0 24px; -} - -.flashcard .verdict-row { - display: flex; - gap: 10px; - margin-top: 12px; -} - -/* ────────────────────────────────────────────── - PAGE: Profile - ────────────────────────────────────────────── */ - -.profile-page .who { - display: flex; - gap: 14px; - align-items: center; - padding: 8px 0 20px; -} - -.profile-page .avatar { - width: 56px; - height: 56px; - border-radius: 28px; - background: var(--ink); - color: var(--bg); - display: flex; - align-items: center; - justify-content: center; - font-family: var(--ff-serif); - font-size: 24px; - letter-spacing: -0.02em; -} - -.profile-page .nickname { - font-family: var(--ff-cn); - font-size: 17px; - color: var(--ink); - font-weight: 500; -} - -.profile-page .phone { - font-family: var(--ff-mono); - font-size: 12px; - color: var(--ink-3); - margin-top: 2px; - letter-spacing: 0.04em; -} - -.profile-page .info-list { - background: var(--bg-elev); - border: 1px solid var(--rule); - border-radius: 14px; - margin-bottom: 18px; -} - -.profile-page .info-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid var(--rule); - font-family: var(--ff-cn); - font-size: 14px; -} - -.profile-page .info-row:last-child { border-bottom: 0; } - -.profile-page .info-row .v { - color: var(--ink-3); - font-family: var(--ff-mono); - font-size: 12px; -} - -/* ────────────────────────────────────────────── - PAGE: History - ────────────────────────────────────────────── */ - -.history-page .page-head { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 14px; -} - -.history-page .page-head h2 { - font-family: var(--ff-cn); - font-size: 22px; - font-weight: 600; - letter-spacing: -0.01em; - margin: 0; -} - -.history-page .count-label { - font-family: var(--ff-mono); - font-size: 11px; - color: var(--ink-3); - letter-spacing: 0.06em; -} - -.history-list { } - -.history-row { - display: flex; - gap: 12px; - padding: 14px 0; - border-bottom: 1px solid var(--rule); - align-items: center; - cursor: pointer; - -webkit-tap-highlight-color: transparent; -} - -.history-row:last-child { border-bottom: 0; } - -.history-row:active { opacity: 0.7; } - -.history-thumb { - width: 64px; - height: 64px; - border-radius: 12px; - flex: 0 0 64px; - overflow: hidden; - background: - repeating-linear-gradient( - 135deg, - oklch(0.93 0.013 70) 0 6px, - oklch(0.96 0.008 70) 6px 12px - ); - display: flex; - align-items: center; - justify-content: center; -} - -.history-thumb img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.history-body { - flex: 1 1 auto; - min-width: 0; -} - -.history-headline { - font-family: var(--ff-cn); - font-size: 16px; - font-weight: 600; - color: var(--ink); - line-height: 1.4; - margin: 0 0 4px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.history-summary { - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-3); - line-height: 1.5; - margin: 0 0 7px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.history-sub { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.history-date { - font-family: var(--ff-en); - font-size: 11px; - color: var(--ink-3); - flex: 0 0 auto; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.history-tag { - font-family: var(--ff-mono); - font-size: 10px; - color: var(--ink-3); - background: var(--bg-mute); - padding: 2px 7px; - border-radius: 999px; - white-space: nowrap; -} - -.history-sub .chip { - height: 20px; - padding: 0 8px; - font-size: 10px; -} - -.history-arrow { - flex: 0 0 auto; - display: flex; - align-items: center; - transition: transform 0.18s ease; -} -.history-arrow.open { transform: rotate(90deg); } - -/* 历史按题目分组:组内展开多次练习 */ -.history-group { border-bottom: 1px solid var(--rule); } -.history-group .history-row { border-bottom: 0; } -.history-sessions { - padding: 2px 0 12px 70px; /* 对齐缩略图右侧 */ -} -.history-subrow { - display: flex; - align-items: center; - gap: 8px; - padding: 9px 4px; - cursor: pointer; - border-top: 1px solid var(--rule); -} -.history-subrow:active { opacity: 0.6; } -.history-subidx { - font-family: var(--ff-cn); - font-size: 13px; - font-weight: 600; - color: var(--ink-2); - flex: 0 0 auto; -} -.history-subtime { - font-family: var(--ff-en); - font-size: 12px; - color: var(--ink-3); - margin-right: auto; - font-variant-numeric: tabular-nums; -} - -/* ────────────────────────────────────────────── - PAGE: Session Detail - ────────────────────────────────────────────── */ - -.session-detail-page { } - -.detail-back { - display: inline-flex; - align-items: center; - gap: 4px; - background: transparent; - border: 0; - padding: 0; - margin-bottom: 16px; - font-family: var(--ff-cn); - font-size: 13px; - color: var(--ink-2); - cursor: pointer; -} - -.detail-hero { - display: flex; - gap: 14px; - align-items: center; - margin-bottom: 20px; -} - -.detail-hero-media { - width: 80px; - height: 80px; - border-radius: 14px; - flex: 0 0 80px; - position: relative; - overflow: hidden; - background: var(--bg-mute); -} - -.detail-hero-media img, -.detail-hero-media video { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.detail-hero-placeholder { - width: 80px; - height: 80px; - border-radius: 14px; - flex: 0 0 80px; - background: - repeating-linear-gradient( - 135deg, - oklch(0.93 0.013 70) 0 6px, - oklch(0.96 0.008 70) 6px 12px - ); -} - -.detail-hero-info { display: flex; flex-direction: column; gap: 4px; } - -.session-practice-preview { - display: flex; - flex-direction: column; - gap: 14px; -} - -.session-practice-media { - width: 100%; - aspect-ratio: 16 / 9; - border-radius: 14px; - position: relative; - overflow: hidden; - background: var(--bg-mute); -} - -.session-practice-media img, -.session-practice-media video { - width: 100%; - height: 100%; - display: block; -} - -.session-practice-media img { object-fit: cover; } -.session-practice-media video { object-fit: contain; background: #000; } -.session-detail-video { margin-bottom: 20px; } - -.detail-topic { - font-family: var(--ff-mono); - font-size: 16px; - font-weight: 500; - color: var(--ink); -} - -.detail-when { - font-family: var(--ff-cn); - font-size: 12px; - color: var(--ink-3); -} - -.attempt-block { margin-bottom: 8px; } - -.attempt-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.attempt-time { - font-family: var(--ff-cn); - font-size: 11px; - color: var(--ink-4); -} - -/* 自定义录音回放(结果页 + history 共用),替换原生