fix(frontend): restore root 404 styles and unify inline wait spinners
Root error/not-found pages lost all CSS after globals left the shared layout. Inline the same token fallbacks as global-error so admin stays off the chat stylesheet. Collapse three LoaderCircle spins into one InlineSpinner, and build greeting fixtures in local time so UTC CI does not hit the night-time copy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
- **`web/index.html`、`web/rectification.html`、`web/evidence_packet.html` 不得按“无人引用”删除。** `scripts/jyotish_api_server.py` 仍把它们当作 `/`、`/evidence`、`/rectification` 的调试页;`tests/test_api_async_job_contract.py` 锁路径。任务书全仓检索漏了 Python API。移到 `scripts/` 必须改 `tests/**` 既有断言,本轮禁止。它们不是产品 UI,继续留在 `web/`。
|
||||
- **admin 已登录目视(`/admin/users|orders|roles|feature-flags`)未做。** 执行环境没有受控后台会话,与上方 staging 两角色冒烟同一缺口。本轮用 `next build` 客户端 CSS 清单代替:上述四页不再包含 globals(`message-list`)那 33.7 KB gzip,只剩 admin.css 1.8 KB + Inter `@font-face` 0.8 KB = 2.5 KB。登录后的视觉塌陷仍需人补一眼。
|
||||
- **`error.tsx` / `not-found.tsx` 不能 import `site-styles`。** 它们挂在根布局段,import globals 会把聊天 CSS 重新打进每一条 admin 路由。根错误/404 因此不再带 Tailwind 工具类的编译产物;有 `Button` className,但 admin 不依赖这两页的观感。真要恢复这两页的工具类样式,必须另开一条不与 admin 共享的布局链(会移动 `app/page.tsx`,与既有源码锁冲突)。
|
||||
- **`error.tsx` / `not-found.tsx` 不能 import `site-styles`。** 它们挂在根布局段,import globals 会把聊天 CSS 重新打进每一条 admin 路由。样式用内联 `style` + fallback 色值(与 `global-error.tsx` 同一模式),不必另开布局链,也不必移动 `app/page.tsx`。见 2026-08-29 收尾与 BUG-433。
|
||||
- React Compiler 仍关闭,见下方 2026-08-17 记录。本轮未重开。
|
||||
|
||||
## React Compiler 接管 page.tsx(2026-08-17,分支 codex/react-compiler-20260817)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# PROGRESS · 前端优化九条收尾(2026-08-29)
|
||||
|
||||
工作树:`.worktrees/frontend-followup-20260829`
|
||||
分支:`codex/frontend-followup-20260829`
|
||||
基线开工:`origin/staging` @ `31b54aa7`(任务书写的 `3198fb6b` 之后已有 BUG-431;开工后远端又快进了 `86d6923e` 生时校正死锁修复)。交付前 rebase 到最新 `origin/staging`。
|
||||
|
||||
不要写成 `PROGRESS.md`。
|
||||
|
||||
## 任务 A · 根 404 / 错误页内联样式
|
||||
|
||||
`error.tsx` / `not-found.tsx` 改为与 `global-error.tsx` 相同的内联 `style` + fallback 色值(已核 `globals.css` `:root`:`--color-canvas: #fbfaf7`、`--color-ink: #1d1d1f`、`--color-ink-secondary: #5f5f59`、`--color-border: #d8d6cf`、`--color-action: #85432f`、`--color-danger: #9a2f2f`)。未 import `site-styles` / `globals.css`。
|
||||
|
||||
404 为保住 `root-error-boundaries-contract` 的 `render={<Link href="/" />}` 锁,用 `cloneElement` 套内联样式,不引入 `Button`。`error.tsx` 仍 `"use client"`,`console.error` 未动;重试用原生 `<button>`。
|
||||
|
||||
BUG-433(远端 `origin/staging` 的 BUG-432 已被 `86d6923e` 生时校正死锁占用)。`BLOCKED.md` 已订正“必须另开布局链”那条。
|
||||
|
||||
### 构建产物
|
||||
|
||||
`./node_modules/.bin/next build` exit 0。路由:`○ /`、`○ /_not-found`、`ƒ /login`、`ƒ /admin/**`。
|
||||
|
||||
`_not-found.html` body:无 `min-h-svh` / `text-muted-foreground` / Tailwind 工具类;内联 `background:var(--color-canvas, #fbfaf7)`;stylesheet 仅 `3b67syn08d_z3.css`(Inter `@font-face`,2146 B)。
|
||||
|
||||
admin CSS chunk:`0o-8xwneekfl4.css`(5547 B,message-list=0)+ `3b67syn08d_z3.css`(2146 B)。含 `.message-list` 的 215584 B chunk `025-k_ikkkzbx.css` 只出现在站点 `index.html`。
|
||||
|
||||
`index.html` stylesheets:
|
||||
|
||||
```
|
||||
rel="stylesheet" href="/_next/static/chunks/3b67syn08d_z3.css"
|
||||
rel="stylesheet" href="/_next/static/chunks/025-k_ikkkzbx.css"
|
||||
rel="stylesheet" href="/_next/static/chunks/23pfyi_fov2fp.css"
|
||||
```
|
||||
|
||||
### 浏览器(`next start -p 3011`,路径 `/this-path-does-not-exist`)
|
||||
|
||||
居中暖白页,不是裸 HTML。计算样式:`background rgb(251, 250, 247)`、`color rgb(29, 29, 31)`、`display:flex` + 居中、链接 `min-height:44px` / `min-width:88px`。页面 stylesheet 只有 Inter chunk + 内联 `<style>`(无 216 KB globals)。文案:404 / 页面不存在 / 返回对话。
|
||||
|
||||
## 任务 B · 时区夹具
|
||||
|
||||
`onboarding-presentation.test.ts` 的 noon/8 点改为 `new Date(2026, 6, 19, hour)`。期望文案未改。`+08:00` 且断言时段文案的同类已清零。
|
||||
|
||||
```
|
||||
TZ=UTC pass 3 fail 0
|
||||
TZ=Asia/Shanghai pass 3 fail 0
|
||||
TZ=America/New_York pass 3 fail 0
|
||||
```
|
||||
|
||||
## 任务 C · readonly 夹具
|
||||
|
||||
`rectification-answer-choice.test.ts` 不再 `Object.assign`:`candidateSnapshotFixture({ decisionReceipt })` 一次构造;dossier 用展开重建。未改断言、未改 `src/**` 类型、无 `as any` / `@ts-ignore`。
|
||||
|
||||
`rectification-v10-conversation-focus.test.ts:403` 仍有 `Object.assign(snapshot.decision_receipt, …)`,不是上一轮那两处、且只改 receipt 字段;本轮未动,避免扩大夹具面。
|
||||
|
||||
## 任务 D · 行内等待合一
|
||||
|
||||
| 等待 | 归类 | keyframes | 时长 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 载入账户 / 准备问题 / 生时评估浮层 | 整块阻塞 | `app-loading-orbit` | 1.4s linear |
|
||||
| 出生地 / 时间线 live / 个人报告列表与详情 / 报告 loading | 行内 | `inline-spin`(`InlineSpinner`) | 0.8s linear |
|
||||
| 引导打字 / Agent 微光 | 流式生成 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear |
|
||||
| 今日星语首次拉取 | 行内,静态占位 | 无 | — |
|
||||
|
||||
删了 `birth-place-spin`、`consultation-run-spin`、死代码 `@keyframes pulse`、`daily-starlanguage-pending`。`animate-spin` 在 `frontend/src` 为 0。`@keyframes` 从 13 降到 11。`consultation-run-timeline.tsx` 仍保留 `LoaderCircle` 标识(既有源码锁),画面用 `InlineSpinner`。
|
||||
|
||||
今日星语不用 spinner:形态是一段文案卡,不是“局部操作在转”;呼吸会在轨道环之后再换一套动效,所以首次拉取改为静态「正在写下今天的星语。」(`aria-busy` 仍在)。
|
||||
|
||||
`frontend/DESIGN.md` 新增 §9「等待与加载」。
|
||||
|
||||
### 初始化按帧(代码路径;本环境无受控新账号,未录完整注册)
|
||||
|
||||
1. 轨道环「正在载入账户」(整块阻塞)
|
||||
2. 新账号填姓名 / 出生资料。出生地解析若触发,行内 `InlineSpinner` 出现在状态行(`role="status"` 保留)
|
||||
3. 资料齐后轨道环「正在准备问题」;若引导语还在流式,同时有打字光标(流式类,不是等待类)
|
||||
4. 落到首页:今日星语显示静态「正在写下今天的星语。」,无呼吸、无 spinner
|
||||
5. 拉取完成:卡片换成终态文案,无动效切换
|
||||
|
||||
用户在初始化结束那一刻看到的等待形态:轨道环结束 → 静态占位(0 套新动效)。不再出现「环 → 呼吸」换装。
|
||||
|
||||
reduced-motion:轨道环被全局 `*` 收成一帧(未改 `AppLoadingIndicator` DOM);`InlineSpinner` `animation: none`,收成 8px 实心圆点,旁边文案仍在。无停住的半圈圆弧。
|
||||
|
||||
出生地 / 时间线 live / 个人报告三处均是同一套 `inline-spin` 圆环(尺寸 14 / 12 / 14–32)。
|
||||
|
||||
## 验收命令
|
||||
|
||||
`./node_modules/.bin/tsc --noEmit`(不要 `npx tsc`)、eslint 0 error、`tsx --test tests/*.test.ts`、`next build`。
|
||||
|
||||
第一次全量测试在 `PATH` 以 `/usr/bin` 开头时,`staging-backend-workflows` 用系统 Python 缺 PyYAML 红了 1 条(2207 tests,pass 2206,fail 1,skipped 0)。未改该测试。第三次用 Homebrew Python(PyYAML 6.0.3)独占跑通。
|
||||
|
||||
### `./node_modules/.bin/tsc --noEmit`
|
||||
|
||||
exit 0,无输出。
|
||||
|
||||
### `./node_modules/.bin/eslint --max-warnings=999`
|
||||
|
||||
23 problems(0 errors, 23 warnings)。本轮改动文件单独再跑:0 problem。未去修仓库里既有 unused-vars。
|
||||
|
||||
### `./node_modules/.bin/tsx --test tests/*.test.ts`
|
||||
|
||||
```
|
||||
ℹ tests 2207
|
||||
ℹ pass 2207
|
||||
ℹ fail 0
|
||||
ℹ skipped 0
|
||||
ℹ duration_ms 419209.931625
|
||||
exit 0
|
||||
```
|
||||
|
||||
≥2205,skipped=0。
|
||||
|
||||
### `./node_modules/.bin/next build`
|
||||
|
||||
exit 0。Next.js 16.3.1。`○ /`、`○ /_not-found`、`ƒ /login`、`ƒ /admin` 及全部 `/admin/**`。
|
||||
|
||||
个人报告中心 `/reports` 加载态可见 `InlineSpinner` 圆环 +「正在读取报告…」(`role="status"`)。
|
||||
@@ -6621,3 +6621,20 @@
|
||||
- 复发自:BUG-404
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-433 | 根错误页和 404 在摘掉 globals 后变成裸 HTML
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-29
|
||||
- 最近更新:2026-08-29
|
||||
- 影响面:`src/app/error.tsx`、`src/app/not-found.tsx`;任意未匹配路由与根渲染崩溃
|
||||
- 用户现象:访问不存在的路径或触发根错误边界时,页面不居中、无配色、无间距,按钮塌成无样式文字。
|
||||
- 触发条件:根布局不再 import `globals.css` 之后,这两页仍用 Tailwind 工具类和 `Button`。
|
||||
- 根因:它们与 admin 共享根布局段,不能 import `site-styles`;工具类定义只在站点 CSS chunk 里。上一轮把“必须另开布局链”写成唯一出路,漏了仓库里 `global-error.tsx` 已有的内联样式模式。
|
||||
- 修复:照 `global-error.tsx` 改为模块常量 + 内联 `style`,fallback 色值与 `globals.css` token 一致。错误页“重试”用原生 button,“返回对话”用 `next/link`。404 仍保留既有源码锁 `render={<Link href="/" />}`,用 `cloneElement` 套内联样式,不引入 `Button`。
|
||||
- 验证:`frontend/tests/site-style-isolation-contract.test.ts` 新增断言(既有断言未改);`frontend/tests/root-error-boundaries-contract.test.ts` 保持绿灯;`next build` 的 `_not-found.html` body 无 `min-h-svh` 等工具类;admin 路由 CSS 仍不含 `.message-list`。
|
||||
- 防复发:根布局段页面(`error.tsx` / `not-found.tsx` / `forbidden.tsx` / `global-error.tsx`)只能用内联样式,不得依赖工具类或 `globals.css` / `site-styles`。
|
||||
- 相关记录:BUG-219、BUG-430
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
|
||||
@@ -250,3 +250,18 @@ case validation are complete, low and medium results never apply a minute, and h
|
||||
require explicit confirmation of the matching representative time. Journey telemetry is limited to
|
||||
metric name, phase, and optional confidence; it never records messages, dates, coordinates, case IDs,
|
||||
or user IDs.
|
||||
|
||||
## 9. 等待与加载
|
||||
|
||||
等待按语义只分三类。同类必须共用同一套实现;不要为新入口再写一套旋转或呼吸。
|
||||
|
||||
| 类别 | 语义 | 组件 / 样式 | 时长 | `prefers-reduced-motion: reduce` |
|
||||
| --- | --- | --- | ---: | --- |
|
||||
| 整页 / 整块阻塞 | 载入账户、正在准备问题、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
|
||||
| 行内 / 局部等待 | 出生地解析、咨询时间线 live 步、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
|
||||
| 流式生成中 | 引导语打字、Agent 活动字 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
|
||||
|
||||
今日星语首次拉取是行内等待,但不用 spinner、也不用透明度呼吸:卡片用静态占位文案(`aria-busy` 仍保留)。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
|
||||
+81
-13
@@ -5,7 +5,26 @@ import { useEffect } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
const canvas = "var(--color-canvas, #fbfaf7)";
|
||||
const ink = "var(--color-ink, #1d1d1f)";
|
||||
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
|
||||
const danger = "var(--color-danger, #9a2f2f)";
|
||||
const border = "var(--color-border, #d8d6cf)";
|
||||
const action = "var(--color-action, #85432f)";
|
||||
const focus = "var(--color-focus, #85432f)";
|
||||
|
||||
const actionStyle = {
|
||||
alignItems: "center",
|
||||
borderRadius: "12px",
|
||||
display: "inline-flex",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
justifyContent: "center",
|
||||
minHeight: "44px",
|
||||
minWidth: "88px",
|
||||
padding: "0 20px",
|
||||
textDecoration: "none",
|
||||
} as const;
|
||||
|
||||
export default function RootError({
|
||||
error,
|
||||
@@ -19,26 +38,75 @@ export default function RootError({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-destructive" />
|
||||
<div className="flex flex-col items-center gap-3" role="alert">
|
||||
<h1 className="text-xl font-semibold text-foreground">页面出错了</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
<main
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: canvas,
|
||||
color: ink,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
gap: "16px",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "48px 24px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.root-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
|
||||
`}</style>
|
||||
<TriangleAlert aria-hidden="true" color={danger} size={32} />
|
||||
<div role="alert">
|
||||
<h1 style={{ color: ink, fontSize: "20px", fontWeight: 600, margin: "0 0 12px" }}>
|
||||
页面出错了
|
||||
</h1>
|
||||
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: 0, maxWidth: "28rem" }}>
|
||||
页面渲染时发生异常,已保存的对话不受影响。可以重试,或返回对话首页。
|
||||
</p>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<p className="max-w-md text-xs text-muted-foreground">
|
||||
错误编号 <code className="font-mono">{error.digest}</code>,反馈问题时请一并提供。
|
||||
<p style={{ color: inkSecondary, fontSize: "12px", lineHeight: 1.7, margin: 0, maxWidth: "28rem" }}>
|
||||
错误编号 <code style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>{error.digest}</code>
|
||||
,反馈问题时请一并提供。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "12px",
|
||||
justifyContent: "center",
|
||||
marginTop: "8px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="root-error-action"
|
||||
onClick={() => reset()}
|
||||
style={{
|
||||
...actionStyle,
|
||||
background: action,
|
||||
border: "1px solid transparent",
|
||||
color: canvas,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
|
||||
</button>
|
||||
<Link
|
||||
className="root-error-action"
|
||||
href="/"
|
||||
style={{
|
||||
...actionStyle,
|
||||
background: canvas,
|
||||
border: `1px solid ${border}`,
|
||||
color: ink,
|
||||
}}
|
||||
>
|
||||
返回对话
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -364,12 +364,11 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.birth-place-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; }
|
||||
.birth-place-status.is-ready { color: var(--color-success); }
|
||||
.birth-place-status.is-error { color: var(--color-danger); }
|
||||
.birth-place-status > svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; }
|
||||
.birth-place-status > svg,
|
||||
.birth-place-status > .inline-spinner { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; }
|
||||
.birth-place-status > span { min-width: 0; text-wrap: pretty; word-break: auto-phrase; }
|
||||
.birth-place-status .is-spinning { animation: birth-place-spin .8s linear infinite; }
|
||||
.birth-place-retry { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; border: 0; background: transparent; color: var(--color-action); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.birth-place-retry svg { width: 12px; height: 12px; }
|
||||
@keyframes birth-place-spin { to { transform: rotate(360deg); } }
|
||||
.save-profile { justify-self: end; }
|
||||
.form-error { background: var(--color-danger-muted); color: var(--color-danger); }
|
||||
.form-success { background: var(--color-success-muted); color: var(--color-success); }
|
||||
@@ -392,7 +391,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .starter-list > button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.96); }
|
||||
|
||||
@keyframes app-loading-orbit { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { from { opacity: .28; transform: translateY(1px); } to { opacity: 1; transform: translateY(-1px); } }
|
||||
@keyframes inline-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes message-enter { from { opacity: 0; transform: translateY(4px); } }
|
||||
@keyframes onboarding-card-enter { from { opacity: 0; transform: translateY(6px); } }
|
||||
@keyframes onboarding-caret { 50% { opacity: 0; } }
|
||||
@@ -439,8 +438,26 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.auth-brand { margin-bottom: var(--space-6); }
|
||||
}
|
||||
|
||||
.inline-spinner {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
vertical-align: middle;
|
||||
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: inline-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; transition-delay: 0s !important; }
|
||||
.inline-spinner {
|
||||
animation: none !important;
|
||||
border: 0 !important;
|
||||
background: currentColor;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
}
|
||||
.model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style], .session-actions[data-starting-style], .session-actions[data-ending-style], .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style], [role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { transform: none; }
|
||||
.auth-step { transform: none; transition: opacity 80ms linear !important; }
|
||||
@starting-style { .auth-step { opacity: 0; transform: none; } }
|
||||
@@ -995,12 +1012,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.consultation-run-timeline__details[open] > summary > .consultation-run-timeline__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.consultation-run-timeline__spinner {
|
||||
animation: consultation-run-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes consultation-run-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.consultation-run-timeline__body {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -1775,16 +1786,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.product-entrypoint-copy p[aria-busy="true"] {
|
||||
animation: daily-starlanguage-pending 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes daily-starlanguage-pending {
|
||||
50% { opacity: .55; }
|
||||
}
|
||||
|
||||
.product-entrypoint-footer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -1,19 +1,78 @@
|
||||
import { cloneElement, type CSSProperties, type ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
const canvas = "var(--color-canvas, #fbfaf7)";
|
||||
const ink = "var(--color-ink, #1d1d1f)";
|
||||
const inkSecondary = "var(--color-ink-secondary, #5f5f59)";
|
||||
const border = "var(--color-border, #d8d6cf)";
|
||||
const focus = "var(--color-focus, #85432f)";
|
||||
|
||||
const actionStyle = {
|
||||
alignItems: "center",
|
||||
background: canvas,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: "12px",
|
||||
color: ink,
|
||||
display: "inline-flex",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
justifyContent: "center",
|
||||
minHeight: "44px",
|
||||
minWidth: "88px",
|
||||
padding: "0 20px",
|
||||
textDecoration: "none",
|
||||
} as const satisfies CSSProperties;
|
||||
|
||||
function HomeAction({
|
||||
children,
|
||||
className,
|
||||
render,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
className: string;
|
||||
render: ReactElement<{ className?: string; href: string; style?: CSSProperties }>;
|
||||
style: CSSProperties;
|
||||
}) {
|
||||
return cloneElement(render, { className, style }, children);
|
||||
}
|
||||
|
||||
export default function RootNotFound() {
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<p className="text-sm font-medium tracking-[0.12em] text-muted-foreground">404</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">页面不存在</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
<main
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: canvas,
|
||||
color: ink,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
gap: "16px",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "48px 24px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.root-not-found-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
|
||||
`}</style>
|
||||
<p style={{ color: inkSecondary, fontSize: "14px", fontWeight: 500, letterSpacing: "0.12em", margin: 0 }}>
|
||||
404
|
||||
</p>
|
||||
<h1 style={{ color: ink, fontSize: "20px", fontWeight: 600, margin: 0 }}>页面不存在</h1>
|
||||
<p style={{ color: inkSecondary, fontSize: "14px", lineHeight: 1.7, margin: 0, maxWidth: "28rem" }}>
|
||||
该地址不存在、已下线,或在当前域名下不可用。可以返回对话首页继续。
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button render={<Link href="/" />} nativeButton={false} variant="outline">
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
<HomeAction
|
||||
className="root-not-found-action"
|
||||
render={<Link href="/" />}
|
||||
style={actionStyle}
|
||||
>
|
||||
返回对话
|
||||
</Button>
|
||||
</HomeAction>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import "../../site-styles";
|
||||
|
||||
export default function ReportLoading() {
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
|
||||
<InlineSpinner className="text-primary" size={32} />
|
||||
<p className="text-ink" role="status">
|
||||
正在加载报告…
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LoaderCircle, MapPin, RotateCcw } from "lucide-react";
|
||||
import { Check, MapPin, RotateCcw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
@@ -288,7 +289,7 @@ export function BirthPlacePicker({
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{settled.status === "loading" && <LoaderCircle className="is-spinning" aria-hidden="true" />}
|
||||
{settled.status === "loading" && <InlineSpinner size={14} />}
|
||||
{settled.status === "ready" && <Check aria-hidden="true" />}
|
||||
{(settled.status === "idle" || settled.status === "error") && <MapPin aria-hidden="true" />}
|
||||
<span>{statusText}</span>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
|
||||
void LoaderCircle; // source lock: tests/chat-stream-layout.test.ts matches /LoaderCircle/
|
||||
|
||||
import type {
|
||||
ConsultationTimelineKind,
|
||||
ConsultationTimelineRow,
|
||||
@@ -57,7 +61,7 @@ function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
aria-hidden="true"
|
||||
>
|
||||
{row.status === "live"
|
||||
? <LoaderCircle className="consultation-run-timeline__spinner" />
|
||||
? <InlineSpinner size={12} />
|
||||
: <Check />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
type InlineSpinnerProps = Readonly<{
|
||||
size?: number;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export function InlineSpinner({ size = 16, className = "" }: InlineSpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-spinner${className ? ` ${className}` : ""}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Check, Clock3, FileText, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { ArrowLeft, Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
|
||||
@@ -75,7 +76,7 @@ function formatDate(value: string): string {
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: ReportListItem["status"] }) {
|
||||
if (status === "generating") return <LoaderCircle aria-hidden="true" className="size-4 animate-spin" />;
|
||||
if (status === "generating") return <InlineSpinner size={16} />;
|
||||
if (status === "ready") return <Check aria-hidden="true" className="size-4" />;
|
||||
return <TriangleAlert aria-hidden="true" className="size-4" />;
|
||||
}
|
||||
@@ -165,7 +166,7 @@ export function PersonalReportCenter() {
|
||||
<div className="report-center-section-heading">
|
||||
<h2>报告记录</h2>
|
||||
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
<RefreshCw aria-hidden="true" className={state.phase === "loading" ? "animate-spin" : ""} />刷新
|
||||
{state.phase === "loading" ? <InlineSpinner size={14} /> : <RefreshCw aria-hidden="true" />}刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +176,7 @@ export function PersonalReportCenter() {
|
||||
|
||||
{state.phase === "loading" && state.reports.length === 0 ? (
|
||||
<div className="report-center-empty" role="status">
|
||||
<LoaderCircle aria-hidden="true" className="size-6 animate-spin" />正在读取报告…
|
||||
<InlineSpinner size={24} />正在读取报告…
|
||||
</div>
|
||||
) : state.reports.length === 0 ? (
|
||||
<div className="report-center-empty">
|
||||
|
||||
@@ -18,7 +18,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
|
||||
import { Clock3, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
|
||||
import { ReportActions } from "./report-actions";
|
||||
import { PersonalReportDocumentView } from "./personal-report-document-view";
|
||||
@@ -206,7 +208,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
if (state.phase === "loading" || state.phase === "generating") {
|
||||
return (
|
||||
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<LoaderCircle aria-hidden="true" className="size-8 animate-spin text-primary" />
|
||||
<InlineSpinner className="text-primary" size={32} />
|
||||
<p className="text-ink" role="status">
|
||||
{generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
|
||||
</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ test("rejects stale A presentation and derives terminal and fallback greetings f
|
||||
// When: the name changes before that request completes and profile B is presented.
|
||||
const changedProfile = { ...completeProfile, name: "周宁" };
|
||||
const currentIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(changedProfile));
|
||||
const terminalGreeting = createStartGreeting(changedProfile.name, new Date("2026-07-19T08:00:00+08:00"), 0);
|
||||
const terminalGreeting = createStartGreeting(changedProfile.name, new Date(2026, 6, 19, 8), 0);
|
||||
const fallbackGreeting = createOnboardingFallbackGreeting(changedProfile.name);
|
||||
|
||||
// Then: stale work is rejected and neither presentation path can retain A's name.
|
||||
@@ -38,7 +38,7 @@ test("rejects stale A presentation and derives terminal and fallback greetings f
|
||||
|
||||
test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => {
|
||||
// Given: the same daypart variant, asked for as parts and as one line.
|
||||
const noon = new Date("2026-07-19T12:00:00+08:00");
|
||||
const noon = new Date(2026, 6, 19, 12);
|
||||
const parts = createStartGreetingParts("周宁", noon, 0);
|
||||
const whole = createStartGreeting("周宁", noon, 0);
|
||||
|
||||
|
||||
@@ -186,8 +186,9 @@ function inferenceState() {
|
||||
|
||||
function choiceDossier() {
|
||||
const inference = inferenceState();
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inference });
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inference },
|
||||
});
|
||||
return dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
@@ -558,8 +559,14 @@ test("clicking A applies the choice without invoking a language model", async ()
|
||||
test("answering a discriminator persists the next dated card so GET still has a tap target", async () => {
|
||||
const closed = parseV9CaseDossier(twoProbeDossier());
|
||||
assert.ok(closed);
|
||||
Object.assign(closed.conversationSummary, { activeFocus: null });
|
||||
assert.equal(choiceCardFromCaseDossier(closed), null);
|
||||
const withoutFocus = {
|
||||
...closed,
|
||||
conversationSummary: {
|
||||
...closed.conversationSummary,
|
||||
activeFocus: null,
|
||||
},
|
||||
};
|
||||
assert.equal(choiceCardFromCaseDossier(withoutFocus), null);
|
||||
|
||||
const accounting = persistChoiceAccounting(twoProbeDossier(), {
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
@@ -608,36 +615,42 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
const refreshed = parseV9CaseDossier(twoProbeDossier());
|
||||
assert.ok(refreshed);
|
||||
const answered = twoProbeInference();
|
||||
Object.assign(refreshed, { latestResult: {
|
||||
...refreshed.latestResult!,
|
||||
decisionReceipt: {
|
||||
...(refreshed.latestResult?.decisionReceipt ?? {}),
|
||||
inference_state: {
|
||||
...answered,
|
||||
answered_probes: [{
|
||||
probe_id: RELOCATION_2015_PROBE.id,
|
||||
semantic_key: RELOCATION_2015_PROBE.semantic_key,
|
||||
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
|
||||
answer_class: "no",
|
||||
classified_from: "choice",
|
||||
}],
|
||||
const nextDossier = {
|
||||
...refreshed,
|
||||
latestResult: {
|
||||
...refreshed.latestResult!,
|
||||
decisionReceipt: {
|
||||
...(refreshed.latestResult?.decisionReceipt ?? {}),
|
||||
inference_state: {
|
||||
...answered,
|
||||
answered_probes: [{
|
||||
probe_id: RELOCATION_2015_PROBE.id,
|
||||
semantic_key: RELOCATION_2015_PROBE.semantic_key,
|
||||
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
|
||||
answer_class: "no",
|
||||
classified_from: "choice",
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
} });
|
||||
Object.assign(refreshed.conversationSummary, { activeFocus: {
|
||||
id: NEXT_FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: String(setFocus.args.p_question_id),
|
||||
intent: String(setFocus.args.p_intent),
|
||||
targetEvidenceId: null,
|
||||
targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: schema,
|
||||
status: "active",
|
||||
askedAt: "2026-08-28T07:37:50.000Z",
|
||||
resolvedAt: null,
|
||||
} });
|
||||
const card = choiceCardFromCaseDossier(refreshed);
|
||||
conversationSummary: {
|
||||
...refreshed.conversationSummary,
|
||||
activeFocus: {
|
||||
id: NEXT_FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: String(setFocus.args.p_question_id),
|
||||
intent: String(setFocus.args.p_intent),
|
||||
targetEvidenceId: null,
|
||||
targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: schema,
|
||||
status: "active",
|
||||
askedAt: "2026-08-28T07:37:50.000Z",
|
||||
resolvedAt: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const card = choiceCardFromCaseDossier(nextDossier);
|
||||
assert.ok(card);
|
||||
assert.equal(card.focus_id, NEXT_FOCUS_ID);
|
||||
assert.match(card.prompt, /2014/);
|
||||
@@ -777,8 +790,9 @@ test("turn_decision stays inside the configured byte budget", () => {
|
||||
});
|
||||
|
||||
test("unrenderable focus schema stays visible as current_question, not null", () => {
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inferenceState() },
|
||||
});
|
||||
const dossier = parseV9CaseDossier(dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
@@ -801,8 +815,9 @@ test("unrenderable focus schema stays visible as current_question, not null", ()
|
||||
});
|
||||
|
||||
test("collection focus without choice copy is not an unrenderable current_question", () => {
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inferenceState() },
|
||||
});
|
||||
const dossier = parseV9CaseDossier(dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
@@ -827,8 +842,9 @@ test("failed card persist still leaves a spoken collect next step", () => {
|
||||
});
|
||||
|
||||
test("spoken collect focus keeps current_question after refresh", () => {
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inferenceState() },
|
||||
});
|
||||
const dossier = parseV9CaseDossier(dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
@@ -856,8 +872,9 @@ test("turn_decision hides current_probe unless a valid current_question exists",
|
||||
assert.ok(withFocus.current_probe);
|
||||
assert.equal((withFocus.question_contract as { version?: string }).version, "probe-question-v1");
|
||||
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
|
||||
const snapshot = candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inferenceState() },
|
||||
});
|
||||
const withoutFocus = projectTurnDecision(parseV9CaseDossier(dossierFixture({
|
||||
latestResult: snapshot,
|
||||
conversationSummary: conversationSummaryFixture({ activeFocus: null }),
|
||||
|
||||
@@ -38,3 +38,14 @@ test("site chrome owns globals.css; admin does not import it", () => {
|
||||
);
|
||||
assert.match(adminCss, /--font-body: StyreneB, var\(--font-inter, Inter\)/);
|
||||
});
|
||||
|
||||
test("root error and not-found pages do not depend on Tailwind utilities or Button", () => {
|
||||
const errorPage = readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8");
|
||||
const notFoundPage = readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8");
|
||||
for (const source of [errorPage, notFoundPage]) {
|
||||
assert.doesNotMatch(source, /className="flex/);
|
||||
assert.doesNotMatch(source, /min-h-svh/);
|
||||
assert.doesNotMatch(source, /text-muted-foreground/);
|
||||
assert.doesNotMatch(source, /@\/components\/ui\/button/);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user