feat(settings,reports): six ayanamsa options; report-scope solid button colours (T6/T7, BUG-1078)

T6: offer True Citra and Fagan-Bradley beside Raman/Lahiri/KP/True Pushya,
engine-supported values only; widen profiles_ayanamsa_check by an additive
migration; ephemeris note names; path tests so no new value falls back to
Raman.

T7: the unlayered `button { color: inherit; }` beats Tailwind's
text-primary-foreground, so solid report buttons showed page ink (light
(light 4.85:1, dark 5.77:1), grey disabled without fade, neutral selected
preset. button.tsx and theme tokens unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:54:53 +08:00
co-authored by Claude Opus 5.5
parent 856dab6c55
commit 873fac8a5b
15 changed files with 483 additions and 8 deletions
+5
View File
@@ -7,6 +7,11 @@
- 本工作树无 `.venv`(主检出的 `.venv` 软链指向不存在的路径):Python 测试用系统 `python3`(pyswisseph 2.10.03)。基线即红的 10 条(`test_varga_vedastro_mode` 9 条、`test_ayanamsa_mode_ownership` 1 条)同名不变。
- Bhava / 行运真实引擎耗时与 429 频率未测;部署后看 `/api/bhava_chalit` 与带日期的 `/api/chart` 的 `chart_view_engine_failed` 日志。
- 未推送、未部署。
## TASK-chart-types-and-report-buttons T6/T7:真实 PostgreSQL 迁移测试、登录态真机待验(2026-09-28)
- 本机无 Docker:`npm run test:db` 未跑。T6 新增迁移 `20260928010000_profile_ayanamsa_six.sql`(只放宽 `profiles_ayanamsa_check`)与 `frontend/tests/database-profile-ayanamsa-six.test.ts`(本机 docker unavailable 跳过),结果以门禁 DB job 为准。替代证据:`ayanamsa-six-paths-20260928.test.ts` 的迁移源文本合同;24 条既有 DB / 部署套件失败名单与基线逐条一致。
- 无受控登录账号:设置里选 True Citra / Fagan-Bradley 后保存、星盘参数表显示、报告页深色按钮只在单元测试与本地无头 Chrome 的静态样张上验证;真机清单 `docs/testing/ayanamsa-report-buttons-20260928.md`。
- 未推送、未部署;BUG-1078 保持 investigating。
## TASK-consult-first-frame-and-pacing:真实 PostgreSQL 寒暄结算测试、真机与真实耗时待验(2026-09-28)
+7
View File
@@ -10,6 +10,13 @@
- 七政四余 Tab 先不展示;计算接口保留,以后放进对话里用。
- Skill 版本不 bump(Skill 文本未改)。不改数据库。
## 2026-09-28 — 岁差可选 6 种;「我的报告」按钮在深色下改成白字(待验收)
- 设置 → 星盘资料的岁差从 4 种扩到 6 种:Raman(默认)、Lahiri、KP、True Citra、True Pushya、Fagan-Bradley。新增的两种都是引擎原本就支持的;Djwhal Khul、Sassanian 引擎也支持但不放出。默认仍是 Raman,生时校正仍固定按 Raman。咨询、报告、星盘、星历、星盘档案都按所选岁差算,不会悄悄回到 Raman。
- 数据库放宽 `profiles` 岁差取值的检查(新迁移只改 CHECK,不动数据);部署前需先跑迁移,否则保存新岁差会失败。
- 「我的报告」列表、阅读页、分块导出和报告出错页里的实心按钮改成白字:浅色主题原来是近黑字压在深陶土红底上(产品反馈的「黑字配深红底」),深色主题原来是浅字压在浅珊瑚底上,两种都看不清;现在浅色是白字陶土底,深色是白字深陶土底。不可点的按钮改成灰色,不再半透明(「导出选中」在还没勾选时就是这样,BUG-1078);导出套餐选中项不再是深红褐底。其它页面的按钮不变(同样的问题另立单)。
- Skill 版本不 bump(Skill 文本未改)。
## 2026-09-28 — 普通对话发出即有反馈,回答按打字节奏写出(待验收)
- 按下发送的那一帧,回答行就带着步骤栏,第一行是「收到,正在看你的问题…」;服务端也先开流、首字节就是这句,读会话、预留点数、判断是不是寒暄这些步骤都在流里做。以前这一两秒整行空白(BUG-1074)。发「你好」这类寒暄会先闪一下这一行,服务端判定是寒暄后收掉,只留一句回复。
+14
View File
@@ -14497,3 +14497,17 @@
- 相关记录:BUG-320(Bhava 进换升表);`TASK-chart-types-and-report-buttons-20260928` T5。
- 复发自:无
- 修复版本:本地 `c35e0195`(未推送)。
## BUG-1078 | 我的报告:实心按钮标签继承正文色(浅色黑字深红底、深色浅字浅珊瑚底),禁用态半透明
- 状态:investigating(修复已在本地分支 `codex/ayanamsa-report-buttons-20260928` 提交,未推送、未部署;真机未走)
- 首次发现 / 最近更新:2026-09-28 / 2026-09-28
- 影响面:「我的报告」列表(`.report-center-*`)、阅读页与分块导出对话框(`.personal-report-reader`、`.report-export-*`)、报告 loading / 状态 / error / not-found(`.personal-report-state`)上渲染为 `<button>` 的实心 `Button`(「导出选中」「继续等待」等)与所有禁用按钮。**同样的根因作用于全站所有渲染为 `<button>` 的实心 `Button`**,本单按产品决定只修报告范围,其它页面未修(见防复发)。
- 现象:产品转述真机反馈:导出按钮黑字配深红色背景,看着不舒服。
- 触发条件:浅色主题打开分块导出,「导出选中」是近黑字 #1d1d1f 在 #a9583e 上(3.33:1);深色主题是 #f2f0ea 在 #d78064 上(2.57:1)。未勾选时按钮再被整体淡到 45%。套餐选中态用 `--color-action-soft`,深色下是 #3a2620 深红褐。
- 根因:`globals.css` 顶部未分层的 `button { color: inherit; }` 与 `button:disabled { opacity: .45; }` 压过 Tailwind v4 放在 `@layer utilities` 里的 `text-primary-foreground` 与 `disabled:opacity-50`(未分层声明总是赢分层声明,与选择器权重无关)。所以 `variant="default"` 的 `<button>` 标签颜色一直是继承的正文色,而不是设计的 `--color-on-dark`。`<Button render={<Link/>}>` 渲染为 `<a>`,不受影响,所以「去登录」这类链接按钮一直是对的。实证:本地 `next build` 产出的 CSS + 无头 Chrome 取 `getComputedStyle`,基线「导出选中 (3)」浅色 `color: rgb(29, 29, 31)`、深色 `rgb(242, 240, 234)`(样张与数值见 PROGRESS)。任务书起初判断为「深色主题下近黑字」,按实测更正。
- 修复(产品 2026-09-28 拍板:只改我的报告,不改全站):`globals.css` 新增一段只挂在四个报告根类下的按钮规则,实心按钮(`[data-slot="button"][class~="bg-primary"]`)自己声明底色与标签色:浅色 #fbfaf7 on #a9583e(4.85:1,hover 同原 `bg-primary/80`);深色 `--color-ink` #f2f0ea on `--color-action-on-dark` #8f4a33(5.77:1),hover 混 6% ink(5.1:1)。禁用态(任何变体)= `--color-canvas-strong` 底 + `--color-ink-tertiary` 字、`opacity: 1`。套餐选中态改 `--color-canvas-muted` 底 + `--color-ink` 字 + `--color-ink-secondary` 边。`button.tsx`、全局 `button` 规则与全部主题 token 未改。
- 验证:`frontend/tests/report-buttons-scope-20260928.test.ts`(5 条):`button.tsx` 变体 / 禁用淡出 / `data-slot` 原样;三处主题块相关 token 原值;所有 `[data-slot="button"]` 覆盖规则都以报告作用域开头、两条深色路径各一处、样式表不定义 `.bg-primary`;前后对比度(前 < 4.5、后 ≥ 4.5)且覆盖规则自己设 `color`;套餐选中态不再用 action-soft。`class-name-definition-contract`、`dark-theme-contract`、`design-token-contract`、`report-export-drawer` 仍绿。无头 Chrome 计算样式:新版浅色「导出选中 (3)」`rgb(251, 250, 247)` on `rgb(169, 88, 62)`,深色 `rgb(242, 240, 234)` on `rgb(143, 74, 51)`;禁用 opacity 1;报告范围外对照按钮与基线一致。
- 防复发:报告页按钮颜色只在这段作用域规则里改。全站其它实心 `<button>` 仍有同一问题(浅色 3.33:1、深色 2.57:1):要修应另立单,在全局 `button { color: inherit; }` 或 `Button` 组件层面解决,并逐页截图;不要把这段报告作用域规则挪成全局。
- 相关记录:BUG-738(action 两档对比度)、BUG-1018 / 1019(分块导出);`TASK-chart-types-and-report-buttons-20260928` T7。
- 复发自:无
- 修复版本:待推送
@@ -0,0 +1,75 @@
# PROGRESS · 岁差 6 种(T6)与「我的报告」按钮配色(T7,BUG-1078)(2026-09-28)
任务书:`docs/tasks/TASK-chart-types-and-report-buttons-20260928.md`(T6、T7 及其 T8 文档部分)。T1–T5(星盘页)由另一执行方并行,本单未碰 `chart-page/*`、`chart-view-*`、`use-chart-page.ts`、`secondary-page-data.ts` 与任何 Python。产品 2026-09-28 授权直接执行(Claude fork 子代理)。
- 基线:`origin/staging` = `5dc977ef`
- 分支 / 工作树:`codex/ayanamsa-report-buttons-20260928` / `.worktrees/ayanamsa-report-buttons-20260928`(本地提交,未推送)
- 环境:Node v22.14.0(`/exec-daemon/node`),Linux;无 Docker;有 `/usr/bin/google-chrome`(无头)。
## T6 岁差扩到 6 项 — 完成
- `frontend/src/lib/ayanamsa.ts`:`AYANAMSA_VALUES` = `raman, lahiri, kp, true_citra, true_pushya, fagan_bradley`;设置项新增 True Citra「以角宿一的实际位置定起点,和 Lahiri 只差约 1 角分。」、Fagan-Bradley「西方恒星占星常用,比 Lahiri 多约 0.9°。」。`resolveAyanamsa` 接受 `true_chitra` → `true_citra`(与 `scripts/ayanamsa_utils.py` 一致)。默认仍 Raman,`AYANAMSA_SWITCH_HINT` 原句保留。
- 数字核对(pyswisseph,1950 / 2000 / 2026 年 1 月 1 日):Fagan-Bradley − Lahiri = 0.8832°(恒定);True Citra − Lahiri = −0.0164° / −0.0171° / −0.0174°(约 1 角分)。
- **偏离任务书:标签写 True Citra,不是 True Chitra。** 星盘页参数表显示的是引擎的 `ayanamsa_display`(`AYANAMSA_DISPLAY_NAMES['true_citra'] = 'True Citra'`),报告的 `SAFE_AYANAMSA` 也是 True Citra;设置里写 Chitra 会让同一个值在两处拼法不同。本单不动 chart-view 与 Python,所以统一用引擎拼法。`true_chitra` 仍作为别名接受。
- **任务书遗漏:数据库 CHECK。** `profiles.ayanamsa` 有 `profiles_ayanamsa_check`(`20260903010000_profile_ayanamsa.sql`)只许旧 4 个值;只改前端会让保存新岁差 500。新增迁移 `frontend/supabase/migrations/20260928010000_profile_ayanamsa_six.sql`:只 drop / add 该 CHECK(放宽,AGENTS §7.6 允许同轮),不动列、默认值、授权、数据。`chart_profiles.ayanamsa` 无 CHECK;人物接口直写类型化列,`chart_profiles_fill_typed_from_json` 的 JSON 兜底(只认旧 4 值)不在写路径上,未改。**部署顺序:迁移先于代码**(staging 迁移本来就在部署前自动应用)。回滚代码后已存的新值会被旧代码 `resolveAyanamsa` 读成 Raman。
- 显示名:`ephemeris-view.ts` `AYANAMSA_SHORT_NAME` 补两项(星历页岁差说明句);`personal-report-generation.ts` 已有,未改;星盘页用引擎显示名,**无需改 chart-view 文件**。人物档案详情取 `AYANAMSA_OPTIONS` 的 label,自动跟随。
- 全链路:12 处 `resolveAyanamsa` / `AYANAMSA_VALUES`(咨询 route service、报告 worker、星盘 load、人物写入、账户 PATCH zod、mastra workflow zod、校正 payload 等)都从同一枚举取值,无第二份白名单;Python 侧 `normalize_ayanamsa_name` 与 CLI `choices=AYANAMSA_MODES` 已含两值。
- 测试:
- `frontend/tests/ayanamsa.test.ts` 改既有断言(三栏):
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 测试名 | `…accepts the four product values` | `…accepts the six product values` | 值从 4 个变 6 个 |
| `AYANAMSA_VALUES` 断言 | `["raman","lahiri","kp","true_pushya"]` | `["raman","lahiri","kp","true_citra","true_pushya","fagan_bradley"]` | 产品 D4 |
| 新增一行 | — | `djwhal_khul` → `raman` | 证明不放出的值仍走回退 |
另加 2 条:6 值逐一原样返回(含大写 / 连字符写法与 `true_chitra` 别名);设置列表顺序与标签、每个值都在 `ayanamsa_utils.py` 里。
- 新增 `frontend/tests/ayanamsa-six-paths-20260928.test.ts`(7 条):账户 PATCH 接受 6 个、拒绝 `djwhal_khul / sassanian / fagan / yukteshwar`;迁移源文本(只改 CHECK、恰好 6 值);账户行、人物写入与读回、星盘发给引擎的 `ayanamsa` 均为 `true_citra` / `fagan_bradley`;星历说明句;报告显示名。
- 新增 `frontend/tests/database-profile-ayanamsa-six.test.ts`(1 条,需 Docker,本机跳过):真实库上 6 值可写、3 个非法值被 `profiles_ayanamsa_check` 拒绝。
## T7 我的报告按钮配色(BUG-1078)— 完成(真机欠)
- **根因更正**:任务书与 Claude 首轮判断是「深色主题下近黑字」。实测(本地 `next build` CSS + 无头 Chrome `getComputedStyle`)不是:`globals.css` 顶部未分层的 `button { color: inherit; }` 压过 Tailwind 分层的 `text-primary-foreground`,实心 `<button>` 的字一直是正文色——**浅色** #1d1d1f on #a9583e(3.33:1,正是反馈的「黑字配深红底」),深色 #f2f0ea on #d78064(2.57:1)。未分层的 `button:disabled { opacity: .45 }` 再把禁用按钮整体淡掉。`render={<Link/>}` 的按钮是 `<a>`,不受影响。这个问题**全站**所有实心 `<button>` 都有,本单按产品决定只修报告范围。
- 修复:`globals.css` 在分块导出规则之后新增报告作用域段(`.report-center-message / .report-center-body / .personal-report-state / .personal-report-reader`):实心 `[data-slot="button"][class~="bg-primary"]` 自己声明底色与字色(浅色 #fbfaf7 on #a9583e 4.85:1、hover 同原 `bg-primary/80`;深色 #f2f0ea on #8f4a33 5.77:1、hover 混 6% ink 5.1:1);禁用(任何变体)`--color-canvas-strong` 底 + `--color-ink-tertiary` 字、`opacity: 1`(浅色 4.10:1、深色 3.95:1,禁用态不受 AA 约束);套餐选中态 `--color-canvas-muted` + `--color-ink` + `--color-ink-secondary` 边(浅色 13.86:1、深色 11.62:1)。深色两条路径(系统偏好 / 固定深色)各一份,写法同 token 块。
- 用 `[class~="bg-primary"]` 而不是 `.bg-primary`:`class-name-definition-contract` 把样式表里每个 `.name` 的首段当作项目类族,写 `.bg-primary` 会让 `bg-background` / `bg-transparent` 等 Tailwind 工具类被判为「未定义的项目类」——首轮就这样红了 1 条,改成属性选择器后绿。
- 未改:`components/ui/button.tsx`、全局 `button` 规则、`:root` 与两块深色 token(合同测试锁定)。
- 测试:新增 `frontend/tests/report-buttons-scope-20260928.test.ts`(5 条,见 BUG-1078 验证段)。
- 样张(本地 build CSS + 与 `button.tsx` 同 class 串的静态页,390px,Chrome 无头),存于执行会话 scratchpad `shots/`(`base-light.png` / `new-light.png` / `base-dark.png` / `new-dark.png`,未入库)。计算样式:
| 按钮 | 基线浅色 | 新版浅色 | 基线深色 | 新版深色 |
| --- | --- | --- | --- | --- |
| 导出选中 (3) | 字 29,29,31 / 底 169,88,62 | 字 251,250,247 / 底 169,88,62 | 字 242,240,234 / 底 215,128,100 | 字 242,240,234 / 底 143,74,51 |
| 导出选中 (0)(禁用) | 同上,opacity 0.45 | 字 106,105,99 / 底 225,222,214,opacity 1 | 同上,opacity 0.45 | 字 156,152,142 / 底 59,58,55,opacity 1 |
| 套餐「常用」选中 | 字 169,88,62 / 底 247,236,229 | 字 29,29,31 / 底 235,233,227 | 字 215,128,100 / 底 58,38,32 | 字 242,240,234 / 底 48,48,45 |
| 范围外对照按钮 | 与基线一致 | 与基线一致 | 与基线一致 | 与基线一致 |
## T8 文档(本单部分)
`CHANGELOG.md` 一条(Skill 不 bump)、`docs/BUG_HISTORY.md` BUG-1078(investigating,待推送部署)、`frontend/DESIGN.md`(岁差一节 6 值;星历说明句名单;报告阅读页按钮一条)、`frontend/docs/VOICE.md`(星历岁差名单)、`docs/testing/ayanamsa-report-buttons-20260928.md`(13 条真机清单)、`BLOCKED.md`(无 Docker / 无登录态)。`CONTEXT.md` 无新术语,未改。
## 验证
| 项 | 基线 `5dc977ef` | 本分支 |
| --- | --- | --- |
| `tsc --noEmit` | 0 | 0 |
| `npm run lint` | 0 error / 127 warning | 0 error / 127 warning |
| `npm test`(Node 22) | 4212 tests / 4158 pass / 24 fail / 30 skip | 见下 |
| `next build` `/` | ○ Static | ○ Static |
| 首屏脚本 gzip-9(index.html 引用 26 个脚本) | 640,535 | 640,678(+143,+0.02%) |
| 首屏 CSS gzip-9 | 42,850 | 43,173(+323) |
24 条基线失败全部是需要 Docker / 部署环境的 DB 与部署套件,名单见 `BLOCKED.md` 同类条目;本分支失败名单与基线逐条比对见下。
**本分支 `npm test`(Node 22)**:4227 tests / 4172 pass / **24 fail** / 31 skip / 0 cancelled(退出码 1 同基线)。失败名单与基线 24 条**逐条相同**(`diff` 为空)。测试名对比:新增 16 条(含 1 条 docker 跳过的 DB 用例),消失 1 条 = `resolveAyanamsa defaults to Raman and accepts the four product values`,已改名为 `…six product values`(见上表三栏)。
隐私标记:`python3 -m pytest tests/test_repo_privacy_markers.py -q` 通过(本机无 `.venv`,用系统 Python)。
## 环境缺口
- 无 Docker:`npm run test:db` 未跑;新迁移与 `database-profile-ayanamsa-six.test.ts` 以门禁 DB job 为准。
- 无受控登录账号:保存新岁差、星盘参数表、报告页按钮只在单元测试与静态样张上验证;真机清单 `docs/testing/ayanamsa-report-buttons-20260928.md`。
- 未推送、未部署(按指令)。
## 交给 Claude 的范围外发现
- 全站所有渲染为 `<button>` 的实心 `Button`(报告范围外)都有同一问题:浅色近黑字 on #a9583e(3.33:1)、深色浅字 on #d78064(2.57:1),禁用时再淡到 45%。根因是 `globals.css` 未分层的 `button { color: inherit; }` / `button:disabled { opacity: .45 }` 压过 Tailwind 工具类。需要产品决定是否另立单。
@@ -0,0 +1,22 @@
# 真机清单 · 岁差 6 种与「我的报告」按钮配色(2026-09-28)
对应 `TASK-chart-types-and-report-buttons-20260928` T6 / T7、BUG-1078。部署到 staging 后照做;每条写「通过 / 不通过 + 截图」。用自己的账号或虚构人物,不要截别人的出生资料。
## 岁差
1. 设置 → 星盘资料(本人):岁差一共 6 项,顺序是 Raman(默认)、Lahiri、KP、True Citra、True Pushya、Fagan-Bradley。没有 Djwhal Khul、Sassanian。
2. 选 True Citra 保存,不报错;刷新页面仍是 True Citra。
3. 打开星盘页:参数表「岁差」一行写 True Citra。
4. 打开星历页:五要素卡下的一行写「五要素按 Lahiri 岁差算,和星盘页的 True Citra 不同。」
5. 换成 Fagan-Bradley 重复 2–4;星盘页的行星度数应比 Lahiri 时小约 0.9°(同一颗星对比即可)。
6. 星盘档案里给一个虚构人物选 Fagan-Bradley 保存,详情页岁差一行写 Fagan-Bradley。
7. 选回 Raman,确认一切恢复。生时校正仍按 Raman(设置里那行提示照旧)。
## 我的报告按钮(手机深色、浅色各走一遍)
8. 浅色(产品反馈就是这种):打开一份报告 → 右上导出 → 分块导出。还没勾选时「导出选中 (0)」是浅灰底灰字;勾选一块后是陶土底**白字**,不再是黑字深红底。
9. 深色:同一步骤。未勾选是深灰底灰字;勾选后是深陶土底白字,看得清、不刺眼。
10. 点「全部分盘」等套餐:选中那一个是中性灰底 + 白字 + 细边,不是深红褐底。
11. 报告列表未登录态「去登录」、报告生成超时页「继续等待」:深色下同样是深陶土底白字。
12. 两种主题各再看一遍 10–11。
13. 对照:首页、星盘页、设置里的实心按钮本单不改,它们仍是旧样子(同一问题另立单)。
+4 -3
View File
@@ -304,8 +304,8 @@ Both chat surfaces pin a new turn at its head instead of following the last stre
The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa radios. Personal 个人资料 stays account basics only.
- **Structure:** four 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
- **Values:** `raman` (default) / `lahiri` / `kp` / `true_pushya`. Copy lives in `frontend/src/lib/ayanamsa.ts`.
- **Structure:** six 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
- **Values (2026-09-28):** `raman` (default) / `lahiri` / `kp` / `true_citra` / `true_pushya` / `fagan_bradley`, shown as Raman(默认)/ Lahiri / KP / True Citra / True Pushya / Fagan-Bradley. Only values the engine already supports (`scripts/ayanamsa_utils.py`); Djwhal Khul and Sassanian are supported by the engine but deliberately not offered. The label is spelled True Citra, matching the engine's `ayanamsa_display` that the chart page parameter table shows. Copy lives in `frontend/src/lib/ayanamsa.ts`; `profiles_ayanamsa_check` lists the same six.
- **Hint:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
- **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`.
- **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute.
@@ -543,6 +543,7 @@ one skeleton, none of them carrying the sidebar.
### Personal report reader
- **Buttons on the report surfaces (2026-09-28, BUG-1078):** the list, the reader with its export dialog, and the loading / state / error / not-found screens restyle the shared `Button` locally; `components/ui/button.tsx` and the theme tokens are untouched, so other pages keep the shared primary. Cause: the unlayered `button { color: inherit; }` beats Tailwind's layered `text-primary-foreground`, so a solid `<button>` shows the page ink — #1d1d1f on #a9583e in light (3.33:1, the reported black-on-dark-red) and #f2f0ea on #d78064 in dark (2.57:1); `<Button render={<Link/>}>` renders an `<a>` and was never affected. Solid (`variant="default"`) now sets its own label colour: light #fbfaf7 on `--color-action` #a9583e (4.85:1); dark `--color-ink` #f2f0ea on `--color-action-on-dark` #8f4a33 (5.77:1), hover mixes 6% ink in (5.1:1). Disabled, any variant: `--color-canvas-strong` fill + `--color-ink-tertiary`, opacity 1 instead of the global 45% fade. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
- **分块导出对话框 (2026-09-24):** native modal `<dialog>`, bottom-aligned below 860px and centered at ≥860px, app colours only. Desktop is viewport-centered (`inset: 0; margin: auto`) regardless of sidebar state, width `min(720px, 92vw)`, max-height 80vh, with full `--radius-lg` corners. Its flex panel is capped at `calc(80vh - 2px)` to reserve the dialog border; desktop rules follow the narrow-screen dvh upgrade so 70dvh cannot override them. Below 860px the content-column offset, bottom alignment and at most 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts.
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`.
@@ -1104,7 +1105,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **先中文、后梵文。** 主行中文,副行 12px 三级色写引擎原名(`lang="sa-Latn"`)。中文名只来自 `src/lib/panchanga-names-zh.ts`,该表逐字抄自任务书附表,不自行翻译、不增删;匹配忽略大小写、空格、连字符,别名只有 Garija/Gara、Dwitiya/Dvitiya、Shashthi/Shashti。表里查不到的名字原样显示梵文、不出副行,并在控制台记一次 `panchanga_name_zh_missing`。星期接口里本来就是中文,不出副行。接口字段不变:`factor.value` 仍是引擎名,中文在视图层投影。§15 行星表「没有中文宿名表就不造一个」仍只管星盘页;星历这张表是产品在任务书里给定的。
- **吉 / 中 / 凶是行右侧的彩色小标签**(`.ephemeris-quality`):吉 `--color-success`、凶 `--color-danger`、中 `--color-warning`,底是同色 `color-mix(… 12%, transparent)`。只照搬引擎标签,不写成建议。
- **卡片顶部当日总评 + 计数。** 一句 `{月相短语},{整体}{Vishti 追加}。`,按固定规则拼(`panchangaDaySummary`,纯函数,不调模型):Purnima「满月之日」、Amavasya「新月之日」、其余白分「月亮渐盈」、黑分「月亮渐亏」;吉 ≥ 4「整体吉」、3「整体偏吉」、2「吉凶参半」、≤ 1「整体偏凶」,中不计入;迦那为 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。下面两枚计数标签「N 吉」「M 凶」。五要素或其中任一吉凶缺项时,总评和计数都不出,只显示已有行。
- 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Pushya} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。
- 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Citra / True Pushya / Fagan-Bradley} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。
- **当日行运(2026-09-26):** 每行是一个 `li`:左 1.75rem 彩色行星符号(复用 `--color-planet-*`,与星盘页行星表同色、同字体栈)+「{行星} 在 {星座}」+ 副行「落你的第 N 宫」(仅 `natalRelativeAvailable` 为真时)与逆行时的「逆行」+ 右侧等宽度数(度°分′,`tabular-nums`)。桌面与手机同一行式,不再有表头和 `data-label` 堆叠。没有本命上升时副行不写宫位,段首写「还没有本命上升,先不写落在你的第几宫。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
- **出口(2026-09-26 挪位):** 「带这天去提问」不在顶栏了。页面最底部一张浅色提示卡 `.ephemeris-ask-card`(`--color-action-soft` 底、`--radius-lg`、宽度与段落卡一致):左侧「想问这一天适不适合做某件事?」,右侧主按钮「带这天去提问」(44px),行为不变——把当前日期预填进对话草稿并回到 `/`。窄屏时问句自己折行,按钮不变宽。不写「这一页是天象本身,不是对你的判断。」
+1 -1
View File
@@ -106,7 +106,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 五要素先写中文,梵文小字在下;中文名只用 `panchanga-names-zh.ts` 那张表(任务书给定),不自己翻,查不到就照写梵文。
- 当日总评只有一种句式:`{月相短语},{整体}{Vishti 追加}。` 月相短语是「满月之日 / 新月之日 / 月亮渐盈 / 月亮渐亏」;整体是「整体吉 / 整体偏吉 / 吉凶参半 / 整体偏凶」;迦那是 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。例:「满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。」规则拼句,不调模型,不加别的形容词;五要素不全就不写。
- 计数标签写「3 吉」「2 凶」,数字与字之间一个空格。吉 / 中 / 凶标签只照搬引擎。
- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Pushya);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」
- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Citra / True Pushya / Fagan-Bradley);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」
- 行运行:「太阳 在 狮子座」,副行「落你的第 2 宫」,逆行时加「· 逆行」。没有本命上升时段首写「还没有本命上升,先不写落在你的第几宫。」(原句「相对本命这一列空着」里的「列」已经没有了)。
- 底部提示卡:左边「想问这一天适不适合做某件事?」,右边按钮「带这天去提问」。这是邀请用户问,不是页面给宜忌结论;页面其余地方仍不写「适合 / 不宜」。
+57 -1
View File
@@ -3979,7 +3979,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); }
.report-export-count { font-variant-numeric: tabular-nums; }
.report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.report-export-presets [aria-pressed="true"] { background: var(--color-action-soft); color: var(--color-action); }
/* Selected preset: neutral raised fill + full ink + a visible edge. The old
--color-action-soft fill reads as a dark red-brown block in the dark theme
(#3a2620) — BUG-1078. */
.report-export-presets [aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); border-color: var(--color-ink-secondary); }
.report-export-list { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 0 var(--space-4) var(--space-4); }
.report-export-list h3 { font-size: var(--type-title-sm); margin: var(--space-3) 0 var(--space-2); }
.report-export-grid { display: grid; gap: var(--space-2); }
@@ -3995,6 +3998,59 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media print { .report-export-drawer { display: none !important; } }
/* ── 我的报告 buttons (BUG-1078) ────────────────────────────────────────────
Scoped to the report surfaces only: the list (`.report-center-*`), the
reader and its export drawer (`.personal-report-reader`), and the
state / error / not-found / loading screens (`.personal-report-state`).
The global tokens and components/ui/button.tsx stay as they are; other
pages keep the shared primary button (product decision 2026-09-28).
Why the shared solid button looked wrong: the unlayered `button { color:
inherit; }` near the top of this sheet beats Tailwind's layered
`text-primary-foreground`, so a solid <button> renders in the inherited ink —
#1d1d1f on #a9583e in the light theme (3.33:1, the reported 「黑字深红底」)
and #f2f0ea on #d78064 in the dark theme (2.57:1). The unlayered
`button:disabled { opacity: .45 }` then fades the whole thing.
- Solid (`variant="default"`): light = #fbfaf7 on #a9583e (4.85:1);
dark = --color-ink #f2f0ea on --color-action-on-dark #8f4a33 (5.77:1),
hover mixes 6% ink in (5.1:1).
- Disabled, any variant: a neutral grey fill with tertiary ink, opacity 1.
The solid variant is matched as `[class~="bg-primary"]`, not `.bg-primary`:
this sheet does not define Tailwind utilities, and class-name-definition-
contract reads every `.name` here as one of our own class families. */
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action);
/* Same as the shared `hover:bg-primary/80`, so the light theme is unchanged. */
--report-button-fill-hover: color-mix(in srgb, var(--color-action) 80%, transparent);
--report-button-ink: var(--color-on-dark);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action-on-dark);
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
--report-button-ink: var(--color-ink);
}
}
:root[data-theme="dark"] :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
--report-button-fill: var(--color-action-on-dark);
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
--report-button-ink: var(--color-ink);
}
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"] {
background: var(--report-button-fill);
color: var(--report-button-ink);
}
@media (hover: hover) {
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"]:not(:disabled):hover {
background: var(--report-button-fill-hover);
}
}
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"]:disabled {
opacity: 1;
border-color: transparent;
background: var(--color-canvas-strong);
color: var(--color-ink-tertiary);
}
/* ============================================================
personal-report: unique block — editorial reader + A4 print
Only /reports/[reportId] uses these semantic classes. The report owns its
+19 -1
View File
@@ -1,4 +1,11 @@
export const AYANAMSA_VALUES = ["raman", "lahiri", "kp", "true_pushya"] as const;
export const AYANAMSA_VALUES = [
"raman",
"lahiri",
"kp",
"true_citra",
"true_pushya",
"fagan_bradley",
] as const;
export type AyanamsaName = (typeof AYANAMSA_VALUES)[number];
export const DEFAULT_AYANAMSA: AyanamsaName = "raman";
@@ -18,11 +25,21 @@ export const AYANAMSA_OPTIONS = [
label: "KP",
description: "克里希那穆提体系用的岁差。",
},
{
value: "true_citra",
label: "True Citra",
description: "以角宿一的实际位置定起点,和 Lahiri 只差约 1 角分。",
},
{
value: "true_pushya",
label: "True Pushya",
description: "True Pushya 岁差。",
},
{
value: "fagan_bradley",
label: "Fagan-Bradley",
description: "西方恒星占星常用,比 Lahiri 多约 0.9°。",
},
] as const satisfies ReadonlyArray<{
value: AyanamsaName;
label: string;
@@ -43,6 +60,7 @@ export function resolveAyanamsa(
if (typeof raw === "string") {
const key = raw.trim().toLowerCase().replace(/-/g, "_");
if (key === "krishnamurti" || key === "krishnamurti_paddhati") return "kp";
if (key === "true_chitra") return "true_citra";
if (isAyanamsaName(key)) return key;
}
return DEFAULT_AYANAMSA;
+2
View File
@@ -263,7 +263,9 @@ const AYANAMSA_SHORT_NAME: Readonly<Record<AyanamsaName, string>> = {
raman: "Raman",
lahiri: "Lahiri",
kp: "KP",
true_citra: "True Citra",
true_pushya: "True Pushya",
fagan_bradley: "Fagan-Bradley",
};
/**
@@ -0,0 +1,20 @@
-- Widen the household ayanamsa setting from four to six values
-- (TASK-chart-types-and-report-buttons-20260928 T6): True Chitra and
-- Fagan-Bradley join Raman, Lahiri, KP and True Pushya. Every value is one the
-- engine already supports (`scripts/ayanamsa_utils.py` AYANAMSA_MODES); Djwhal
-- Khul and Sassanian stay out on purpose.
--
-- Only the CHECK changes: no column, default, grant or row is touched, so the
-- migrate-then-deploy window is safe (the deployed code only writes the old
-- four). `chart_profiles.ayanamsa` has no CHECK; the people route writes the
-- typed column directly, so the JSON fallback trigger is not on the write path.
begin;
alter table public.profiles
drop constraint if exists profiles_ayanamsa_check;
alter table public.profiles
add constraint profiles_ayanamsa_check
check (ayanamsa in ('raman', 'lahiri', 'kp', 'true_citra', 'true_pushya', 'fagan_bradley'));
commit;
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { accountProfilePatchSchema } from "../src/lib/account-profile-patch.ts";
import { AYANAMSA_VALUES } from "../src/lib/ayanamsa.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts";
import { ephemerisAyanamsaNote } from "../src/lib/ephemeris-view.ts";
import { globalBirthProfileFromAccountRow } from "../src/lib/server-owned-birth-profile.ts";
// TASK-chart-types-and-report-buttons-20260928 T6. `resolveAyanamsa` falls
// back to Raman for anything it does not know, so each new value is walked
// through the paths that store it or send it to the engine. Fictional data only.
const NEW_VALUES = ["true_citra", "fagan_bradley"] as const;
test("the account PATCH accepts the six values and still rejects the ones not offered", () => {
for (const value of AYANAMSA_VALUES) {
assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: value }).success, true, value);
}
for (const value of ["djwhal_khul", "sassanian", "fagan", "yukteshwar"]) {
assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: value }).success, false, value);
}
});
test("the profiles CHECK is widened to exactly the six values by an additive migration", () => {
const sql = readFileSync(
new URL("../supabase/migrations/20260928010000_profile_ayanamsa_six.sql", import.meta.url),
"utf8",
);
assert.match(sql, /drop constraint if exists profiles_ayanamsa_check/);
assert.match(
sql,
/check \(ayanamsa in \('raman', 'lahiri', 'kp', 'true_citra', 'true_pushya', 'fagan_bradley'\)\)/,
);
assert.doesNotMatch(sql, /djwhal_khul|sassanian/);
assert.doesNotMatch(sql, /drop column|alter column|rename|set not null|update public\.|set default/i);
});
test("the account row keeps a new ayanamsa instead of turning it into Raman", () => {
for (const value of NEW_VALUES) {
const profile = globalBirthProfileFromAccountRow({
name: "虚构甲",
birth_date: "1990-01-01",
reported_birth_time: "08:00",
latitude: 22.3,
longitude: 114.1,
timezone_offset: 8,
ayanamsa: value,
});
assert.equal(profile.ayanamsa, value);
}
});
test("a saved person keeps a new ayanamsa on write and on read", () => {
for (const value of NEW_VALUES) {
const parsed = parseChartSubjectWrite({ name: "虚构乙", date: "1990-01-01", reportedTime: "08:00", ayanamsa: value });
assert.equal(parsed.ok, true);
if (!parsed.ok) continue;
assert.equal(parsed.write.columns.ayanamsa, value);
assert.equal(libraryProfileFromSubjectRow({ id: "p-1", name: "虚构乙", ayanamsa: value }).ayanamsa, value);
}
});
test("the chart page sends the new ayanamsa to the engine unchanged", async () => {
for (const value of NEW_VALUES) {
const bodies: Record<string, unknown>[] = [];
await assembleChartView({
userId: "user-1",
profile: {
date: "1990-01-01",
time: "08:00",
latitude: 22.3,
longitude: 114.1,
timezoneOffset: 8,
ayanamsa: value,
},
postEngine: async (path, body) => {
bodies.push(body);
return { status: "http_error", path, elapsedMs: 0, httpStatus: 500 };
},
asOf: "2026-09-28",
});
assert.equal(bodies[0]?.ayanamsa, value);
}
});
test("the ephemeris note names the new ayanamsa rather than calling it unknown", () => {
assert.equal(ephemerisAyanamsaNote("true_citra"), "五要素按 Lahiri 岁差算,和星盘页的 True Citra 不同。");
assert.equal(ephemerisAyanamsaNote("fagan_bradley"), "五要素按 Lahiri 岁差算,和星盘页的 Fagan-Bradley 不同。");
});
test("the report generator already has a display name for both new values", () => {
const source = readFileSync(new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8");
assert.match(source, /\["true_citra", "True Citra"\]/);
assert.match(source, /\["fagan_bradley", "Fagan-Bradley"\]/);
});
+29 -2
View File
@@ -9,9 +9,9 @@ import {
resolveAyanamsa,
} from "../src/lib/ayanamsa.ts";
test("resolveAyanamsa defaults to Raman and accepts the four product values", () => {
test("resolveAyanamsa defaults to Raman and accepts the six product values", () => {
assert.equal(DEFAULT_AYANAMSA, "raman");
assert.deepEqual([...AYANAMSA_VALUES], ["raman", "lahiri", "kp", "true_pushya"]);
assert.deepEqual([...AYANAMSA_VALUES], ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]);
assert.equal(resolveAyanamsa(), "raman");
assert.equal(resolveAyanamsa(null), "raman");
assert.equal(resolveAyanamsa({}), "raman");
@@ -20,8 +20,35 @@ test("resolveAyanamsa defaults to Raman and accepts the four product values", ()
assert.equal(resolveAyanamsa({ ayanamsa: "krishnamurti" }), "kp");
assert.equal(resolveAyanamsa({ ayanamsa: "true-pushya" }), "true_pushya");
assert.equal(resolveAyanamsa({ ayanamsa: "fagan" }), "raman");
assert.equal(resolveAyanamsa({ ayanamsa: "djwhal_khul" }), "raman");
assert.equal(isAyanamsaName("raman"), true);
assert.equal(isAyanamsaName("sidereal"), false);
assert.match(AYANAMSA_SWITCH_HINT, /咨询、报告、星盘库和每日星语/);
assert.match(AYANAMSA_SWITCH_HINT, /生时校正目前仍固定按 Raman/);
});
// TASK-chart-types-and-report-buttons-20260928 T6: every offered value must
// come back unchanged — `resolveAyanamsa` silently falls back to Raman for an
// unknown value, so a value missing from AYANAMSA_VALUES would quietly turn a
// user's True Citra chart into a Raman one on every path.
test("each of the six ayanamsa values resolves to itself, never to the Raman fallback", () => {
for (const value of ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]) {
assert.equal(resolveAyanamsa({ ayanamsa: value }), value);
assert.equal(resolveAyanamsa({ ayanamsa: value.toUpperCase().replace(/_/g, "-") }), value);
}
// Same alias the engine accepts (`scripts/ayanamsa_utils.py`).
assert.equal(resolveAyanamsa({ ayanamsa: "true_chitra" }), "true_citra");
assert.equal(resolveAyanamsa({ ayanamsa: "True-Chitra" }), "true_citra");
});
test("the settings list is exactly the six values in product order, only engine-supported ones", async () => {
const { AYANAMSA_OPTIONS } = await import("../src/lib/ayanamsa.ts");
assert.deepEqual(AYANAMSA_OPTIONS.map((option) => option.value), [...AYANAMSA_VALUES]);
assert.deepEqual(AYANAMSA_OPTIONS.map((option) => option.label), [
"Raman(默认)", "Lahiri", "KP", "True Citra", "True Pushya", "Fagan-Bradley",
]);
const { readFileSync } = await import("node:fs");
const engine = readFileSync(new URL("../../scripts/ayanamsa_utils.py", import.meta.url), "utf8");
for (const value of AYANAMSA_VALUES) assert.match(engine, new RegExp(`'${value}': getattr\\(swe`));
for (const option of AYANAMSA_OPTIONS) assert.ok(option.description.length > 0);
});
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// TASK-chart-types-and-report-buttons-20260928 T6. Fictional users only.
// Runs in the gate's DB job (needs Docker).
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("profiles.ayanamsa accepts the six offered values and rejects the rest", {
skip: docker ? false : "docker unavailable",
}, () => {
const fixture = startPostgresFixture();
try {
applyMigrations(fixture);
const owner = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional ayanamsa owner', '${owner}@example.invalid', true, now());`);
assert.equal(fixture.psql(`select ayanamsa from public.profiles where id = '${owner}'`), "raman");
// The account PATCH writes through service_role.
for (const value of ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]) {
fixture.psqlAs(...SERVICE, `set role service_role; update public.profiles set ayanamsa = '${value}' where id = '${owner}';`);
assert.equal(fixture.psql(`select ayanamsa from public.profiles where id = '${owner}'`), value);
}
for (const value of ["djwhal_khul", "sassanian", "fagan"]) {
assert.throws(
() => fixture.psql(`update public.profiles set ayanamsa = '${value}' where id = '${owner}'`),
/profiles_ayanamsa_check/,
);
}
} finally {
fixture.stop();
}
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// BUG-1078 (TASK-chart-types-and-report-buttons-20260928 T7). The report
// surfaces get their own button colours; the shared button and the theme
// tokens must stay exactly as other pages use them.
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonSource = readFileSync(new URL("../src/components/ui/button.tsx", import.meta.url), "utf8");
const SCOPE = ":is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader)";
function blockTokens(opener: string): Map<string, string> {
const start = css.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
const body = css.slice(start, css.indexOf("\n}", start));
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)].map((hit) => [hit[1], hit[2].trim()]));
}
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
test("the shared button keeps its variants, disabled fade and data-slot hook", () => {
assert.match(buttonSource, /default: "bg-primary text-primary-foreground hover:bg-primary\/80"/);
assert.match(buttonSource, /disabled:pointer-events-none disabled:opacity-50/);
assert.match(buttonSource, /data-slot="button"/);
});
test("the theme tokens the fix reads are unchanged", () => {
const light = blockTokens(":root {\n color-scheme: light;");
const preferred = blockTokens(':root:not([data-theme="light"]) {');
const pinned = blockTokens(':root[data-theme="dark"] {');
assert.equal(light.get("color-action"), "#a9583e");
assert.equal(light.get("color-on-dark"), "#fbfaf7");
assert.equal(light.get("color-action-on-dark"), "#d78064");
for (const dark of [preferred, pinned]) {
assert.equal(dark.get("color-action"), "#d78064");
assert.equal(dark.get("color-on-dark"), "#241f1c");
assert.equal(dark.get("color-action-on-dark"), "#8f4a33");
assert.equal(dark.get("color-ink"), "#f2f0ea");
}
});
test("button overrides exist only inside the report scope", () => {
const rules = [...css.matchAll(/([^{}]*\[data-slot="button"\][^{}]*)\{/g)].map((hit) => hit[1].trim());
assert.ok(rules.length >= 3, "solid, hover and disabled rules are present");
assert.ok(rules.some((selector) => selector.endsWith('[data-slot="button"][class~="bg-primary"]')), "solid rule matches the default variant");
// Comments stripped, as class-name-definition-contract does.
assert.doesNotMatch(css.replace(/\/\*[\s\S]*?\*\//g, " "), /\.bg-primary\b/, "the sheet must not define the Tailwind utility itself");
for (const selector of rules) assert.ok(selector.startsWith(SCOPE), `unscoped button rule: ${selector}`);
assert.match(css, new RegExp(`${SCOPE.replace(/[().]/g, "\\$&")} \\[data-slot="button"\\]:disabled \\{\\n opacity: 1;`));
// Both dark paths (OS preference and pinned) switch the fill.
assert.equal(css.split(`:root:not([data-theme="light"]) ${SCOPE}`).length - 1, 1);
assert.equal(css.split(`:root[data-theme="dark"] ${SCOPE}`).length - 1, 1);
});
test("solid report buttons read at least 4.5:1 in both themes, up from inherited ink", () => {
// Before: the global unlayered `button { color: inherit; }` beat
// `text-primary-foreground`, so the label was the page ink.
assert.ok(contrast("#1d1d1f", "#a9583e") < 4.5, "light before: near-black on clay");
assert.ok(contrast("#f2f0ea", "#d78064") < 4.5, "dark before: ink on lifted coral");
assert.ok(contrast("#fbfaf7", "#a9583e") >= 4.5, "light after");
assert.ok(contrast("#f2f0ea", "#8f4a33") >= 4.5, "dark after");
// The override must set `color` itself, or the global inherit wins again.
const solid = css.match(/\[data-slot="button"\]\[class~="bg-primary"\] \{([^}]*)\}/);
assert.ok(solid);
assert.match(solid[1], /color: var\(--report-button-ink\);/);
assert.match(css, /\nbutton \{ color: inherit; \}/, "the global rule this works around is unchanged");
});
test("the selected export preset no longer uses the dark red-brown action-soft fill", () => {
const rule = css.match(/\.report-export-presets \[aria-pressed="true"\] \{([^}]*)\}/);
assert.ok(rule);
assert.doesNotMatch(rule[1], /--color-action-soft|--color-action\b/);
assert.match(rule[1], /--color-ink/);
});