Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) d3be9491ee 组件库 K0:换上 Motion 底座和 shadcn 零件,页面还没换(#104)。
Independent Staging Quality Gate / validate (push) Successful in 20m4s
Independent Staging Quality Gate / publish (push) Failing after 7m48s
去掉 GSAP。对话入场改为按需加载的 Motion,仍是 160 毫秒,仍尊重减少动态效果。
用 shadcn@4.21.4 补上任务书列出的组件,不覆盖已有的 8 个。设计令牌映射到 shadcn 变量。
手写控件检查和只减不增的白名单。Motion 预设与四个 store 只是骨架,没有接到页面上。

自测:tsc --noEmit 退出码 0;npm run lint 0 error(122 条原有 warning);
定向测试 37 项、fail 0、cancelled 0;next build 后 / 仍是 Static;
首屏 JS gzip 578349 → 578435(+86 字节,+0.01%)。

Co-Authored-By: Grok (agent loop)
2026-10-24 15:37:29 +08:00
Jesse_ChenandClaude Opus 5.5 0f66d92d4d docs(tasks): #93 fix brief round 2 (product chose Grok)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-24 10:27:10 +08:00
Jesse_ChenandClaude Opus 5.5 0b8fcdb842 merge full-data parity (二) research timing into staging (issue #92)
Independent Staging Quality Gate / validate (push) Successful in 18m33s
Independent Staging Quality Gate / publish (push) Successful in 4m53s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-24 00:58:24 +08:00
Jesse_ChenandClaude Opus 5.5 4d0e398ab6 docs(tasks): #93 fix brief (agent loop, first rejection)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-23 23:04:36 +08:00
52 changed files with 5354 additions and 111 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-10-24 — 组件库底座:先备好零件,页面还没换
- 对话入场从 GSAP 换成 Motion,仍是 160 毫秒,仍看系统「减少动态效果」,仍是用到才加载。
- 补上 shadcn 的基础组件和颜色别名。现有按钮、日历、弹出层、选择、侧栏、提示、页签、多行输入没有重装。
- 新的手写按钮、弹窗、菜单、用字符当图标,会在检查里报错。星盘和星图的绘制不在此列。已经这样写的文件记在白名单里,这份名单以后只减不增。
- 侧栏、对话、星盘页面这一张还没换。Skill 版本不变(BUG-1430)。
## 2026-10-23 — 研究口径的推运会标出和 PyJHora 已知的差别
- 完整数据版研究章里,和 PyJHora 对过、顺序或年数不同的方法,会在该方法标题下写明差在哪一类。
+16
View File
@@ -19366,3 +19366,19 @@
- 复发自:无
- 修复版本:分支 `codex/full-data-parity-2-20261021`。
## BUG-1430 | 组件库底座与手写控件检查
- 状态:enhancement
- 首次发现:2026-10-24
- 最近更新:2026-10-24
- 影响面:前端依赖、对话消息入场、设计令牌别名、新增的 shadcn 组件、手写控件检查。侧栏、对话页、星盘页的控件这一张没有换。
- 用户现象:页面上的控件和文案与改前相同。对话新出现的一行仍是一次短入场;系统开了「减少动态效果」时不做这次入场。
- 触发条件:打开首页或已有对话。这一张不改变请求、扣点或落库。
- 根因:不是缺陷。产品在组件库迁移里定了换用 Motion、shadcn、按需加载和 lint 拦截(D1、D2、D3、D4、D7)。这一张只加底座。
- 修复:去掉 GSAP。消息入场改为按需加载的 Motion,时长仍是 160 毫秒。用固定版本的 shadcn CLI 补上任务书列出的组件,不重装已有的 8 个。设计令牌映射到 shadcn 变量,深色浅色都跟着现有色板。Motion 预设和四个 store 只是骨架,没有接到页面上。`src/components/ui` 以外的新手写控件会报错;迁移前已有的文件在白名单里,测试锁住这份名单不能变长。星盘与星图的 SVG 绘制路径豁免图标规则。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-0-base-20261023.md`。没有浏览器登录态,不把界面截图写成通过。
- 防复发:`frontend/tests/ui-kit-whitelist.test.ts` 锁住白名单只减不增,并检查新文件里的 `<button>` 会报错。`frontend/tests/chat-bundle-splitting-contract.test.ts` 与 `frontend/tests/session-conversation-layout.test.ts` 锁住入场仍是按需加载、160 毫秒、尊重减少动态效果。`frontend/tests/ui-kit-tokens.test.ts` 锁住 shadcn 样式导入和颜色别名。
- 相关记录:issue #104。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K0。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-0-base-20261023`。
@@ -0,0 +1,60 @@
# PROGRESS · 组件库 K0 底座与规矩(2026-10-24)
工单 #104。分支 `codex/ui-kit-0-base-20261023`。基线 `origin/staging` `0f66d92d4d6545972202f209c63c87959d44b87e`。只做任务书「K0 底座与规矩」。侧栏、对话页、星盘页都还没换。
## 结论
零件和检查规矩已经放进仓库。打开网站时看见的按钮、侧栏、对话和星盘,这一张没有改。对话里新出现的一行仍是一次 160 毫秒的入场;系统开了「减少动态效果」时不做这次入场。
## 做了什么
- 去掉 `gsap`、`@gsap/react`。加上并钉死:`motion@14.1.0`、`react-resizable-panels@4.14.3`、`@dnd-kit/core@6.3.1`、`@dnd-kit/sortable@10.0.0`、`@dnd-kit/utilities@3.2.2`、`zustand@5.0.15`、`shadcn@4.21.4`。这些版本都在 2026-10-10 之前发布。
- `npx shadcn@4.21.4 add` 加了任务书列出的全部组件。没有被 CLI 拒绝的名字。已有的 button、calendar、popover、select、sidebar、sonner、tabs、textarea 没有重装、没有覆盖(CLI 问过要不要覆盖 button 和 textarea,回答是不覆盖)。
- `globals.css` 第二行是 `@import "shadcn/tailwind.css";`。浅色 `:root` 上的 shadcn 变量指向现有色板;深色跟着这些色板变量走,没有另写一套色。
- 消息入场改成按需 `import("motion")`。模块还没到就不补播。`LazyMotion` 和 `MotionConfig reducedMotion="user"` 写在 `src/lib/motion/provider.tsx`,这一张没有挂到页面上。
- 动效规范表每一档写成 `src/lib/motion/presets.ts` 里的数据。没有另写动画,也没有接到页面上。现在真正在播的仍是消息入场 160 毫秒,不是表里「发送消息」那一档的 260 毫秒。
- `src/stores/` 有会话、人物、账户、界面四个空骨架。`Home()` 的 `useState` / `useRef` 没有迁,数量没有增加。
- `src/components/ui` 以外,新手写 `button` / `dialog` / `select`、勾选和单选、指定的 role、`createPortal`、用字符当图标,lint 会报错。星盘 `personal-report/vedic-chart-svg.tsx` 和星图 `chart-page/western-wheel-svg.tsx` 只豁免 SVG 里的图标字符,按钮仍然报错。迁移前已有的 62 个文件在白名单里。
## 断言改动(原值 / 新值 / 原因)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-bundle-splitting-contract.test.ts` | 静态图里没有 gsap;动态 `import("gsap")`;`gsap.matchMedia()`;`if (!gsapCore)`;`prefetchOnIdle(loadGsap)` | 没有 `gsap` / `@gsap/react`;没有静态 `from "motion"`;动态 `import("motion")`;`matchMedia("(prefers-reduced-motion: no-preference)")`;`if (!messageMotion)`;`useEntryEffect`;`prefetchOnIdle(loadMessageMotion)` | 入场从 GSAP 换成 Motion,仍按需加载,模块没到不补播。时长 0.16、曲线、clearProps 字符串不变 |
| `session-conversation-layout.test.ts` | `gsap.matchMedia()` | `matchMedia("(prefers-reduced-motion: no-preference)")` | 同上。时长、曲线、clearProps 的断言没有放宽 |
| `design-token-contract.test.ts` | `join(directory.pathname, entry.name)` | `join(fileURLToPath(directory), entry.name)` | Windows 上 `URL.pathname` 以 `/G:` 开头,`path.join` 会拼成 `G:\G:\...`,合同还没读到源码就失败。颜色断言没有改 |
其余既有断言没有改。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 检查 | 在 `frontend` 下 `npm run lint` | 0 error,122 warning(都是改前就有的,没有修) |
| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 下面 10 个文件 | 37 项,pass 37,fail 0,cancelled 0,skipped 0,退出码 0 |
| 构建 | 在 `frontend` 下 `npm run build` | 成功。`/` 是 `○` Static。79 页 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加 | 改前 578349 字节;改后 578435 字节;+86 字节;+0.01%。在 ±2% 以内。33 个文件,没有缺失 |
| 依赖 | `npm ls` 上述新包 `--depth=0` | 都能看到。`npm ls gsap @gsap/react` 是空的(退出码 1,表示没装) |
定向测试文件:`chat-bundle-splitting-contract`、`session-conversation-layout`、`motion-presets`、`ui-kit-stores`、`ui-kit-tokens`、`ui-kit-whitelist`、`design-token-contract`、`dark-theme-contract`、`report-fact-table-print`、`home-shell-growth-contract`。
首屏这 33 个 JS 里没有 gsap、没有 Motion 运行时、没有 dnd-kit、zustand、react-resizable-panels,也没有 shadcn 的命令行程序。构建日志里 skill-package-registry 的 5 条 Turbopack 警告是改前就有的,没有动。
白名单探针没有留在仓库里。测试用 ESLint 的 `lintText` 假装有一个 `src/components/ui-kit-k0-probe.tsx`,确认 `<button>` 会报错,`src/components/ui/` 里不报,星盘和星图的 SVG 文本不报、旁边的按钮仍报。
## 偏离
- CLI 额外依赖了 `cn`。它当时装的 `0.5.0` 是 2026-10-16 发布的,不满两周。改钉 `cn@0.4.0`(2026-09-22)。新组件只调用 `cn()`。
- `@theme` 里的 `--color-border`、`--color-input` 仍是浅色十六进制 `#d8d6cf`、`#b8b5ad`。若改成 `var()`,现有的描边按钮、弹出层、多行输入在深色下会被重新上色。语义变量 `--border`、`--input` 跟着主题走,给以后的组件用。Tailwind 的 `bg-primary` 仍锁在文字阶 `--color-action`;新组件读的 `--primary` 是按钮填充 `--color-action-fill`。深色下这两个颜色不一样(`#d78064` 和 `#8f4a33`)。
- 没有组件名被 CLI 拒绝。
## 环境缺口
- 没有浏览器,也没有登录态。K0 不应改可见控件;真机对照留给验收。本机没有打开未登录首页做目视,不能写成「已经看过没有新的等待动画」。
- 没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑也没有宣称 `test:db`。K0 不动表。
## 记录
- BUG-1430,状态 `enhancement`。不是缺陷。
- `CHANGELOG.md`、`frontend/DESIGN.md`、`docs/tasks/README.md`(这一行标成「K0 待验收(K1–K7 仍待领取)」,没有写成已合入)。
+4 -3
View File
@@ -39,7 +39,7 @@
| --- | --- | --- | --- | --- |
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0 待验收(K1–K7 仍待领取) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022` | (一)已合入 staging;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
@@ -523,11 +523,12 @@
| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 已合入 staging | `codex/report-english-han-leak-20261021` |
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` |
| `TASK-full-data-parity-1-20261021.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | #91 完整数据版补齐(一):K1–K3 已做;K4 把年运新字段放到原键之后,Yogini 缺完整年数时不再整段失败(BUG-1425、BUG-1426) | 已合入 staging | `codex/full-data-parity-1-20261021` |
| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 待验收 | `codex/full-data-parity-2-20261021` |
| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 已用 10 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 待验收 | `codex/full-data-parity-2-20261021` |
| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 已合入 staging | `codex/full-data-parity-2-20261021` |
| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 已用 10 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 已合入 staging | `codex/full-data-parity-2-20261021` |
| `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` |
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 1 次打回**:功能、隔离、速度都过(Linux 约 20 秒,worker 约 60 MB);结果指纹排除本章致重算对不上(违反 D2)、源码合同测试没跟改、自己的新测试与 deploy README 打架 | 待执行 | `codex/full-data-parity-3-20261022` |
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` |
| `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` |
| `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` |
@@ -0,0 +1,53 @@
# 修复单:完整数据版补齐(三)独立年度方法参考(issue #93,第 1 次打回,2026-10-23)
- 被验提交:`c644881a3`(分支 `codex/full-data-parity-3-20261022`)
- 原任务书:`TASK-full-data-parity-3-20261022.md`
- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase)。BUG 编号开工时核对最大号 +1。
## 验收结论(Claude,Linux,PyJHora 4.8.7 已装,不屏蔽)
做对了的:PyJHora 只在子进程 worker 里导入,三张虚构盘生成后父进程 `sys.modules` 里没有 `jhora`;S1~S3 开关保持「未装」时的老结果;没碰冻结打分文件;中英各有「来源:PyJHora / Source: PyJHora」;英文汉字 0;输入是当地时间与正确年份(D6 默认)。
| 项 | 结果 |
| --- | --- |
| 生成时长(Linux,0 / 1 / 9 号盘,排盘 + 资料包 + 先英文后中文,PyJHora 真跑) | 22.0 / 19.8 / 19.7 秒 |
| worker 峰值内存(0.5 秒采样) | 约 60 MB |
| 本章内容(9 号盘中文) | 3 年 × 4 张表,717 行;「未获得结果」6 处 |
| 前端全量、类型检查、构建 | 与 staging 一致 |
| 全量 pytest | 比 staging 基线多 5 条;2 条单独重跑通过(本机负载),**3 条单独跑仍失败**,见下 |
## P1 · 结果指纹:别把本章排除在外(必修)
`jyotish_engine._bind_result_without_annual_method_reference` 在绑定前把 `pyjhora_annual_method_reference` 摘掉、绑定后再放回。结果是:交付出去的资料包**里有**这一章,`result_hash` 却**没算**它;任何人按 `hash_bound_result` 重算都对不上——`tests/test_report_reference_perf.py::test_fast_hash_matches_the_legacy_algorithm_on_a_real_packet` 因此失败(BUG-693 / 694 的出处绑定就是靠「重算能对上」)。`calculation_profile_contract.py` 的 D2 也写明只有三类可以排除,本单没有授权加第四类。
修法:删掉 `_bind_result_without_annual_method_reference`,恢复原来的 `bind_result_to_profile(delivered, profile)`;本章照常算进指纹。原任务书「切掉本章后与 T1 完成时的输出逐字节相同」那条对照,比较前把**报告引用号 / 结果指纹那一行**按正则抹成同一个占位符,再逐字节比较(引用号本来就该随结果变);在 PROGRESS 写明抹掉的是哪一行。
验收:上面那条测试通过;`tests/test_calculation_profile_contract.py` 全过;「切掉本章逐字节相同」的对照仍通过(抹掉引用号行之后)。
## P2 · 源码文本合同测试没跟着改(必修,P1 修完多半自动消失)
`tests/test_calculation_profile_contract.py::test_professional_reference_packet_rebinds_sanitized_delivery` 在 `build_professional_report_reference_packet` 源码里找 `bind_result_to_profile(delivered, profile)`,被包进新函数后找不到(`ValueError: substring not found`)。按 P1 恢复原调用即可;若仍要改这个函数,按 AGENTS §7.8 同轮改测试并写三栏。
## P3 · 自己的新测试和自己写的部署说明打架(必修,小)
`tests/test_pyjhora_annual_method_reference.py::test_deploy_does_not_set_year_lord_pyjhora_reference` 扫 `deploy/` 下所有文件不得出现 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`,而本单在 `deploy/README.md` 写了「Do not set `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`」。二选一:README 改成不点名(例如「不要打开年主的 PyJHora 对照开关」),或测试只扫 compose / env / workflow 这类配置文件、跳过 `*.md`。推荐前者。
## 顺手(小,不阻塞)
- `build_professional_report_reference_packet` 对标准版和 JSON 输出也会起一次 PyJHora 子进程(本章只在完整数据版渲染)。线上目前只请求完整数据版,影响不大;顺手改成只在 `edition == full_data` 时调用(放在 `professional_report_reference._assemble_professional_report_reference` 判定版本之后),并加一条测试:标准版不启动 worker。
- 进度里写「超时放宽到 120 秒」。Linux 上整份约 20 秒,120 秒只是上限,保留可以;但请在 PROGRESS 写明:前端总等待是 180 秒,worker 超时 + 其余生成时间不得超过它。
## 交回前必跑
- `tests/test_pyjhora_annual_method_reference.py`、`tests/test_calculation_profile_contract.py`、`tests/test_report_reference_perf.py` 与原任务书第 7 节列出的文件。
- 200 张英文扫描(屏蔽 PyJHora)。
- 全量 pytest、快速门、前端由 Claude 复验。
## 第 2 轮(2026-10-24,产品选 B:交回 Grok)
被验提交 `c899e3650`:P1~P3 与「标准版不启动 worker」都已改对,快速门通过。还差两条测试:给 `build_professional_report_reference_packet` 加了 `include_annual_method_reference` 参数后,有 4 个测试的替身函数补了 `**_kwargs`,漏了下面 2 个,单独跑都报 `TypeError: ... got an unexpected keyword argument 'include_annual_method_reference'`:
- `tests/test_reader_appendix_language.py::test_public_boundary_only_leaves_internal_renderer_and_json_unchanged`
- `tests/test_report_density_facts.py::test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean`
修法与那 4 处相同:替身(lambda)加上 `**_kwargs`,不改业务代码、不改断言。交回前 `grep -rn "build_professional_report_reference_packet" tests/` 把其余替身也过一遍,并跑这两条加上上面列出的文件。
+25 -1
View File
@@ -156,6 +156,28 @@ outside `.personal-report-*` and `.report-center-*` may use them.
Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of the interface remains light. Dark ink is punctuation, never a page-scale surface. No raw color may appear in UI styles outside these tokens and their documented alpha mixes.
### shadcn aliases (2026-10-24, K0)
`globals.css` imports `shadcn/tailwind.css` on the line after Tailwind. Semantic variables on the light `:root` point at the palette above. Dark mode follows because those aliases read `--color-*` tokens the dark blocks already override. The aliases are not copied into the dark blocks.
| shadcn | Points at |
|---|---|
| `--background` / `--foreground` | `--color-canvas` / `--color-ink` |
| `--card`, `--secondary`, `--muted` | `--color-canvas-muted` |
| `--primary` / `--primary-foreground` | `--color-action-fill` / `--color-on-action-fill` |
| `--accent` | `--color-selected` |
| `--destructive` | `--color-danger` |
| `--border` / `--input` | `--color-border` / `--color-border-strong` |
| `--ring` | `--color-focus` |
| `--sidebar` | `--color-sidebar` |
| `--radius` | `12px`, the same length as `--radius-lg`. `--radius-sm` and `--radius-md` stay 8px |
`@theme` `--color-border` and `--color-input` stay the light hexes `#d8d6cf` and `#b8b5ad`. `border-border` and `border-input` already paint the outline button, popover, and textarea. Pointing those theme values at `var()` would restyle them in dark mode. Semantic `--border` and `--input` do follow the theme, for components added from here on.
Tailwind `bg-primary` stays the locked alias `--color-action` (text rank). Semantic `--primary`, which new shadcn components read, is `--color-action-fill`. In dark mode those are different colors (`#d78064` and `#8f4a33`).
New controls live in `src/components/ui` and are the shadcn base-nova components. Outside that folder, lint rejects a new native `button`, `dialog`, `select`, checkbox or radio `input`, the dialog/menu/tab/listbox roles, `createPortal`, and a short list of characters used as icons. Chart drawing SVGs are exempt from the icon rule only: `personal-report/vedic-chart-svg.tsx` and `chart-page/western-wheel-svg.tsx`. Buttons in those files are still rejected. Files that already did this before K0 are listed in `frontend/eslint/ui-primitive-whitelist.json`. That list may shrink and may not grow.
### Where the action color appears
The accent has **two ranks**, because one hex cannot do both jobs. Claude coral
@@ -504,7 +526,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Typography:** assistant body `--type-body-md` (16px) with serif subheadings; user body 14px.
- **Tables:** three-column technique audit tables keep 状态 on one line. Below 768px they stack each row as title + status, then the note, instead of squeezing 已执行 into a vertical glyph column.
- **Follow-up:** the latest settled consultation answer may offer two or three grounded next questions under that answer. Clicking one sends it in the current session. The composer never hosts suggestion chips. If the answer does not support a grounded continuation, nothing is shown.
- **Motion:** a new row enters once, through the GSAP tween in `chat-message-row.tsx` at the 160ms Message duration; there is no CSS entrance keyframe beside it. The trailing assistant reply is one component (`LatestAssistantEntry`) from its first streamed token through settlement, so settling never remounts it and never replays the entrance.
- **Motion:** a new row enters once, through the Motion tween in `chat-message-row.tsx` at the 160ms Message duration; there is no CSS entrance keyframe beside it. Motion loads with `import("motion")` only when the reader has not asked for reduced motion, and a row does not replay the entrance if that module has not arrived yet. The trailing assistant reply is one component (`LatestAssistantEntry`) from its first streamed token through settlement, so settling never remounts it and never replays the entrance. The 260ms send-message preset is data for a later issue and does not change this 160ms entrance.
#### Streaming states
@@ -881,6 +903,8 @@ Looping animation is covered separately in section 9.
Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motion disables non-essential animation. Motion communicates state; decorative looping is limited to an active loading state.
K0 stores the later site-motion rows as data in `src/lib/motion/presets.ts` (duration, curve, direction) and wraps `LazyMotion` plus `MotionConfig reducedMotion="user"` in `src/lib/motion/provider.tsx`. No page mounts that provider yet, and the presets are not wired. Sidebar and other spatial changes stay instant. The one live entrance is still the message row at 160ms.
## 7. Depth & Surface
Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals.
+7
View File
@@ -1,10 +1,17 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import uiPrimitives from "./eslint/no-handwritten-control.mjs";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
files: ["src/**/*.{tsx,jsx}"],
ignores: ["src/components/ui/**"],
plugins: { "jyotisha-ui": uiPrimitives },
rules: { "jyotisha-ui/no-handwritten-control": "error" },
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
+147
View File
@@ -0,0 +1,147 @@
import { readFileSync } from "node:fs";
/**
* Blocks new handwritten controls outside src/components/ui.
* Icon glyphs inside chart SVG drawings are exempt:
* - src/components/personal-report/vedic-chart-svg.tsx (星盘)
* - src/components/chart-page/western-wheel-svg.tsx (星图)
* Buttons, menus, roles, and createPortal in those files are still reported.
*/
const ICON_GLYPH = /[☰↑↓←→×✕✖✓✔⋯▾▸▴‹›+]/u;
const ROLES = new Set(["dialog", "menu", "menuitem", "tablist", "tab", "listbox"]);
const SVG_DRAWING = new Set([
"src/components/personal-report/vedic-chart-svg.tsx",
"src/components/chart-page/western-wheel-svg.tsx",
]);
const whitelist = new Set(
JSON.parse(readFileSync(new URL("./ui-primitive-whitelist.json", import.meta.url), "utf8")),
);
function repoPath(filename) {
const normalized = filename.replaceAll("\\", "/");
const marker = "/src/";
const index = normalized.lastIndexOf(marker);
if (index === -1) return normalized;
return normalized.slice(index + 1);
}
function elementName(opening) {
if (!opening || opening.type !== "JSXOpeningElement") return null;
return opening.name.type === "JSXIdentifier" ? opening.name.name : null;
}
function staticValue(attribute) {
if (!attribute?.value) return null;
if (attribute.value.type === "Literal") return attribute.value.value;
const expression = attribute.value.expression;
if (attribute.value.type === "JSXExpressionContainer" && expression?.type === "Literal") {
return expression.value;
}
return null;
}
function attribute(opening, name) {
return opening.attributes.find((item) => (
item.type === "JSXAttribute" && item.name?.type === "JSXIdentifier" && item.name.name === name
));
}
function insideSvg(node) {
let current = node.parent;
while (current) {
if (current.type === "JSXElement" && elementName(current.openingElement) === "svg") return true;
current = current.parent;
}
return false;
}
function glyphIn(value) {
return typeof value === "string" && ICON_GLYPH.test(value);
}
const rule = {
meta: {
type: "problem",
schema: [],
messages: {
element: "Handwritten <{{name}}> is outside src/components/ui. Use the shadcn component.",
input: "Handwritten checkbox or radio input is outside src/components/ui. Use Checkbox or RadioGroup.",
role: "role=\"{{role}}\" is outside src/components/ui. Use the shadcn component.",
portal: "createPortal is outside src/components/ui. Use Dialog, Sheet, or Drawer.",
icon: "Character {{sample}} is standing in for an icon. Use Lucide.",
},
},
create(context) {
const rel = repoPath(context.filename ?? context.getFilename());
if (whitelist.has(rel)) return {};
const iconExempt = SVG_DRAWING.has(rel);
function reportIcon(node, value) {
if (!glyphIn(value)) return;
if (iconExempt && insideSvg(node)) return;
const sample = value.match(ICON_GLYPH)?.[0] ?? value;
context.report({ node, messageId: "icon", data: { sample } });
}
function walkText(node) {
if (!node) return;
if (node.type === "Literal") reportIcon(node, node.value);
else if (node.type === "TemplateLiteral") {
for (const part of node.quasis) reportIcon(part, part.value.cooked);
} else if (node.type === "ConditionalExpression") {
walkText(node.consequent);
walkText(node.alternate);
} else if (node.type === "LogicalExpression" || node.type === "BinaryExpression") {
walkText(node.left);
walkText(node.right);
}
}
return {
JSXOpeningElement(node) {
const name = elementName(node);
if (name === "button" || name === "dialog" || name === "select") {
context.report({ node, messageId: "element", data: { name } });
}
if (name === "input") {
const type = staticValue(attribute(node, "type"));
if (type === "checkbox" || type === "radio") {
context.report({ node, messageId: "input" });
}
}
const role = staticValue(attribute(node, "role"));
if (typeof role === "string" && ROLES.has(role)) {
context.report({ node, messageId: "role", data: { role } });
}
},
JSXText(node) {
reportIcon(node, node.value);
},
JSXExpressionContainer(node) {
if (node.parent?.type === "JSXAttribute") return;
walkText(node.expression);
},
CallExpression(node) {
const callee = node.callee;
const name = callee.type === "Identifier"
? callee.name
: callee.type === "MemberExpression" && callee.property.type === "Identifier"
? callee.property.name
: null;
if (name === "createPortal") context.report({ node, messageId: "portal" });
},
};
},
};
const plugin = {
rules: {
"no-handwritten-control": rule,
},
};
export default plugin;
@@ -0,0 +1,64 @@
[
"src/app/(app)/page.tsx",
"src/app/error.tsx",
"src/app/global-error.tsx",
"src/components/account-deletion-gate.tsx",
"src/components/account-deletion-section.tsx",
"src/components/account-dialog-overlay.tsx",
"src/components/ayanamsa-preference-field.tsx",
"src/components/billing-panel.tsx",
"src/components/birth-place-picker.tsx",
"src/components/birth-sky/birth-sky-converge.tsx",
"src/components/birth-sky/birth-sky-replay.tsx",
"src/components/birth-time-candidate-result.tsx",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-evidence-draft-card.tsx",
"src/components/birth-time-guide-turn.tsx",
"src/components/birth-time-intake.tsx",
"src/components/birth-time-legacy-rectification.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/chart-page/chart-birth-editor.tsx",
"src/components/chart-page/chart-dasha-tab.tsx",
"src/components/chart-page/chart-page-view.tsx",
"src/components/chart-page/chart-type-picker.tsx",
"src/components/chart-page/chart-vedic-tab.tsx",
"src/components/chart-profile-form.tsx",
"src/components/chat-composer.tsx",
"src/components/chat-message-actions.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/conversation-follow-ups.tsx",
"src/components/email-otp-login.tsx",
"src/components/ephemeris/ephemeris-page.tsx",
"src/components/feedback-dialog.tsx",
"src/components/home-bootstrap-error.tsx",
"src/components/home-onboarding-shell.tsx",
"src/components/jump-to-latest-button.tsx",
"src/components/legal/legal-consent-gate.tsx",
"src/components/legal/login-consent.tsx",
"src/components/markdown-code-block.tsx",
"src/components/model-selector.tsx",
"src/components/onboarding-redeem-paywall.tsx",
"src/components/people/people-page.tsx",
"src/components/person-relation-field.tsx",
"src/components/personal-report/personal-report-center.tsx",
"src/components/personal-report/personal-report-markdown-view.tsx",
"src/components/personal-report/report-actions.tsx",
"src/components/personal-report/report-edge-jump.tsx",
"src/components/personal-report/report-export-drawer.tsx",
"src/components/personal-report/report-row-delete.tsx",
"src/components/profile-gender-field.tsx",
"src/components/profile-panel.tsx",
"src/components/rectification-agentic-chat.tsx",
"src/components/rectification-board.tsx",
"src/components/rectification-choice-card.tsx",
"src/components/rectification-composer-reopen.tsx",
"src/components/rectification-range-delivery.tsx",
"src/components/rectification-segment-delivery.tsx",
"src/components/reply-down-reason.tsx",
"src/components/starter-home.tsx",
"src/components/subject-switcher.tsx",
"src/components/synastry/synastry-add-dialog.tsx",
"src/components/synastry/synastry-picker.tsx",
"src/components/synastry/synastry-report-view.tsx",
"src/components/theme-preference-menu.tsx"
]
+1965 -64
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -20,7 +20,9 @@
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^6.3.2",
"@base-ui/react": "^1.6.0",
"@gsap/react": "^2.1.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@mastra/core": "^1.50.1",
"@refinedev/antd": "^6.0.3",
"@refinedev/core": "^5.0.12",
@@ -33,24 +35,28 @@
"boring-avatars": "2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "0.4.0",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"gsap": "^3.15.0",
"lucide-react": "^1.24.0",
"motion": "14.1.0",
"next": "16.3.1",
"pg": "^8.22.0",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-resizable-panels": "4.14.3",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"shadcn": "4.21.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
"zod": "^3.25.76",
"zustand": "5.0.15"
},
"devDependencies": {
"@types/node": "^20",
+37
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "shadcn/tailwind.css";
@import "tw-animate-css";
.personal-report-fact-tables { margin-block: 2rem; min-width: 0; }
@@ -27,6 +28,20 @@
--color-muted: var(--color-canvas-muted);
--color-muted-foreground: var(--color-ink-secondary);
--color-destructive: var(--color-danger);
--color-card: var(--color-canvas-muted);
--color-card-foreground: var(--color-ink);
--color-popover: var(--color-canvas);
--color-popover-foreground: var(--color-ink);
--color-accent: var(--color-selected);
--color-accent-foreground: var(--color-ink);
--color-sidebar-foreground: var(--color-ink);
--color-sidebar-primary: var(--color-surface-dark);
--color-sidebar-primary-foreground: var(--color-on-dark);
--color-sidebar-accent: var(--color-selected);
--color-sidebar-accent-foreground: var(--color-ink);
--color-sidebar-border: var(--color-border);
--color-sidebar-ring: var(--color-focus);
/* Literal on purpose: border-border / border-input already paint these hexes. */
--color-border: #d8d6cf;
--color-input: #b8b5ad;
--color-ring: var(--color-focus);
@@ -231,6 +246,28 @@
--sidebar-width-tablet: 240px;
--sidebar-width-icon: 64px;
--sidebar-width-mobile: min(86vw, 320px);
/* shadcn names for the palette above. One copy is enough: dark blocks
override the --color-* targets, and these aliases follow both themes. */
--background: var(--color-canvas);
--foreground: var(--color-ink);
--card: var(--color-canvas-muted);
--card-foreground: var(--color-ink);
--popover: var(--color-canvas);
--popover-foreground: var(--color-ink);
--primary: var(--color-action-fill);
--primary-foreground: var(--color-on-action-fill);
--secondary: var(--color-canvas-muted);
--secondary-foreground: var(--color-ink);
--muted: var(--color-canvas-muted);
--muted-foreground: var(--color-ink-secondary);
--accent: var(--color-selected);
--accent-foreground: var(--color-ink);
--destructive: var(--color-danger);
--border: var(--color-border);
--input: var(--color-border-strong);
--ring: var(--color-focus);
--radius: 12px;
--sidebar: var(--color-sidebar);
}
/* ── Dark theme ──────────────────────────────────────────────────────────────
+47 -26
View File
@@ -7,20 +7,25 @@ import { ConsultationRunTimeline } from "@/components/consultation-run-timeline"
import type { ChatMessageView } from "@/lib/chat-message-view";
import { memo, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
type GsapCore = typeof import("gsap")["gsap"];
type MessageAnimate = typeof import("motion")["animate"];
const useEntryEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let gsapCore: GsapCore | undefined;
let gsapRequest: Promise<GsapCore> | undefined;
let messageMotion: { animate: MessageAnimate } | undefined;
let messageMotionRequest: Promise<{ animate: MessageAnimate }> | undefined;
function loadGsap() {
gsapRequest ??= import("gsap").then((module) => {
const core = module.gsap ?? module.default;
gsapCore = (core as GsapCore & { gsap?: GsapCore }).gsap ?? core;
return gsapCore;
function loadMessageMotion() {
messageMotionRequest ??= import("motion").then((module) => {
messageMotion = { animate: module.animate };
return messageMotion;
});
return gsapRequest;
return messageMotionRequest;
}
function bezierFromCss(ease: string): [number, number, number, number] {
const numbers = ease.match(/-?\d*\.?\d+/g)?.map(Number);
if (!numbers || numbers.length !== 4) return [0.22, 1, 0.36, 1];
return [numbers[0], numbers[1], numbers[2], numbers[3]];
}
function motionPreferred() {
@@ -29,7 +34,7 @@ function motionPreferred() {
&& !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
if (motionPreferred()) prefetchOnIdle(loadGsap);
if (motionPreferred()) prefetchOnIdle(loadMessageMotion);
export const ChatMessageRow = memo(function ChatMessageRow({
message,
@@ -80,25 +85,41 @@ export const ChatMessageRow = memo(function ChatMessageRow({
useEntryEffect(() => {
const row = messageRow.current;
if (!row) return;
if (!gsapCore) {
if (motionPreferred()) void loadGsap();
if (!messageMotion) {
if (motionPreferred()) void loadMessageMotion();
return;
}
const gsap = gsapCore;
const motion = gsap.matchMedia();
motion.add("(prefers-reduced-motion: no-preference)", () => {
gsap.fromTo(row, {
autoAlpha: 0,
y: message.role === "user" ? 8 : 12,
}, {
autoAlpha: 1,
clearProps: "opacity,transform,visibility",
duration: 0.16,
ease: "cubic-bezier(.22, 1, .36, 1)",
y: 0,
});
const media = window.matchMedia("(prefers-reduced-motion: no-preference)");
if (!media.matches) return;
const fromY = message.role === "user" ? 8 : 12;
const tween = {
clearProps: "opacity,transform,visibility",
duration: 0.16,
ease: "cubic-bezier(.22, 1, .36, 1)",
};
const animation = messageMotion.animate(row, {
opacity: [0, 1],
y: [fromY, 0],
}, {
duration: tween.duration,
ease: bezierFromCss(tween.ease),
});
return () => motion.revert();
const clear = () => {
for (const prop of tween.clearProps.split(",")) row.style.removeProperty(prop.trim());
};
const finished = animation.finished.then(clear, clear);
const onChange = () => {
if (media.matches) return;
animation.stop();
clear();
};
media.addEventListener("change", onChange);
return () => {
animation.stop();
media.removeEventListener("change", onChange);
clear();
void finished;
};
}, [message.role]);
const thinkingPanel = showThinkingPanel
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+108
View File
@@ -0,0 +1,108 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "cn"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "cn"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+227
View File
@@ -0,0 +1,227 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
import { cn } from "cn"
type DrawerContextProps = {
hasSnapPoints: boolean
modal: DrawerPrimitive.Root.Props["modal"]
showSwipeHandle: boolean
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
}
const DrawerContext = React.createContext<DrawerContextProps | null>(null)
function useDrawer() {
const context = React.useContext(DrawerContext)
if (!context) {
throw new Error("useDrawer must be used within a Drawer.")
}
return context
}
function Drawer({
modal = true,
showSwipeHandle = false,
snapPoints,
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props & {
showSwipeHandle?: boolean
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection]
)
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
)
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
className
)}
{...props}
/>
)
}
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer()
const swipeAxis =
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x"
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && (
<DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
)}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? "" : undefined}
className={cn(
// Base.
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
// Sizing.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
// Stack.
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
// Direction: up.
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
// Direction: left.
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
// Direction: right.
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
className
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
{...props}
/>
)
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-balance text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,267 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "cn"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "cn"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+19
View File
@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,37 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "cn"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { cn } from "cn"
import * as ResizablePrimitive from "react-resizable-panels"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "cn"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "cn"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+65
View File
@@ -0,0 +1,65 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "cn"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+198
View File
@@ -0,0 +1,198 @@
/** Motion presets for the site-motion table. Data only; nothing animates here. */
export const easeOut = [0.22, 1, 0.36, 1] as const;
export const easeSpring = [0.34, 1.56, 0.64, 1] as const;
export type MotionEase = readonly [number, number, number, number];
export type MotionDirection =
| "none"
| "up"
| "down"
| "with-nav"
| "with-control"
| "from-edge"
| "from-trigger"
| "from-composer"
| "crossfade";
export type MotionPreset = {
duration: number;
durationExit?: number;
durationFollow?: number;
ease: MotionEase;
/** Travel axis. "none" is fade or color only. */
axis: "x" | "y" | "none";
/** Pixels. 0 means the caller measures the real delta, or there is no travel. */
distance: number;
direction: MotionDirection;
stagger?: number;
scale: boolean;
/** prefers-reduced-motion: drop travel and scale. Fade stays at or under 150ms. */
reduced: { duration: number; travel: false; instant?: boolean };
};
const fade = { duration: 0.15, travel: false as const };
export const motionPresets = {
press: {
duration: 0.12,
ease: easeOut,
axis: "none",
distance: 0,
direction: "none",
scale: false,
reduced: { duration: 0.12, travel: false },
},
pageEnter: {
duration: 0.28,
ease: easeOut,
axis: "y",
distance: 16,
direction: "with-nav",
scale: false,
reduced: fade,
},
pageExit: {
duration: 0.15,
ease: easeOut,
axis: "none",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
selectionFollow: {
duration: 0.26,
ease: easeOut,
axis: "none",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
tabSwitch: {
duration: 0.26,
ease: easeOut,
axis: "x",
distance: 0,
direction: "with-control",
scale: false,
reduced: fade,
},
chartType: {
duration: 0.44,
ease: easeOut,
axis: "none",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
personSwitch: {
duration: 0.3,
durationFollow: 0.8,
ease: easeOut,
axis: "none",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
dateTurn: {
duration: 0.26,
ease: easeOut,
axis: "x",
distance: 0,
direction: "with-control",
scale: false,
reduced: fade,
},
sendMessage: {
duration: 0.26,
ease: easeOut,
axis: "y",
distance: 0,
direction: "from-composer",
scale: false,
reduced: fade,
},
sessionSwitch: {
duration: 0.2,
ease: easeOut,
axis: "none",
distance: 0,
direction: "crossfade",
scale: false,
reduced: fade,
},
listReorder: {
duration: 0.26,
durationExit: 0.28,
ease: easeOut,
axis: "y",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
dialog: {
duration: 0.22,
durationExit: 0.16,
ease: easeOut,
axis: "none",
distance: 0,
direction: "from-trigger",
scale: true,
reduced: fade,
},
drawer: {
duration: 0.28,
durationExit: 0.2,
ease: easeOut,
axis: "x",
distance: 0,
direction: "from-edge",
scale: false,
reduced: fade,
},
toast: {
duration: 0.32,
ease: easeSpring,
axis: "y",
distance: 0,
direction: "down",
scale: false,
reduced: fade,
},
numberChange: {
duration: 0.36,
ease: easeOut,
axis: "y",
distance: 0,
direction: "with-control",
scale: false,
reduced: { duration: 0, travel: false, instant: true },
},
localeSwitch: {
duration: 0.32,
ease: easeOut,
axis: "none",
distance: 0,
direction: "crossfade",
stagger: 0.04,
scale: false,
reduced: fade,
},
disclosure: {
duration: 0.24,
ease: easeOut,
axis: "y",
distance: 0,
direction: "none",
scale: false,
reduced: fade,
},
} as const satisfies Record<string, MotionPreset>;
export type MotionPresetName = keyof typeof motionPresets;
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { LazyMotion, MotionConfig } from "motion/react";
import type { ReactNode } from "react";
const loadDomAnimation = () =>
import("motion/react").then((module) => module.domAnimation);
/** Not mounted in K0. Callers use `m` from motion/react inside this tree. */
export function MotionProvider({ children }: { children: ReactNode }) {
return (
<LazyMotion features={loadDomAnimation} strict>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</LazyMotion>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { create } from "zustand";
/** Skeleton. Account state moves here in a later issue, not in K0. */
type AccountState = {
placeholder: true;
};
export const useAccountStore = create<AccountState>(() => ({
placeholder: true,
}));
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { create } from "zustand";
/** Skeleton. People-archive state moves here in a later issue, not in K0. */
type PeopleState = {
placeholder: true;
};
export const usePeopleStore = create<PeopleState>(() => ({
placeholder: true,
}));
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { create } from "zustand";
/** Skeleton. Conversation state moves here in a later issue, not in K0. */
type SessionState = {
placeholder: true;
};
export const useSessionStore = create<SessionState>(() => ({
placeholder: true,
}));
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { create } from "zustand";
/** Skeleton. Shell UI state moves here in a later issue, not in K0. */
type UiState = {
placeholder: true;
};
export const useUiStore = create<UiState>(() => ({
placeholder: true,
}));
@@ -71,23 +71,21 @@ test("the pre-markdown fallback renders prose but never raw markdown", () => {
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
});
test("gsap loads on demand while keeping the reduced-motion gate", () => {
// Given: the animation engine is no longer part of the static import graph.
assert.doesNotMatch(messageRowSource, /^import .*from "gsap"/m);
assert.doesNotMatch(messageRowSource, /@gsap\/react/);
assert.match(messageRowSource, /import\("gsap"\)/);
test("message motion loads on demand while keeping the reduced-motion gate", () => {
// 原值: 静态图里没有 gsap,动态 import("gsap"),gsap.matchMedia(),if (!gsapCore)
// 新值: 静态图里没有 motion/react,动态 import("motion"),matchMedia(no-preference),if (!messageMotion)
// 原因: 入场从 GSAP 换成 Motion,仍按需加载,模块没到就不要补播。时长、曲线、清属性不变。
assert.doesNotMatch(messageRowSource, /gsap|@gsap\/react/);
assert.doesNotMatch(messageRowSource, /^import .*from ["']motion/m);
assert.match(messageRowSource, /import\("motion"\)/);
// Then: reduced-motion users never request it, and the tween contract is unchanged.
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
// Former value: `duration: 0\.18`. DESIGN.md §6 gives the message entrance 160ms and the CSS
// keyframe that used to run alongside was 160ms too; 0.18 was the GSAP copy drifting.
assert.match(messageRowSource, /matchMedia\("\(prefers-reduced-motion: no-preference\)"\)/);
assert.match(messageRowSource, /duration: 0\.16/);
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
assert.match(messageRowSource, /if \(!gsapCore\) \{/);
assert.match(messageRowSource, /if \(!messageMotion\) \{/);
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
});
@@ -108,5 +106,7 @@ test("deferred chat chunks are requested during idle time after first paint", ()
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
// 原值: prefetchOnIdle(loadGsap) / 新值: prefetchOnIdle(loadMessageMotion)
// 原因: 同上,空闲预取的是 Motion 而不是 GSAP。
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadMessageMotion\)/);
});
+5 -1
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const root = new URL("../", import.meta.url);
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
@@ -33,7 +34,10 @@ function rootDeclarations() {
function sourceFiles(path: string): string[] {
const directory = new URL(path, root);
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const child = join(directory.pathname, entry.name);
// 原值: join(directory.pathname, entry.name)
// 新值: join(fileURLToPath(directory), entry.name)
// 原因: Windows 上 URL.pathname 以 /G: 开头,path.join 会拼成 G:\G:\...,合同还没读到源码就失败。断言不变。
const child = join(fileURLToPath(directory), entry.name);
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
});
+78
View File
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { easeOut, easeSpring, motionPresets } from "../src/lib/motion/presets.ts";
import { MotionProvider } from "../src/lib/motion/provider.tsx";
const providerSource = readFileSync(new URL("../src/lib/motion/provider.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
test("every site-motion row is a preset and nothing else is invented", () => {
assert.deepEqual(Object.keys(motionPresets).sort(), [
"chartType",
"dateTurn",
"dialog",
"disclosure",
"drawer",
"listReorder",
"localeSwitch",
"numberChange",
"pageEnter",
"pageExit",
"personSwitch",
"press",
"selectionFollow",
"sendMessage",
"sessionSwitch",
"tabSwitch",
"toast",
]);
assert.deepEqual(easeOut, [0.22, 1, 0.36, 1]);
assert.deepEqual(easeSpring, [0.34, 1.56, 0.64, 1]);
assert.equal(motionPresets.press.duration, 0.12);
assert.equal(motionPresets.pageEnter.duration, 0.28);
assert.equal(motionPresets.pageEnter.distance, 16);
assert.equal(motionPresets.pageExit.duration, 0.15);
assert.equal(motionPresets.selectionFollow.duration, 0.26);
assert.equal(motionPresets.tabSwitch.duration, 0.26);
assert.equal(motionPresets.chartType.duration, 0.44);
assert.equal(motionPresets.personSwitch.duration, 0.3);
assert.equal(motionPresets.personSwitch.durationFollow, 0.8);
assert.equal(motionPresets.dateTurn.duration, 0.26);
assert.equal(motionPresets.sendMessage.duration, 0.26);
assert.equal(motionPresets.sendMessage.direction, "from-composer");
assert.equal(motionPresets.sessionSwitch.duration, 0.2);
assert.equal(motionPresets.sessionSwitch.direction, "crossfade");
assert.equal(motionPresets.listReorder.duration, 0.26);
assert.equal(motionPresets.listReorder.durationExit, 0.28);
assert.equal(motionPresets.dialog.duration, 0.22);
assert.equal(motionPresets.dialog.durationExit, 0.16);
assert.equal(motionPresets.dialog.scale, true);
assert.equal(motionPresets.drawer.duration, 0.28);
assert.equal(motionPresets.drawer.durationExit, 0.2);
assert.equal(motionPresets.toast.duration, 0.32);
assert.equal(motionPresets.toast.ease, easeSpring);
assert.equal(motionPresets.numberChange.duration, 0.36);
assert.equal(motionPresets.numberChange.reduced.instant, true);
assert.equal(motionPresets.localeSwitch.duration, 0.32);
assert.equal(motionPresets.localeSwitch.stagger, 0.04);
assert.equal(motionPresets.disclosure.duration, 0.24);
for (const preset of Object.values(motionPresets)) {
assert.equal(preset.reduced.travel, false);
assert.ok(preset.reduced.duration <= 0.15);
if (preset !== motionPresets.toast) assert.equal(preset.ease, easeOut);
}
});
test("LazyMotion loads domAnimation asynchronously and honors reduced motion", () => {
assert.equal(typeof MotionProvider, "function");
assert.match(providerSource, /LazyMotion/);
assert.match(providerSource, /MotionConfig/);
assert.match(providerSource, /reducedMotion="user"/);
assert.match(providerSource, /import\("motion\/react"\)\.then\(\(module\) => module\.domAnimation\)/);
assert.doesNotMatch(providerSource, /domMax/);
assert.doesNotMatch(pageSource, /motion\/react|@\/lib\/motion\/provider|@\/stores\//);
assert.doesNotMatch(layoutSource, /motion\/react|@\/lib\/motion\/provider|@\/stores\//);
});
@@ -40,7 +40,9 @@ test("centers the starter home even when the conversation grid is not is-empty",
});
test("keeps message motion restrained and honors reduced-motion preferences", () => {
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
// 原值: gsap.matchMedia() / 新值: matchMedia("(prefers-reduced-motion: no-preference)")
// 原因: GSAP 换成 Motion 后仍只在用户允许动态效果时入场。时长、曲线、清属性不变。
assert.match(messageRowSource, /matchMedia\("\(prefers-reduced-motion: no-preference\)"\)/);
assert.match(messageRowSource, /prefers-reduced-motion:\s*no-preference/);
// Former value: `duration:\s*0\.18`. DESIGN.md §6 gives the message entrance 160ms; 0.18 was
// the GSAP copy drifting from the (now removed) 160ms CSS keyframe that ran alongside it.
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { useAccountStore } from "../src/stores/account-store.ts";
import { usePeopleStore } from "../src/stores/people-store.ts";
import { useSessionStore } from "../src/stores/session-store.ts";
import { useUiStore } from "../src/stores/ui-store.ts";
const home = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
test("K0 store skeletons exist and Home() does not move state into them", () => {
assert.equal(useSessionStore.getState().placeholder, true);
assert.equal(usePeopleStore.getState().placeholder, true);
assert.equal(useAccountStore.getState().placeholder, true);
assert.equal(useUiStore.getState().placeholder, true);
assert.doesNotMatch(home, /useSessionStore|usePeopleStore|useAccountStore|useUiStore/);
});
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("shadcn tailwind utilities are imported on the second line", () => {
const [first, second] = css.split(/\r?\n/);
assert.equal(first, '@import "tailwindcss";');
assert.equal(second, '@import "shadcn/tailwind.css";');
});
test("shadcn semantic variables alias the existing palette", () => {
const light = css.slice(css.indexOf(":root {\n color-scheme: light;"), css.indexOf("\n}", css.indexOf(":root {\n color-scheme: light;")));
const aliases: Record<string, string> = {
"--background": "var(--color-canvas)",
"--foreground": "var(--color-ink)",
"--card": "var(--color-canvas-muted)",
"--primary": "var(--color-action-fill)",
"--primary-foreground": "var(--color-on-action-fill)",
"--destructive": "var(--color-danger)",
"--border": "var(--color-border)",
"--input": "var(--color-border-strong)",
"--ring": "var(--color-focus)",
"--sidebar": "var(--color-sidebar)",
"--radius": "12px",
};
for (const [name, value] of Object.entries(aliases)) {
assert.match(light, new RegExp(`${name.replace("-", "\\-")}: ${value.replace(/[()]/g, "\\$&")};`));
}
});
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
/** K0 snapshot. Later issues may delete entries. Adding one fails this test. */
const BASELINE = [
"src/app/(app)/page.tsx",
"src/app/error.tsx",
"src/app/global-error.tsx",
"src/components/account-deletion-gate.tsx",
"src/components/account-deletion-section.tsx",
"src/components/account-dialog-overlay.tsx",
"src/components/ayanamsa-preference-field.tsx",
"src/components/billing-panel.tsx",
"src/components/birth-place-picker.tsx",
"src/components/birth-sky/birth-sky-converge.tsx",
"src/components/birth-sky/birth-sky-replay.tsx",
"src/components/birth-time-candidate-result.tsx",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-evidence-draft-card.tsx",
"src/components/birth-time-guide-turn.tsx",
"src/components/birth-time-intake.tsx",
"src/components/birth-time-legacy-rectification.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/chart-page/chart-birth-editor.tsx",
"src/components/chart-page/chart-dasha-tab.tsx",
"src/components/chart-page/chart-page-view.tsx",
"src/components/chart-page/chart-type-picker.tsx",
"src/components/chart-page/chart-vedic-tab.tsx",
"src/components/chart-profile-form.tsx",
"src/components/chat-composer.tsx",
"src/components/chat-message-actions.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/conversation-follow-ups.tsx",
"src/components/email-otp-login.tsx",
"src/components/ephemeris/ephemeris-page.tsx",
"src/components/feedback-dialog.tsx",
"src/components/home-bootstrap-error.tsx",
"src/components/home-onboarding-shell.tsx",
"src/components/jump-to-latest-button.tsx",
"src/components/legal/legal-consent-gate.tsx",
"src/components/legal/login-consent.tsx",
"src/components/markdown-code-block.tsx",
"src/components/model-selector.tsx",
"src/components/onboarding-redeem-paywall.tsx",
"src/components/people/people-page.tsx",
"src/components/person-relation-field.tsx",
"src/components/personal-report/personal-report-center.tsx",
"src/components/personal-report/personal-report-markdown-view.tsx",
"src/components/personal-report/report-actions.tsx",
"src/components/personal-report/report-edge-jump.tsx",
"src/components/personal-report/report-export-drawer.tsx",
"src/components/personal-report/report-row-delete.tsx",
"src/components/profile-gender-field.tsx",
"src/components/profile-panel.tsx",
"src/components/rectification-agentic-chat.tsx",
"src/components/rectification-board.tsx",
"src/components/rectification-choice-card.tsx",
"src/components/rectification-composer-reopen.tsx",
"src/components/rectification-range-delivery.tsx",
"src/components/rectification-segment-delivery.tsx",
"src/components/reply-down-reason.tsx",
"src/components/starter-home.tsx",
"src/components/subject-switcher.tsx",
"src/components/synastry/synastry-add-dialog.tsx",
"src/components/synastry/synastry-picker.tsx",
"src/components/synastry/synastry-report-view.tsx",
"src/components/theme-preference-menu.tsx",
] as const;
const RULE = "jyotisha-ui/no-handwritten-control";
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
const eslint = new ESLint({ cwd: frontendRoot });
const drawingSnippet = `export function Chart() {
return (
<div>
<button type="button">保存</button>
<span>☰</span>
<svg viewBox="0 0 10 10"><text>×</text></svg>
</div>
);
}
`;
async function ruleMessages(filename: string, code: string) {
const [result] = await eslint.lintText(code, { filePath: filename });
return (result?.messages ?? []).filter((message) => message.ruleId === RULE);
}
test("the handwritten-control whitelist can only shrink", () => {
const live = JSON.parse(readFileSync(new URL("../eslint/ui-primitive-whitelist.json", import.meta.url), "utf8")) as string[];
assert.deepEqual(live, [...live].sort());
assert.equal(new Set(live).size, live.length);
assert.ok(live.length <= BASELINE.length);
for (const file of live) assert.ok(BASELINE.includes(file as (typeof BASELINE)[number]), file);
});
test("a new file under src/components with a native button fails lint", async () => {
const messages = await ruleMessages(
"src/components/ui-kit-k0-probe.tsx",
`export function Probe() { return <button type="button">保存</button>; }\n`,
);
assert.ok(messages.some((message) => message.message.includes("<button>")));
});
test("src/components/ui is outside the handwritten-control rule", async () => {
const messages = await ruleMessages(
"src/components/ui/ui-kit-k0-probe.tsx",
`export function Probe() { return <button type="button">保存</button>; }\n`,
);
assert.equal(messages.length, 0);
});
test("chart and star-map SVG drawings are exempt from the icon rule only", async () => {
const ruleSource = readFileSync(new URL("../eslint/no-handwritten-control.mjs", import.meta.url), "utf8");
assert.match(ruleSource, /src\/components\/personal-report\/vedic-chart-svg\.tsx/);
assert.match(ruleSource, /src\/components\/chart-page\/western-wheel-svg\.tsx/);
for (const filename of [
"src/components/personal-report/vedic-chart-svg.tsx",
"src/components/chart-page/western-wheel-svg.tsx",
]) {
const messages = await ruleMessages(filename, drawingSnippet);
assert.ok(messages.some((message) => message.message.includes("<button>")), filename);
assert.ok(messages.some((message) => message.message.includes("☰")), filename);
assert.equal(messages.some((message) => message.message.includes("×")), false, filename);
}
});