fix(frontend): raise dark contrast, expand hit areas, and surface input limits
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 9m54s

Dark muted surfaces missed WCAG AA; action and tertiary tokens plus a 32-pair contract close that. Hit targets, remaining-count, and Enter-to-send follow the interaction audit without changing visual sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 11:19:00 +08:00
parent b43808b016
commit e8fa1ce4ea
20 changed files with 555 additions and 40 deletions
+8
View File
@@ -1,5 +1,13 @@
# BLOCKED
## 客户端交互收尾(2026-08-30,分支 `codex/interaction-20260830`
- **`.message-actions button` 不能做到 44×44。** 四个 26×26 按钮,`gap: 1px`,中心距 27px;下方 follow-up 芯片只有 8px 底边距。44×44 伪元素会水平重叠 17px,并在有 follow-up 时垂直吃掉 1px 点击。热区做到不重叠的最大值 **27×34**(水平吃掉 1px 缝,垂直用掉 4px 底边距)。距 44×44 还差 17px 宽、10px 高。把 `gap` 拉到 18px 才能水平到 44,但那会改变视觉留白,本轮红线禁止。
- **`.chart-nav-chip``.auth-links button` 停在 40px。** 两者视觉高度 32px,容器 `gap: 8px`chip 还会换行)。`inset: -4px` 把热区扩到 40px,刚好填满间距、互不重叠。再扩 4px 就会在换行后上下吃点击。距 44px 还差 4px。
- 以上两处都写进 `frontend/tests/touch-target-contract.test.ts`,禁止在间距不变时把伪元素硬撑到 44px。
- **登录页当前关密码通道,`.auth-links` 不渲染。** 热区用夹具 + 注入节点量的。产品登录是 OTP,「发送验证码」已是 44px。
- **主对话计数 / 引导框中文 IME 实机未录。** 无受控登录会话,headless Chrome 无输入法。四处计数与三处 `isComposing` 由合同测试锁定。
- 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。
- PostgreSQL 事务反向测试:当前执行环境没有 `docker``postgres``initdb``psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。
- staging 两角色浏览器冒烟:已确认受控 admin 测试账号存在且是 `user,admin`,但当前执行环境没有其密码或已登录会话;也未提供受控 viewer 账号。不得读取/猜测凭据或使用他人账号。已完成匿名 shell、5 个资源 401、写请求 401 的服务端冒烟;admin/viewer 登录后浏览器冒烟待授权人员提供受控会话后补验。
+110
View File
@@ -0,0 +1,110 @@
# PROGRESS · 客户端交互与 UI 收尾(2026-08-30
工作树:`.worktrees/interaction-20260830`
分支:`codex/interaction-20260830`
基线:任务书写 `origin/staging` @ `ea0fbd44`;开工时远端已快进到 `95f58512`。推送前再 fetch,远端又多了 `b43808b0`(占用 BUG-444),本轮条目改为 **BUG-445448**。按红线始终跟 `origin/staging`,不是本地 ref。
不要写成 `PROGRESS.md`
未改 `.gitea/workflows/**`。未提升 main。未手写 `useCallback` / `useMemo`。未改既有测试断言(`composer-isolation-contract``import { ChatComposer }` 通过把计数组件拆到独立文件保住)。
## 任务 0 · 深色对比度
两个深色块(`prefers-color-scheme` 内的 `:root:not([data-theme="light"])``:root[data-theme="dark"]`):
| token | 旧 | 新 |
| --- | --- | --- |
| `--color-ink-tertiary` | `#928e84` | `#9c988e` |
| `--color-action` / `--color-focus` / `--report-accent` | `#d4785a` | `#d78064` |
`#d4785a``frontend/src` + `DESIGN.md` 已清零(含四个根边界页)。浅色 `#85432f` 未动。
32 组(8 字色 × 4 底色)全部 ≥ 4.5:1。最紧两对:tertiary on muted **4.60:1**action on muted **4.51:1**
反向:把 tertiary 改回 `#928e84` 后合同变红,失败信息为 `--color-ink-tertiary (#928e84) on --color-canvas-muted (#30302d) is 4.05:1`。已还原。
BUG-445。
## 任务 1 · 触达尺寸
视觉尺寸未改。浅色夹具 before/afteractions / chips / auth **0 像素差**(标题字符串不同,2689 px 只在 h1)。深色夹具除标题外,actions 313 pxtertiary 提亮)、auth 1148 pxaction 提亮),chip 字形 0 差。
| 选择器 | 视觉 | 命中(elementFromPoint | 相邻 |
| --- | --- | --- | --- |
| `.message-actions button` ×4 | 26×26gap 1px | 27×35CSS 伪元素 27×34;末颗 28×35 为整数探针取整) | gapX **0**,不重叠。中心距 27px,44×44 会水平重叠 17px8px 底边距下 44 高会吃 follow-up 1px |
| `.chart-nav-chip` | 32 高,gap 8px | 约 58×39`inset: -4px` → 40 高) | gapX **2**,不重叠。距 44 差 4px |
| `.auth-links button` | 32 高 | 86×41 / 60×41 | 登录页当前无密码通道,节点未挂;夹具 space-between gapX 142。注入探针:浅色 `rgb(133,67,47)`,深色 `rgb(215,128,100)` = `#d78064` |
| `.select-item` | min-height **44** | 列表 `max-height` 仍在,条目多 4px,时钟菜单本来就滚 | — |
| `.birth-time-skip-button` | min-height **44** | 独占一行 | — |
| `.report-center-section-heading button` | min-height **44** | 与 h2 间距 24px | — |
止损见 `BLOCKED.md`。BUG-446。
## 任务 2 · 字数上限
四处都做了,没有因高度跳动减范围。
| 位置 | 上限 | 阈值(剩余 ≤) | 计数放哪 |
| --- | ---: | ---: | --- |
| 主对话 `ChatComposer` | 500(入门称呼 80 | 50 / 8 | 既有 `.composer-footer` 右侧,`<span>` 不是 `<p>`,移动端 `> p { display: none }` 不误伤 |
| `ProfileFields` 姓名 | 80 | 8 | 标签行 `character-remaining-row` |
| `birth-time-guide-turn` | 500 | 50 | 标签行 |
| `BirthTimeUnmatchedClarification` | 240 | 24 | 标签行 |
平时不挂载。`aria-live="polite"` + `aria-atomic="true"` 只在阈值内。可见数字 `aria-hidden`,live 文案只有「接近字数上限」和「已达 N 字上限」,避免每字播报。到顶 `data-limit``--color-danger`
未登录会话,产品主输入未逐字录屏;行为由 `character-remaining-contract` 锁死。footer `min-height: 44px` 已在,计数 `white-space: nowrap`,出现时不新造一行。
BUG-447。
## 任务 3 · 发送键
选择:**统一 Enter 发送、Shift+Enter 换行**,不是保留 ⌘+Enter 只补提示。
理由:主对话和生时校正对话已经是这套;用户把 Enter 习惯带到引导框只会换行。引导框仍有「整理为经历草稿」,不会卡死。漏 `isComposing` 会让中文选词误发送,比现状严重。`rows={3}` 的换行改走 Shift+Enter,并在既有 `#birth-time-guide-hint` 写明。
| 输入框 | 发送 | 换行 | `isComposing` |
| --- | --- | --- | --- |
| 主对话 `handleComposerKeyDown` | Enter | Shift+Enter | 先 return |
| 生时校正 `rectification-agentic-chat` | Enter | Shift+Enter | 同一条件 |
| 生时引导 `birth-time-guide-turn` | Enter(本轮从 ⌘/Ctrl+Enter 改来) | Shift+Enter | 先 return |
headless 无输入法,未做产品里的拼音→Enter 选词实机。合同锁三处源码都带 `event.nativeEvent.isComposing`
BUG-448。
## 命令输出
`./node_modules/.bin/tsc --noEmit`**0**(约 235s,无诊断)
`./node_modules/.bin/eslint` 触及文件 → **0 error**。既有 warning`page.tsx` 两处 `react-hooks/exhaustive-deps`restoreConsultationRecovery / profile),`globals.css` 无 eslint 配置被忽略。不是本轮引入。
`./node_modules/.bin/tsx --test tests/*.test.ts`**2307 / 2298 pass / 9 fail / skipped 0 / todo 0**
9 条失败全是本机无 Docker 的数据库类(与任务书 23+10 的环境缺口同类,本环境实际是 9 条 fail、0 skip)。标题:
1. admin code functions reject immutable codes, revoked redemption, and roll back on audit failure
2. Owner recovery grants only one currently loginable synced identity admin
3. local PostgreSQL applies the reviewed business schema and serves authenticated business calls
4. redeem security: case-sensitive hashing, rate limiting, idempotency and order ownership
5. Better Auth supports shared user OTP/password sessions for admins
6. database drops secret refs, generates immutable codes, and invalidates evidence after key rotation
7. ingest P0: education kinds, batch confirm, opening focus reuse, precision lock
8. PR-4 candidate decisions use server UUIDs, receipt-derived gates and separate acceptance/confirmation
9. v9 migration applies on a fresh database and re-applies idempotently
本轮新增合同(dark 32 组、touch-target、character-remaining、composer-ime)在聚焦套件 121/121 中全绿。无产品逻辑新增失败。
`./node_modules/.bin/next build`**0**。第一次因 worktree 里 `node_modules` 外链被 Turbopack 拒;`npm ci` 后:
```
✓ Compiled successfully in 3.3s
Finished TypeScript in 6.1s
✓ Generating static pages using 7 workers (60/60) in 330ms
```
5 条既有 `skill-package-registry.ts` filesystem-tracing warning,未失败。
## 证据路径(未提交)
`.omo/evidence/interaction-20260830/`before/after 浅色深色 PNG、`hit-rects.json`、login-light、not-found-light。
+63
View File
@@ -6815,3 +6815,66 @@
- 复发自:BUG-414
- 修复版本:待发布
## BUG-445 | 深色主题次要字色与行动色在卡片底上对比度不足 4.5:1
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:深色主题 token、围栏代码块语言标签、根边界页行动色
- 用户现象:深色模式下卡片底上的次要说明文字和黏土橙行动色发灰发糊,代码块语言标签是其中一处已上线的实例。
- 触发条件:系统偏好或账户菜单选择深色;任何把 `ink-tertiary``action` 画在 `canvas-muted` 上的界面。
- 根因:对比度合同只验了字色对阅读面 `--color-canvas`,漏掉了卡片底 `--color-canvas-muted`。实测 `--color-ink-tertiary` `#928e84``#30302d` 为 4.05:1`--color-action` `#d4785a` 为 4.18:1,均低于 WCAG AA 4.5:1。
- 修复:两个深色块把 `--color-ink-tertiary` 改为 `#9c988e``--color-action` / `--color-focus` / `--report-accent` 改为 `#d78064`(色相 15°、饱和度不变,只提亮明度)。四个根边界页内联 token 与 `DESIGN.md` 对照表同步,清掉 `#d4785a`
- 验证:`frontend/tests/dark-theme-contract.test.ts` 把对比度断言扩成 8 种字色 × 4 种底色,失败信息打印具体配对和比值;反向把 tertiary 改回 `#928e84` 后该测试变红。
- 防复发:新增字色或底色 token 时必须进入这张 32 组矩阵,不得只验阅读面;不得靠放宽 4.5 阈值让测试通过。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-446 | 回答操作等控件热区小于文档承诺的 44px
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:回答下方赞/踩/复制/重新生成、侧栏星盘 chip、登录页链接按钮、模型选择项、生时跳过、报告中心章节按钮
- 用户现象:回答下方四个图标按钮只有 26×26px 且没有 padding,很难点准;若干列表项和链接也低于 44px。
- 触发条件:任意一条助手回答下方的操作行;侧栏星盘 chip;登录页链接;onboarding 下拉;报告中心章节操作。
- 根因:`DESIGN.md` 写了 44px,样式却把高频图标做成 26px/`padding: 0`chip 32px,若干文字按钮 40px。
- 修复:视觉尺寸不动。图标行与 chip、登录链接用透明伪元素扩大热区。选择项、跳过、报告章节按钮把 `min-height` 提到 44px。回答操作四个按钮中心距只有 27px、下方 follow-up 只有 8px 边距,44×44 会互相吃点击,热区停在不重叠的 27×34;chip 与登录链接因 8px 间距停在 40px。见 `BLOCKED.md`
- 验证:`frontend/tests/touch-target-contract.test.ts` 锁 26px 视觉、伪元素尺寸、以及三处 `min-height >= 44`。改前改后截图对比视觉尺寸。
- 防复发:新增可点元素必须满足 44px 命中区;视觉小于 44px 时用伪元素补,且扩大后的热区不得与相邻可点元素重叠。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-447 | 字数上限只有 maxLength,到顶后界面毫无反应
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:主对话输入框、姓名输入、生时引导经历框、选择题补充说明
- 用户现象:打到字数上限后键盘像失灵,没有提示说明已经到顶。
- 触发条件:主输入写满 500 字(入门称呼为 80)、姓名 80、引导经历 500、补充说明 240。
- 根因:四处只有 `maxLength`,没有接近上限时的可见反馈,也没有 `aria-describedby`
- 修复:剩余字数进入阈值(500→50,短输入按 10% 且不低于 8)才挂载计数;平时不显示。计数用 `ink-tertiary`,到顶转 `danger``aria-live="polite"``aria-atomic="true"`,可见数字不进 live 文本,避免每字播报。主输入计数放进已有 `.composer-footer`,不新造一层把输入框顶高。
- 验证:`frontend/tests/character-remaining-contract.test.ts` 锁阈值、两句 live 文案、四处 `aria-describedby` 接线。
- 防复发:带 `maxLength` 的产品输入必须在阈值内给出计数,并只在阈值内挂载 live region。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-448 | 生时引导框用 ⌘/Ctrl+Enter 发送且没有任何说明
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:`birth-time-guide-turn` 经历输入框
- 用户现象:主对话和生时校正对话都是 Enter 发送,到引导框按 Enter 只会换行,界面没有任何键位说明。
- 触发条件:生时引导流程里在「说说这段经历」多行框按 Enter。
- 根因:该框绑定了修饰键发送,且未处理 `isComposing`;另外两处已经是 Enter 发送并忽略组字中的 Enter。
- 修复:统一为 Enter 发送、Shift+Enter 换行,并加上 `!event.nativeEvent.isComposing`。保留「整理为经历草稿」按钮。在既有 `#birth-time-guide-hint` 补一句键位说明。选择统一发送键而不是只补提示,是因为用户已在另外两处养成 Enter 习惯,漏掉 `isComposing` 会让中文选词误发送。
- 验证:`frontend/tests/composer-ime-contract.test.ts` 锁三处 `onKeyDown` 都判断 `isComposing`,引导框不再看 `metaKey`/`ctrlKey`
- 防复发:产品内多行发送框必须同一套 Enter / Shift+Enter / `isComposing` 合同;新的输入框不得只改发送键不处理组字。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
+8 -5
View File
@@ -94,16 +94,19 @@ identical.
| Page floor | `#f3f2ee` | `#1a1a19` | On light, raised surfaces step *down* in lightness; on dark they step *up*. The floor is the darkest surface in dark mode, the second-lightest in light mode. |
| Reading surface | `#fbfaf7` | `#262624` | Warm, never neutral black. A cool grey would change the product's identity rather than its brightness. |
| Warm card | `#ebe9e3` | `#30302d` | One step above the reading surface in both themes, by opposite directions. |
| Primary action | `#85432f` | `#d4785a` | The clay hue is kept and lifted. `#85432f` is 2.1:1 on `#262624` — unreadable. |
| Primary action | `#85432f` | `#d78064` | The clay hue is kept and lifted. `#85432f` is 2.1:1 on `#262624` — unreadable. |
| Action hover | `#6f3627` | `#e59273` | Hover darkens on light and lightens on dark. |
| Dark punctuation | `#1d1d1f` | `#f2f0ea` | A dark block on a light page becomes a light block on a dark page; the token name describes the light-theme value, the role is "highest-contrast block". |
| Semantic hues | `#9a2f2f` / `#28633e` / `#b07b22` | `#e08573` / `#74b189` / `#d8a758` | Lifted for contrast; their `-muted` pairs stop being pale washes and become dark tints of the same hue. |
| Scrim | `rgba(29,29,31,.34)` | `rgba(0,0,0,.58)` | A scrim has to darken a surface that is already dark. |
| Shadow | low alpha | ~5× alpha | Without a light ground to fall on, a soft shadow registers as nothing. |
Contrast is asserted, not assumed: ink, action, danger, success and warning must
all clear 4.5:1 against the dark reading surface, and the four neutral surfaces
must increase in lightness in floor → canvasmuted → strong order.
Contrast is asserted, not assumed: eight inks (ink, ink-strong, ink-secondary,
ink-tertiary, action, danger, success, warning) must all clear 4.5:1 against
four dark surfaces (canvas-soft, canvas, canvas-muted, sidebar-solid). The four
neutral surfaces must increase in lightness in floor → canvas → muted → strong
order. Adding a text or surface token means adding it to that 32-pair matrix;
checking only the reading surface is not enough.
**Surfaces that do not follow the theme, on purpose:**
@@ -162,7 +165,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- Desktop shell: 288px sidebar plus flexible reading panel.
- Layout breakpoints: mobile below 768px, tablet 7681023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail.
- Component breakpoints are separate and narrower, each owned by one surface rather than the shell: 900px and 480px (redemption and profile forms), 760px and 720px (personal report and report centre), 640px (rectification candidates), 639px (membership header), 620px (onboarding cards), 430px (toasts and the conversational rectification panel). Do not repurpose one for another surface, and do not assume testing the three layout tiers covers them.
- All full-height surfaces use `100dvh`. Touch targets are at least 44px.
- All full-height surfaces use `100dvh`. Touch targets are at least 44px. Where adjacent controls sit closer than 44px, the hit expands to the largest size that does not overlap; those exceptions are locked by `frontend/tests/touch-target-contract.test.ts`.
## 5. Components
+2 -2
View File
@@ -76,7 +76,7 @@ export default function RootError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -86,7 +86,7 @@ export default function RootError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.root-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+2 -2
View File
@@ -38,7 +38,7 @@ export default function Forbidden() {
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
--bp-action: #d78064;
}
}
:root[data-theme="dark"] {
@@ -46,7 +46,7 @@ export default function Forbidden() {
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
--bp-action: #d78064;
}
`}</style>
<section style={{ maxWidth: "32rem", textAlign: "center" }}>
+2 -2
View File
@@ -60,7 +60,7 @@ export default function GlobalError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -70,7 +70,7 @@ export default function GlobalError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+55 -12
View File
@@ -158,16 +158,16 @@
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action: #d78064;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-ink-tertiary: #9c988e;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
@@ -206,7 +206,7 @@
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
--report-accent: #d78064;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
@@ -229,16 +229,16 @@
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action: #d78064;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-ink-tertiary: #9c988e;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
@@ -277,7 +277,7 @@
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
--report-accent: #d78064;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
@@ -730,6 +730,7 @@ button:disabled { cursor: default; opacity: .45; }
.sidebar-empty { margin: 0; padding: 0 0 var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
.chart-nav-list { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: 0 0 var(--space-2); }
.chart-nav-chip {
position: relative;
min-height: 32px;
max-width: 100%;
padding: 0 var(--space-3);
@@ -742,6 +743,12 @@ button:disabled { cursor: default; opacity: .45; }
font-weight: 400;
line-height: 1.35;
}
/* Visual chip stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */
.chart-nav-chip::after {
position: absolute;
inset: -4px;
content: "";
}
.chart-nav-chip:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.sidebar-label:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: 2px; border-radius: var(--radius-sm); }
.session-nav-toggle { min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; font-size: var(--type-caption); transition: background-color 120ms ease-out, color 120ms ease-out; }
@@ -1251,6 +1258,7 @@ button:disabled { cursor: default; opacity: .45; }
.conversation-follow-up:hover { background: var(--color-canvas-muted); }
.conversation-follow-up:disabled { cursor: not-allowed; opacity: .56; }
.message-actions button {
position: relative;
display: inline-flex;
width: 26px;
height: 26px;
@@ -1266,6 +1274,18 @@ button:disabled { cursor: default; opacity: .45; }
opacity: .7;
transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out;
}
/* Visual icon stays 26px. Neighbours sit 27px centre-to-centre (26px + 1px gap)
and follow-ups start after an 8px bottom margin, so a 44×44 overlay would
steal clicks. This is the largest non-overlapping hit: 27×34. */
.message-actions button::after {
position: absolute;
top: 50%;
left: 50%;
width: 27px;
height: 34px;
content: "";
transform: translate(-50%, -50%);
}
.message-actions button:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent);
color: var(--color-ink-secondary);
@@ -1502,6 +1522,23 @@ button:disabled { cursor: default; opacity: .45; }
.composer .composer-stop { background: var(--color-action); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-action-hover); }
.composer-footer { width: min(760px, 100%); min-height: 44px; display: flex; align-items: center; margin: var(--space-1) auto 0; }
.character-remaining {
margin-left: auto;
flex-shrink: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.4;
white-space: nowrap;
}
.character-remaining[data-limit] { color: var(--color-danger); }
.character-remaining-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-2);
}
.model-selector-trigger { width: fit-content; min-width: 0; max-width: min(100%, 360px); min-height: 44px; display: inline-flex; grid-column: 1; align-items: center; justify-self: start; gap: var(--space-2); border: 0; padding: 0 var(--space-2); border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; }
.model-selector-trigger > b { min-width: 0; overflow: visible; overflow-wrap: anywhere; text-align: left; white-space: normal; font-size: var(--type-caption); font-weight: 500; line-height: 1.35; }
.model-selector-trigger svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); transition: transform 120ms ease-out; }
@@ -1654,7 +1691,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.auth-mode-tabs button, .auth-links button { min-width: 0; min-height: 40px; border: 0; background: transparent; cursor: pointer; color: var(--color-ink-secondary); font: inherit; font-size: 13px; }
.auth-mode-tabs button[aria-pressed="true"] { border-radius: calc(var(--radius-md) - 2px); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-soft); }
.auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); }
.auth-links button { position: relative; min-height: 32px; padding: 0; color: var(--color-action); }
/* Visual link stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */
.auth-links button::after {
position: absolute;
inset: -4px;
content: "";
}
.admin-page { background: var(--color-canvas-soft); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
@@ -2447,7 +2490,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 44px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-grid > label { gap: 7px; }
.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 700; }
@@ -2504,7 +2547,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.select-content { width: var(--anchor-width); min-width: 190px; max-width: min(360px, calc(100vw - 32px)); height: auto !important; max-height: min(360px, calc(100vh - 32px)); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); padding: 5px; box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); animation: select-content-in 140ms var(--ease-out) both; }
.select-list { display: grid; gap: 2px; max-height: min(320px, 50vh) !important; overflow-y: auto !important; outline: none; scrollbar-width: none; -ms-overflow-style: none; }
.select-list::-webkit-scrollbar { width: 0; height: 0; display: none; }
.select-item { min-height: 40px; position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 9px 10px; border-radius: var(--radius-sm); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.35; cursor: pointer; outline: none; user-select: none; }
.select-item { min-height: 44px; position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 9px 10px; border-radius: var(--radius-sm); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.35; cursor: pointer; outline: none; user-select: none; }
.select-item:hover, .select-item[data-highlighted] { background: var(--color-action-soft); color: var(--color-action-hover); }
.select-item[data-selected] { color: var(--color-action-hover); font-weight: 650; }
.select-item[data-disabled] { cursor: not-allowed; opacity: .45; }
@@ -3052,7 +3095,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.report-center-section { max-width: 1080px; margin: 0 auto; }
.report-center-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 14px; }
.report-center-section-heading h2 { margin: 0; font-family: var(--font-body); font-size: 20px; font-weight: 650; letter-spacing: -.015em; }
.report-center-section-heading button { min-height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-center-section-heading button { min-height: 44px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-center-section-heading button:hover { color: var(--color-action); }
.report-center-list { border-top: 1px solid var(--color-border-strong); }
.report-center-card { min-height: 126px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; padding: 22px 0; border-bottom: 1px solid var(--color-border); }
+2 -2
View File
@@ -76,7 +76,7 @@ export default function RootNotFound() {
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -86,7 +86,7 @@ export default function RootNotFound() {
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.root-not-found-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+19 -2
View File
@@ -37,6 +37,9 @@ import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { BirthPlacePicker } from "@/components/birth-place-picker";
import { ChatComposer } from "@/components/chat-composer";
import { ComposerCharacterRemaining } from "@/components/composer-character-remaining";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
@@ -902,11 +905,24 @@ function invalidateCandidateAfterLocationChange(current: Profile, next: Profile)
}
function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`;
return (
<>
<label>
<span></span>
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
<span className="character-remaining-row">
<CharacterRemaining id={nameRemainingId} length={value.name.length} maxLength={80} />
</span>
<input
id={nameInputId}
required
autoComplete="name"
maxLength={80}
placeholder="例如:林遥"
value={value.name}
aria-describedby={characterRemainingVisible(value.name.length, 80) ? nameRemainingId : undefined}
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
<BirthLocationFields
@@ -4301,6 +4317,7 @@ export default function Home() {
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<ComposerCharacterRemaining maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} />
</div>
</div>}
</SidebarInset>
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
choiceQuestionGroups,
choiceSelectionIntent,
@@ -94,6 +96,8 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
);
}
const unmatchedRemainingId = "birth-time-unmatched-remaining";
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
const [note, setNote] = useState("");
const reframeRef = useRef<HTMLButtonElement | null>(null);
@@ -108,9 +112,13 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend></legend>
<label className="birth-time-unmatched-note">
<span></span>
<span className="character-remaining-row">
<CharacterRemaining id={unmatchedRemainingId} length={note.length} maxLength={240} />
</span>
<textarea
maxLength={240}
aria-describedby={characterRemainingVisible(note.length, 240) ? unmatchedRemainingId : undefined}
onChange={(event) => setNote(event.target.value)}
placeholder="例如:这段变化发生得更早,或情况不太一样"
value={note}
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import type { JourneyProgress } from "@/lib/birth-time-journey-turn";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
@@ -13,11 +15,17 @@ type BirthTimeGuideTurnProps = {
readonly onPause: () => void;
};
const guideRemainingId = "birth-time-guide-remaining";
export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
const [message, setMessage] = useState("");
const phase = props.progress.adaptiveRound > 0
? `自适应第 ${props.progress.adaptiveRound} / ${props.progress.maxAdaptiveRounds}`
: "基础证据";
const showRemaining = characterRemainingVisible(message.length, 500);
const describedBy = showRemaining
? `birth-time-guide-hint ${guideRemainingId}`
: "birth-time-guide-hint";
return (
<div className="birth-time-guide-turn" aria-live="polite">
@@ -27,23 +35,27 @@ export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
</div>
<p className="birth-time-guide-question">{protectOnboardingPhrases(props.question)}</p>
<label className="birth-time-guide-composer">
<span></span>
<span className="character-remaining-row">
<CharacterRemaining id={guideRemainingId} length={message.length} maxLength={500} />
</span>
<textarea
aria-describedby="birth-time-guide-hint"
aria-describedby={describedBy}
disabled={props.pending}
maxLength={500}
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && message.trim()) {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && !event.shiftKey && message.trim()) {
event.preventDefault();
props.onSubmit(message.trim());
}
}}
/>
</label>
<p id="birth-time-guide-hint" className="birth-time-guide-hint"></p>
<p id="birth-time-guide-hint" className="birth-time-guide-hint">Enter Shift+Enter </p>
<div className="birth-time-guided-actions">
<button className="button-text birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onPause}></button>
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}></button>
@@ -0,0 +1,31 @@
import {
characterRemainingAnnouncement,
characterRemainingLabel,
characterRemainingVisible,
} from "@/lib/character-remaining";
export function CharacterRemaining({
id,
length,
maxLength,
}: {
id: string;
length: number;
maxLength: number;
}) {
if (!characterRemainingVisible(length, maxLength)) return null;
const remaining = maxLength - length;
const atLimit = remaining <= 0;
return (
<span
id={id}
className="character-remaining"
data-limit={atLimit ? "true" : undefined}
aria-live="polite"
aria-atomic="true"
>
<span aria-hidden="true">{characterRemainingLabel(remaining, maxLength)}</span>
<span className="sr-only">{characterRemainingAnnouncement(remaining, maxLength)}</span>
</span>
);
}
@@ -5,12 +5,15 @@ import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react";
import { ArrowUp, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
composerDraftSnapshot,
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
export const composerRemainingId = "composer-character-remaining";
export function useComposerDraft(): string {
return useSyncExternalStore(
subscribeComposerDraft,
@@ -53,12 +56,14 @@ export function ChatComposer({
onStop,
}: ChatComposerProps) {
const draft = useComposerDraft();
const showRemaining = characterRemainingVisible(draft.length, maxLength);
return (
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={showRemaining ? composerRemainingId : undefined}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
@@ -0,0 +1,9 @@
"use client";
import { CharacterRemaining } from "@/components/character-remaining";
import { composerRemainingId, useComposerDraft } from "@/components/chat-composer";
export function ComposerCharacterRemaining({ maxLength }: { maxLength: number }) {
const draft = useComposerDraft();
return <CharacterRemaining id={composerRemainingId} length={draft.length} maxLength={maxLength} />;
}
+17
View File
@@ -0,0 +1,17 @@
/** Show the remaining-count hint when this many characters (or fewer) are left. */
export function remainingThreshold(maxLength: number): number {
return Math.max(8, Math.min(50, Math.round(maxLength / 10)));
}
export function characterRemainingVisible(length: number, maxLength: number): boolean {
return maxLength - length <= remainingThreshold(maxLength);
}
export function characterRemainingLabel(remaining: number, maxLength: number): string {
return remaining <= 0 ? `已达 ${maxLength} 字上限` : `还剩 ${remaining}`;
}
/** Stable live text: one announcement on entering the threshold, one at the cap. */
export function characterRemainingAnnouncement(remaining: number, maxLength: number): string {
return remaining <= 0 ? `已达 ${maxLength} 字上限` : "接近字数上限";
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
characterRemainingAnnouncement,
characterRemainingLabel,
characterRemainingVisible,
remainingThreshold,
} from "../src/lib/character-remaining.ts";
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("thresholds stay quiet until the last 10 percent, floored at 8 and capped at 50", () => {
assert.equal(remainingThreshold(500), 50);
assert.equal(remainingThreshold(240), 24);
assert.equal(remainingThreshold(80), 8);
assert.equal(characterRemainingVisible(449, 500), false);
assert.equal(characterRemainingVisible(450, 500), true);
assert.equal(characterRemainingVisible(71, 80), false);
assert.equal(characterRemainingVisible(72, 80), true);
});
test("the visible label updates while the live announcement stays at two phrases", () => {
assert.equal(characterRemainingLabel(12, 500), "还剩 12 字");
assert.equal(characterRemainingLabel(0, 500), "已达 500 字上限");
assert.equal(characterRemainingAnnouncement(12, 500), "接近字数上限");
assert.equal(characterRemainingAnnouncement(0, 80), "已达 80 字上限");
});
test("the count region is polite, atomic, and only mounted inside the threshold", () => {
assert.match(remainingSource, /if \(!characterRemainingVisible\(length, maxLength\)\) return null;/);
assert.match(remainingSource, /aria-live="polite"/);
assert.match(remainingSource, /aria-atomic="true"/);
assert.match(remainingSource, /aria-hidden="true"/);
assert.match(remainingSource, /className="sr-only"/);
assert.match(css, /\.character-remaining\[data-limit\] \{ color: var\(--color-danger\); \}/);
});
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
assert.match(composer, /aria-describedby=\{showRemaining \? composerRemainingId : undefined\}/);
assert.match(page, /<ComposerCharacterRemaining maxLength=\{!profileComplete && onboardingStep === "name" \? 80 : 500\} \/>/);
assert.match(page, /className="composer-footer"/);
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
assert.match(guide, /aria-describedby=\{describedBy\}/);
assert.match(choice, /aria-describedby=\{characterRemainingVisible\(note\.length, 240\) \? unmatchedRemainingId : undefined\}/);
assert.match(css, /\.composer-footer > p \{ display: none; \}/);
assert.doesNotMatch(page, /<p className="character-remaining"/);
});
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
function onKeyDownBlock(source: string, marker: string) {
const start = source.indexOf(marker);
assert.notEqual(start, -1, `missing onKeyDown near ${marker}`);
const slice = source.slice(start, start + 600);
const handler = slice.match(/onKeyDown=\{\(event\) => \{[\s\S]*?\n\s*\}\}/)
?? slice.match(/function handleComposerKeyDown[\s\S]*?\n \}/);
assert.ok(handler, `could not isolate onKeyDown from ${marker}`);
return handler[0];
}
test("all three composers ignore Enter while an IME is composing", () => {
const main = page.slice(
page.indexOf("function handleComposerKeyDown"),
page.indexOf("\n\n if (!hydrated"),
);
const rectificationHandler = onKeyDownBlock(rectification, "onKeyDown={(event) => {");
const guideHandler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
for (const [name, handler] of [
["main composer", main],
["rectification composer", rectificationHandler],
["birth-time guide", guideHandler],
] as const) {
assert.match(handler, /event\.nativeEvent\.isComposing/, `${name} must ignore composing Enter`);
}
});
test("the guide turn sends with Enter and wraps with Shift+Enter, matching the other two", () => {
const handler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/);
assert.doesNotMatch(handler, /metaKey \|\| event\.ctrlKey/);
assert.match(guide, /Enter 发送,Shift\+Enter 换行/);
});
+55 -8
View File
@@ -64,15 +64,43 @@ test("dark ink reads lighter than dark canvas, and the action stays legible", ()
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const canvas = pinned.get("color-canvas")!;
for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) {
const value = pinned.get(token)!;
assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`);
assert.ok(
contrast(value, canvas) >= 4.5,
`--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`,
);
// Checking only the reading surface missed ink-tertiary and action on the
// raised card (--color-canvas-muted). New text or surface tokens must join
// this matrix; do not drop a pair or lower 4.5 to make it pass.
const inks = [
"color-ink",
"color-ink-strong",
"color-ink-secondary",
"color-ink-tertiary",
"color-action",
"color-danger",
"color-success",
"color-warning",
];
const surfaces = [
"color-canvas",
"color-canvas-soft",
"color-canvas-muted",
"color-sidebar-solid",
];
const failures: string[] = [];
for (const ink of inks) {
const inkValue = pinned.get(ink)!;
assert.match(inkValue, /^#[0-9a-f]{6}$/i, `--${ink} must be a hex so contrast is checkable`);
for (const surface of surfaces) {
const surfaceValue = pinned.get(surface)!;
assert.match(
surfaceValue,
/^#[0-9a-f]{6}$/i,
`--${surface} must be a hex so contrast is checkable`,
);
const ratio = contrast(inkValue, surfaceValue);
if (ratio < 4.5) {
failures.push(`--${ink} (${inkValue}) on --${surface} (${surfaceValue}) is ${ratio.toFixed(2)}:1`);
}
}
}
assert.equal(failures.length, 0, failures.join("; "));
// Elevation reads through lightness on dark: floor is darkest, raised steps up.
const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"]
.map((token) => luminance(pinned.get(token)!));
@@ -80,3 +108,22 @@ test("dark ink reads lighter than dark canvas, and the action stays legible", ()
assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise");
}
});
test("the previous dark action and tertiary hexes are gone from product sources", () => {
const sources = [
globalStyles,
readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/forbidden.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/global-error.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8"),
];
for (const source of sources) {
assert.doesNotMatch(source, /#d4785a/i);
}
assert.equal(pinned.get("color-action"), "#d78064");
assert.equal(pinned.get("color-focus"), "#d78064");
assert.equal(pinned.get("color-ink-tertiary"), "#9c988e");
assert.equal(preferred.get("color-action"), "#d78064");
assert.equal(preferred.get("color-ink-tertiary"), "#9c988e");
});
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
function minHeightPx(selector: string) {
const matches = [...cssDeclarations(selector, globalStyles).matchAll(/min-height:\s*(\d+)px/g)]
.map((hit) => Number(hit[1]));
assert.ok(matches.length > 0, `${selector} must declare min-height`);
return Math.max(...matches);
}
function hasHitExpandPseudo(selector: string) {
const after = cssDeclarations(`${selector}::after`, globalStyles);
assert.match(after, /position:\s*absolute/);
assert.match(after, /content:\s*""/);
return after;
}
test("40px controls grow to a 44px box when that does not pack a scrolling list badly", () => {
assert.ok(minHeightPx(".select-item") >= 44);
assert.ok(minHeightPx(".birth-time-window-details .birth-time-skip-button") >= 44);
assert.ok(minHeightPx(".report-center-section-heading button") >= 44);
});
test("icon and chip controls keep their visual size and expand the hit with a pseudo-element", () => {
const actions = cssDeclarations(".message-actions button", globalStyles);
assert.match(actions, /width:\s*26px/);
assert.match(actions, /height:\s*26px/);
assert.match(cssDeclarations(".message-actions", globalStyles), /gap:\s*1px/);
const actionHit = hasHitExpandPseudo(".message-actions button");
assert.match(actionHit, /width:\s*27px/);
assert.match(actionHit, /height:\s*34px/);
assert.equal(minHeightPx(".chart-nav-chip"), 32);
assert.match(hasHitExpandPseudo(".chart-nav-chip"), /inset:\s*-4px/);
const auth = cssDeclarations(".auth-links button", globalStyles);
assert.match(auth, /min-height:\s*32px/);
assert.match(hasHitExpandPseudo(".auth-links button"), /inset:\s*-4px/);
});