diff --git a/).join( b/).join( deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index 042ccd7..468b5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ pack.log # 视觉验证的临时产物(harness + 截图),不进版本库 .shots/ + +# video2code 会话缓存,不进版本库 +.v2c/ diff --git a/AGENTS.md b/AGENTS.md index f985680..0209be1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,9 @@ - **待办墙**:Notion 式三列(待办 / 进行中 / 已完成),卡片带科目、预计时长、累计投入,可拖拽换列; - **学习热力图**:点亮条件是**当天实际复习了多少分钟**,不是「当天有没有消耗 token / 有没有开过会话」; - **番茄钟**:专注 / 短休 / 长休自动轮换,换面板、刷新、关掉浏览器再打开都按绝对截止时间恢复; + AI 起钟走门禁(必须挂「进行中」的卡),走完自动提醒开钟的那个会话(可开关); +- **每日计划表**:`HH:MM~HH:MM 事情` 的每日固定模板,开了之后主面板显示、模型从系统提示里 + 知道「现在该做什么」,还可以像迷你条一样悬浮在桌面上(功能与悬浮各自独立开关); - **常驻迷你条**:右下角小胶囊,在对话页也能看见剩余时间。 它**不属于 DSH 官方**,也不依赖任何第三方包(运行时依赖为零)。宿主半边只额外用到 DSH 自己的 @@ -25,14 +28,14 @@ | `markdown.js` | Markdown 导出 / 读回(纯函数):`toMarkdown` 生成 Obsidian 友好的 `desk.md`,`fromMarkdown` + `mergeMarkdown` 按 id 合并读回。机器字段放在行尾 `` 注释里。 | | `bbdc.js` | **不背单词打卡桥**(纯数据层,零依赖):从腾讯 Androws 虚拟磁盘里的 App 数据读打卡/时长/词书,再用 App WebView 缓存里挖到的 `sid` 打 `learnywhere.cn` 的统计接口补累计词数。**只读**,不回写 App。`bbdcView()` 把采集结果裁成面板要看的紧凑形状(挡住 sid 摘要与临时目录);`readBbdc()` 带 10 分钟缓存 + 并发去重。 | | `timer.js` | 番茄钟状态机:`start/pause/resume/reset/complete/reconcile`。真相是绝对时间戳,不用「还剩多少毫秒」。 | -| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 | -| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘)+ 独立的「单词打卡」面板 + 设置页 + 迷你条,注册 6 个槽位(2 个侧栏入口 + 2 个 main + 1 个迷你条 + 1 个设置节)。 | +| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要(含每日计划表的「现在该做什么」)。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/`dayPlan` 计划表当前时段/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 | +| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘 / 每日计划表)+ 独立的「单词打卡」面板 + 设置页 + 迷你条与计划表两个悬浮件,注册 7 个槽位(2 个侧栏入口 + 2 个 main + 2 个 shell.overlay + 1 个设置节)。 | | `cordis.patch.yml` | 让宿主把本插件 insert 进 loader 树。 | | `package.json` | `dsh.bundle.patch` 指向上面那个 patch;`dsh.client` 声明客户端半边与注入的运行时包。 | -| `test/desk.test.js` | 30 个单测,`node --test`。 | +| `test/desk.test.js` | 数据层主套件(含番茄钟状态机、AI 起钟会话标记、每日计划表归一化与区间判定),`node --test`。 | | `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 | | `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 | -| `test/mini-dock.test.js` | 5 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带。 | +| `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 | | `test/style.test.js` | 6 个单测(视觉层守卫):CSS 里 hex 必须为 0、饼图 8 色不许退回语义色、不许有零点几像素字号、标题不用衬线、字阶挂在各根上、图表配色归属。 | | `tools/client-loader.mjs` | 把 `client.js` 这个 loader 格式的 bundle 在纯 Node 里载进来、取 `exports.__test`。两个测试文件共用一份,免得各写一套桩。放在 `tools/` 是因为 `node --test` 会把 `test/` 下每个文件都当测试跑。 | | `apply-probe.mjs` | 离线冒烟:用 stub ctx 把两侧 `apply()` 真跑一遍,不启动 DSH 就能抓加载期错误。 | @@ -62,6 +65,9 @@ 或 Windows 用户目录那种绝对路径(`C:` 下 Users 里带真名的那种)—— CI 会 grep 直接红。 7. **复习排期、队列排序、周报、标签聚合只在 `desk.js` 一处算。** 宿主把 `review / due / tags / weekly / journal` 随快照发出,`client.js` 只画不算。浏览器里再实现一遍 SM-2 迟早和宿主对不上。 + 每日计划表的「现在在哪个区间」(`dailyPlanNow`)同理只在 desk.js:注入 system prompt 与快照 + `dayPlan` 都从它取。客户端那份 `pmin()` 只做钟表换算(当前分钟落在哪个区间、剩几分钟), + 和画倒计时是一档的事,不是领域算法——别把排序、校验再写一遍进浏览器。 8. **Markdown 读回是「合并」不是「覆盖」**:`desk.md` 里没出现的卡不动;靠行尾 `` 对回原卡, 注释被删就当新卡。导出的 Markdown 里不许出现本机绝对路径。 9. **不背单词那边只读,且凭据不出门。** `bbdc.js` 只读 App 数据与官方 H5 的公开统计接口, @@ -78,6 +84,21 @@ 规则只写在 `bbdcView()` 一处(客户端是独立 bundle、只能 `require react`,两边各写一遍必散)。 另有 `durationAsOf` / `durationMightBeStale`:只在「本地落后且云端没兜住」时提醒, 判定今天用 `desk.js` 的 `dayKey`(本地时区),**不能用 `toISOString()`** —— UTC+8 的早上会认错天。 +10. **AI 起番茄钟走门禁,走完的提醒发回起钟的会话。** 这是用户点名的督促链路: + - **门禁**:工具 `focus` 起钟必须 `task=` 一张**「进行中」**的卡,不挂卡 / 卡不存在 / 卡不在 doing + 都直接拒绝,报错文案教完整流程(`add` 建卡 → `move` 到 `doing` → `focus` 挂卡)。目的是逼 AI 把它 + 安排的任务真的落到待办墙上,周报和近 30 天统计才对得上。**只限工具路径**——客户端 POST + `timer.start`(手动起钟)不受限制。 + - **会话标记**:AI 起钟时把 `exec.agent.id`(= 会话 id)存进 `state.timer`(`by:'ai'` + `sessionId`); + 专注走完自动摆的休息**继承**这两个字段(链条到休息结束为止),手动提前结束摆的休息也继承。 + - **提醒发送**:`withDesk` 结账时把走完的段收进 sink,`emitTimerEnd` 过滤(开关关了 / 不是 AI 链 / + 段走完超过 10 分钟才被结账的都跳过)后调钩子:`ctx.get('agents').get(sessionId).followup(消息)`。 + 这是官方 `dsh-goal-round-driver` 的同款姿势(app.asar 里有源码可对照);`followup` 空闲就开新一轮、 + 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin'`,正文自带「这是系统自动消息,不是用户发言」。 + 宿主侧另有 10 秒看门狗:面板没开没人轮询 desk 时由它兜底结账。 + - **开关**:`settings.notifySessionOnTimerEnd`(默认开,`!== false` 即开,语义同 `injectPrompt`), + 设置页有勾选项。**手动停钟(客户端按钮 / 工具 `off=true`)永不提醒**;会话已关闭(agents 里没有) + 就静默放弃,不排队不补发。 ## 三个必须知道的坑 @@ -184,8 +205,8 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk ## 测试 ```bash -node --test # 58 个单测:数据层 + 番茄钟状态机 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条落点与拖拽带 + 视觉层守卫(hex / 字阶 / 配色归属) -node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 +node --test # 73 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属) +node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验 node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整 ``` @@ -199,7 +220,7 @@ node install.mjs --check # 体检:本目录与某个 profile 的接线是否 拿一份 React 18 的 UMD 构建(本机任意 `node_modules/react{,-dom}/umd/*.production.min.js`), 写一个 HTML 复刻宿主外壳(`frame > centerCol(flex, overflow:hidden) + overlayLayer(z-index:20, pointer-events:none)`), 塞进宿主的 `body{--dsw-alias-*}` 主题 token,替换 `window.fetch` 成一个小假宿主, -然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把四个槽位挂出来, +然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把七个槽位挂出来, 再用真 `MouseEvent('click')` 点一遍按钮、断言 POST 出去的 op。 暗色只要给 `` 加 `data-ds-dark-theme`(注意:暗色标记在 **body** 上,不是 html)。 无头 Edge 出图与结果:`msedge --headless=new --dump-dom`(把断言写进一个屏外 `
`)+ `--screenshot`。
diff --git a/apply-probe.mjs b/apply-probe.mjs
index 4b3c7c4..fef36c7 100644
--- a/apply-probe.mjs
+++ b/apply-probe.mjs
@@ -4,7 +4,7 @@
  * 用法:node apply-probe.mjs   (cwd = 本目录,含 index.js/desk.js/timer.js/client.js)
  */
 import assert from 'node:assert/strict'
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 
@@ -223,6 +223,101 @@ check('未知 op 带 error 文案', () => assert.ok(String(JSON.parse(p5.body).e
 const p6 = await post({ op: 'task.add' })
 check('缺 title 返回 400', () => assert.equal(p6.code, 400))
 
+// ---- AI 起钟门禁 + 番茄钟走完的会话提醒(督促链路端到端)----
+// stub 一个 agents 服务:宿主提醒走 ctx.get('agents').get(sessionId).followup(消息)
+const agentStub = { id: 'sess-A', followups: [], followup(msg) { this.followups.push(msg) } }
+registered.services.agents = { get(id) { return id === 'sess-A' ? agentStub : undefined } }
+
+const blockedNoTask = await run('focus', { minutes: 25 })
+check('AI 起钟不挂卡被拒,且报错里教了完整流程', () => {
+  assert.equal(blockedNoTask.ok, false)
+  assert.match(blockedNoTask.message, /挂卡/)
+  assert.match(blockedNoTask.message, /action=add/)
+  assert.match(blockedNoTask.message, /status=doing/)
+})
+
+const gateCard = await run('add', { title: '门禁冒烟卡', subject: '英语' })
+check('门禁卡建好', () => assert.equal(gateCard.ok, true))
+const blockedTodo = await run('focus', { minutes: 25, task: '门禁冒烟卡' })
+check('AI 起钟挂待办卡被拒', () => {
+  assert.equal(blockedTodo.ok, false)
+  assert.match(blockedTodo.message, /进行中/)
+})
+
+await run('move', { title: '门禁冒烟卡', status: 'doing' })
+const aiFocus = await tool.execute({ action: 'focus', minutes: 25, task: '门禁冒烟卡' }, { agent: { id: 'sess-A' } })
+check('挂「进行中」的卡可以起钟', () => assert.equal(aiFocus.ok, true))
+check('起钟回执预告了自动提醒', () => assert.match(aiFocus.message, /自动往本会话发一条提醒/))
+check('计时器记下了起钟会话', () => {
+  const t = JSON.parse(readFileSync(join(PROBE_HOME, 'study-desk', 'desk.json'), 'utf8')).timer
+  assert.equal(t.by, 'ai')
+  assert.equal(t.sessionId, 'sess-A')
+})
+
+// 时间快进:把 endsAt 改到过去再 GET,宿主结账 → 提醒送进 stub 会话
+const deskPath = join(PROBE_HOME, 'study-desk', 'desk.json')
+const rawDesk = JSON.parse(readFileSync(deskPath, 'utf8'))
+rawDesk.timer.endsAt = Date.now() - 1000
+rawDesk.timer.startedAt = rawDesk.timer.endsAt - 25 * 60000
+writeFileSync(deskPath, JSON.stringify(rawDesk))
+const nudgeRes = fakeRes()
+await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, nudgeRes)
+check('专注走完 → 提醒以 user 消息送进起钟会话', () => {
+  assert.equal(agentStub.followups.length, 1)
+  const msg = agentStub.followups[0]
+  assert.equal(msg.role, 'user')
+  assert.match(msg.content[0].text, /番茄钟·自动提醒/)
+  assert.match(msg.content[0].text, /25 分钟/)
+  assert.equal(msg.source.kind, 'plugin')
+})
+check('自动摆的休息继承会话标记(链条不断)', () => {
+  const t = JSON.parse(nudgeRes.body).state.timer
+  assert.equal(t.kind, 'break')
+  assert.equal(t.sessionId, 'sess-A')
+})
+
+// 关掉开关:休息走完不再提醒
+await post({ op: 'settings.update', patch: { notifySessionOnTimerEnd: false } })
+const rawDesk2 = JSON.parse(readFileSync(deskPath, 'utf8'))
+rawDesk2.timer.endsAt = Date.now() - 1000
+writeFileSync(deskPath, JSON.stringify(rawDesk2))
+await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, fakeRes())
+check('开关关闭后不再提醒', () => assert.equal(agentStub.followups.length, 1))
+
+// POST timer.start(客户端手动路径)不受门禁限制,也不带会话标记
+const pUser = await post({ op: 'timer.start', minutes: 5, name: '手动起钟' })
+check('手动起钟不需要挂卡', () => {
+  const body = JSON.parse(pUser.body)
+  assert.equal(body.ok, true)
+  assert.equal(body.state.timer.by, null)
+})
+await post({ op: 'timer.reset' })
+await run('remove', { title: '门禁冒烟卡' })
+
+// ---- 每日计划表:数据校验在 desk.js 一处,快照带宿主算好的 dayPlan
+const dp1 = await post({ op: 'dailyPlan.update', patch: {
+  enabled: true,
+  items: [
+    '这行不是对象,会被丢掉',
+    { start: '08:00', end: '09:30', label: '背单词', subject: '英语' },
+    { start: '09:30', end: '08:00', label: '起终写反的', subject: '' },
+    { start: '25:00', end: '26:00', label: '非法时刻的' },
+  ],
+} })
+check('POST dailyPlan.update 200', () => assert.equal(dp1.code, 200))
+check('计划表坏行被丢、好行保留', () => {
+  const dp = JSON.parse(dp1.body).state.dailyPlan
+  assert.equal(dp.enabled, true)
+  assert.equal(dp.items.length, 1)
+  assert.equal(dp.items[0].label, '背单词')
+})
+check('快照带 dayPlan(current/next 形状)', () => {
+  const j = JSON.parse(dp1.body)
+  assert.ok(Array.isArray(j.dayPlan.items) && j.dayPlan.items.length === 1)
+  assert.ok(j.dayPlan.current === null || j.dayPlan.current.label === '背单词')
+})
+await post({ op: 'dailyPlan.update', patch: { enabled: false } })
+
 // ---- 复习 / 复盘 / Markdown
 const p7 = await post({ op: 'review.grade', title: 'POST 加的卡', grade: 'easy' })
 check('POST review.grade 200', () => assert.equal(p7.code, 200))
@@ -342,6 +437,7 @@ globalThis.fetch = async () => ({
       journal: {},
       timer: null,
       settings: { focusMin: 25, shortBreakMin: 5, longBreakMin: 15, roundsBeforeLong: 4, dailyGoalMin: 180, streakMin: 10, injectPrompt: true, subjects: [], heatmapWeeks: 26, reviewQueueSize: 10 },
+      dailyPlan: { enabled: true, float: true, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
       createdAt: 0,
       updatedAt: 0,
     },
@@ -350,6 +446,7 @@ globalThis.fetch = async () => ({
     streak: 0,
     focusRoundsToday: 0,
     review: { scheduled: 0, due: 0, dueToday: 0, overdue: 0, doneToday: 0, weekDone: 0 },
+    dayPlan: { current: null, next: null, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
     due: [],
     tags: [],
     weekly: { weeks: [], goalWeekly: 1260 },
@@ -400,12 +497,12 @@ const clientCtx = {
 
 check('client apply() 不抛', () => clientMod.apply(clientCtx))
 // 两个面板:学习工作台 + 单词打卡,各自一对 sidebar.panellist / main
-check('注册了 6 个槽位(两个面板 + 迷你条 + 设置页)', () => {
+check('注册了 7 个槽位(两个面板 + 两个悬浮件 + 设置页)', () => {
   const names = clientRegistered.slots.map((s) => s.slotName).sort()
-  assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
+  assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
 })
 check('inject 的槽位名与注册的一致', () => {
-  assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
+  assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
 })
 check('两个侧栏面板的 id 与两个 main 的 key 逐一对齐', () => {
   const ids = clientRegistered.slots.filter((x) => x.slotName === 'sidebar.panellist').map((x) => x.id).sort()
@@ -443,9 +540,13 @@ check('每个槽位的 factory 都用 Boundary 包了一层', () => {
     assert.equal(node.__el.name, 'Boundary', `${s.slotName} 没被 Boundary 包住,实际是 ${node.__el && node.__el.name}`)
   }
 })
-check('Boundary 的 where 标签能区分两个面板', () => {
+check('Boundary 的 where 标签能区分两个面板与两个悬浮件', () => {
   const wheres = clientRegistered.slots.map((s) => s.factory({ size: 18 }).props.where).sort()
-  assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
+  assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'shell.overlay.plandock', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
+})
+check('两个 shell.overlay 的 id 不冲突(宿主按 id 挂槽位)', () => {
+  const ids = clientRegistered.slots.filter((x) => x.slotName === 'shell.overlay').map((x) => x.id).sort()
+  assert.deepEqual(ids, ['study-desk-mini', 'study-desk-plan'])
 })
 check('注入了一份 style', () => assert.ok(clientRegistered.effects.length > 0))
 
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..9b66625
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,23 @@
+
+  dsh-study-desk · 学习工作台
+  
+  
+    
+      
+      
+    
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
diff --git a/client.js b/client.js
index db5ea91..3b224a5 100644
--- a/client.js
+++ b/client.js
@@ -12,7 +12,7 @@
  * 挂载点:
  *   sidebar.panellist  → 左侧栏图标(id 与 main 的 key 同名,侧栏自动 dispatch)
  *   main (keyed)       → 整页工作台
- *   shell.overlay      → 右下角常驻迷你计时条(跨面板存活)
+ *   shell.overlay      → 右下角常驻迷你计时条 + 每日计划表悬浮件(跨面板存活,各一个槽位)
  *   settings.section   → 「学习工作台」设置页
  */
 
@@ -48,6 +48,8 @@ window.__ModuleLoader__.load({
     // 单词打卡是第二个独立面板:侧栏一项 + main 一格,id 与 main 的 key 必须同名
     const BBCDC_PANEL_ID = 'study-desk-bbdc'
     const MINI_POS_KEY = 'dsh-study-desk:mini-pos'
+    // 每日计划表悬浮件的落点:和迷你条一样是纯本地偏好,不占 desk.json
+    const PLAN_POS_KEY = 'dsh-study-desk:plan-pos'
 
     const STATUS_META = [
       { id: 'todo', label: '待办', latin: 'TODO' },
@@ -135,6 +137,22 @@ window.__ModuleLoader__.load({
       return `${d} 天后`
     }
 
+    /** 快捷键只在「没在打字」时生效:输入框、下拉、可编辑元素里的按键是内容,不是指令。 */
+    function isTypingTarget(e) {
+      const t = e && e.target
+      if (!t || !t.tagName) return false
+      const tag = String(t.tagName).toLowerCase()
+      return tag === 'input' || tag === 'textarea' || tag === 'select' || t.isContentEditable === true
+    }
+
+    /** 时:分。当天明细里每一段专注的起点。 */
+    function hhmm(ts) {
+      const d = new Date(Number(ts) || 0)
+      if (!Number.isFinite(d.getTime())) return '—'
+      const p = (n) => String(n).padStart(2, '0')
+      return `${p(d.getHours())}:${p(d.getMinutes())}`
+    }
+
     // -----------------------------------------------------------------------
     // 全局 store:宿主是唯一真相源,这里只缓存最近一次快照
     // -----------------------------------------------------------------------
@@ -142,7 +160,7 @@ window.__ModuleLoader__.load({
     const store = {
       ready: false,
       error: null,
-      state: { tasks: [], sessions: [], reviews: [], journal: {}, timer: null, settings: {} },
+      state: { tasks: [], sessions: [], reviews: [], journal: {}, timer: null, settings: {}, dailyPlan: { enabled: false, float: false, items: [] } },
       minutes: {},
       todayMinutes: 0,
       streak: 0,
@@ -153,6 +171,8 @@ window.__ModuleLoader__.load({
       forecast: { days: [], later: 0, total: 0, max: 0 },
       focusPie: null,
       plan: { items: [], due: 0, cap: 10, advice: null },
+      // 每日计划表:宿主算好「现在/下一场」,这里只缓存
+      dayPlan: { current: null, next: null, items: [] },
       tags: [],
       weekly: { weeks: [], goalWeekly: 0 },
       journalLog: [],
@@ -190,6 +210,7 @@ window.__ModuleLoader__.load({
         this.forecast = json.forecast || this.forecast
         this.focusPie = json.focusPie || this.focusPie
         this.plan = json.plan || this.plan
+        this.dayPlan = json.dayPlan || this.dayPlan
         this.tags = json.tags || []
         this.weekly = json.weekly || this.weekly
         this.journalLog = json.journal || []
@@ -430,8 +451,21 @@ window.__ModuleLoader__.load({
         'aria-hidden': true,
       }
       const paths = {
-        board: ['M3 4.5h14v11H3z', 'M3 8.5h14', 'M7.5 8.5v7'],
+        board: ['M3 4.5h14v11H3z', 'M3 8.5h14', 'M7.6 8.5v7', 'M12.3 8.5v7', { d: 'M13.4 10.75h2.5v2.5h-2.5z', fill: true }],
+        // 工作台侧栏入口:任务卡 + 计时徽章(品牌款是番茄,侧栏这枚讲「卡上的时间」)。
+        // 卡的右下角在徽章处断开口,避免两条描边打架。
+        deskcard: [
+          'M13 3.5H6A1.5 1.5 0 0 0 4.5 5v10A1.5 1.5 0 0 0 6 16.5h4.9',
+          'M14.5 9.2V5A1.5 1.5 0 0 0 13 3.5',
+          'M7.5 7.5h4',
+          'M7.5 10.5h3',
+          'M18.3 13.5a4.3 4.3 0 1 1-8.6 0 4.3 4.3 0 0 1 8.6 0z',
+          'M14 13.3v-2.1',
+          'M14 13.5l1.7 1.2',
+        ],
         clock: ['M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z', 'M10 6.5v4l2.5 1.5'],
+        // 每日计划表:日历框 + 两个吊耳 + 指针(区别于纯时钟,讲「时间段」)
+        plancal: ['M3.5 6h13v11h-13z', 'M3.5 9h13', 'M7 4v2.5', 'M13 4v2.5', 'M10 11v2.5', 'M10 13.5l1.8 1.3'],
         play: ['M6.5 4.8v10.4l8-5.2z'],
         pause: ['M7 4.8v10.4', 'M13 4.8v10.4'],
         stop: ['M6 6h8v8H6z'],
@@ -448,10 +482,20 @@ window.__ModuleLoader__.load({
         down: ['M10 3.5v9', 'M6.2 8.8 10 12.6l3.8-3.8', 'M4 16.5h12'],
         up: ['M10 12.5v-9', 'M6.2 7.2 10 3.4l3.8 3.8', 'M4 16.5h12'],
         tag: ['M10.6 3.5H16v5.4l-8 8-5.4-5.4z', 'M13.1 6.4h.01'],
+        // 词书打卡:本子 + 书脊 + 封面上的勾(原先是通用标签形,认不出「打卡」)
+        book: ['M5 3.5h10.5a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5A1.5 1.5 0 0 1 3.5 15V5A1.5 1.5 0 0 1 5 3.5z', 'M6.5 3.5v13', 'M9.2 10.3l1.7 1.7 3-3.6'],
         search: ['M8.8 3.4a5.4 5.4 0 1 0 0 10.8 5.4 5.4 0 0 0 0-10.8z', 'M12.8 12.8l3.7 3.7'],
       }
       const p = paths[name] || paths.board
-      return h('svg', common, p.map((d, i) => h('path', { key: i, d })))
+      // 路径项可以是字符串(描边)或 { d, fill: true }(填 currentColor,如看板里亮着的那格)
+      return h('svg', common, p.map((entry, i) => {
+        const item = typeof entry === 'string' ? { d: entry } : entry
+        return h('path', {
+          key: i, d: item.d,
+          fill: item.fill ? 'currentColor' : 'none',
+          stroke: item.fill ? 'none' : 'currentColor',
+        })
+      }))
     }
 
     function Progress({ value, max, tone }) {
@@ -597,7 +641,7 @@ window.__ModuleLoader__.load({
         setNameDraft('')
       }
 
-      return h('section', { className: 'sd-timer-card' }, [
+      return h('section', { className: 'sd-timer-card' + (timer && timer.running ? ' running' : '') }, [
         !timer ? h('div', { className: 'sd-timer-name-row', key: 'nr' }, [
           h('input', {
             key: 'n',
@@ -699,6 +743,8 @@ window.__ModuleLoader__.load({
       const settings = s.state.settings || {}
       const weeksCount = Number(settings.heatmapWeeks) || 26
       const [range, setRange] = React.useState(weeksCount)
+      // 点了哪个格子(dayKey):展开那天的专注明细。原始 sessions 列表,不是聚合 —— 汇总仍归 desk.js。
+      const [picked, setPicked] = React.useState(null)
 
       const byDay = React.useMemo(() => {
         const map = new Map()
@@ -755,6 +801,12 @@ window.__ModuleLoader__.load({
 
       const template = `22px repeat(${grid.cols.length}, 13px)`
 
+      const pickedRows = picked
+        ? s.state.sessions
+          .filter((x) => x.kind === 'focus' && dayKey(x.at) === picked)
+          .sort((a, b) => a.at - b.at)
+        : []
+
       return h('section', { className: 'sd-card-block' }, [
         h('div', { className: 'sd-block-head', key: 'h' }, [
           h('h2', { className: 'sd-block-title', key: 't' }, '学习热力图'),
@@ -782,8 +834,26 @@ window.__ModuleLoader__.load({
               className: 'sd-cell' + (d ? ' lv' + d.level + (d.key === dayKey(now) ? ' today' : '') : ' empty'),
               style: { gridColumn: wi + 2, gridRow: di + 2 },
               title: d ? `${d.key} · 专注 ${d.minutes} 分钟${d.count ? ` · ${d.count} 段` : ''}` : '',
+              onClick: d ? () => setPicked((cur) => (cur === d.key ? null : d.key)) : undefined,
             }))),
           ])),
+        picked
+          ? h('div', { className: 'sd-heat-day-detail', key: 'detail' }, [
+            h('div', { className: 'sd-heat-day-head', key: 'h' }, [
+              h('b', { key: 'd' }, picked + (picked === dayKey(now) ? '(今天)' : '')),
+              h('span', { className: 'sd-block-sub', key: 's' }, pickedRows.length
+                ? `${pickedRows.length} 段 · 共 ${fmtMinutes(pickedRows.reduce((m, x) => m + x.minutes, 0))}`
+                : '这天没有专注记录'),
+              h('button', { className: 'sd-plan-toggle', key: 'x', onClick: () => setPicked(null) }, '收起'),
+            ]),
+            ...pickedRows.map((x) => h('div', { className: 'sd-heat-day-row', key: x.id }, [
+              h('span', { className: 'sd-heat-day-time', key: 't' }, hhmm(x.at)),
+              h('span', { className: 'sd-heat-day-name', key: 'n', title: x.title || x.subject || '' },
+                x.title || x.subject || '未命名'),
+              h('span', { className: 'sd-heat-day-min', key: 'm' }, fmtShort(x.minutes)),
+            ])),
+          ])
+          : null,
         h('div', { className: 'sd-heat-foot', key: 'f' }, [
           h('span', { className: 'sd-heat-note', key: 'n' },
             `点亮条件:当天实际复习满 1 分钟;越亮 = 越接近每日 ${fmtMinutes(goal)} 的目标`),
@@ -1093,6 +1163,7 @@ window.__ModuleLoader__.load({
           note: task.note || '',
           subject: task.subject || '',
           estimateMin: task.estimateMin || 0,
+          dueDate: task.dueDate || '',
         })
         setEditing(true)
       }
@@ -1105,6 +1176,7 @@ window.__ModuleLoader__.load({
         if (draft.subject !== (task.subject || '')) patch.subject = draft.subject
         const est = Math.max(0, Math.floor(Number(draft.estimateMin) || 0))
         if (est !== (task.estimateMin || 0)) patch.estimateMin = est
+        if ((draft.dueDate || '') !== (task.dueDate || '')) patch.dueDate = draft.dueDate || null
         setEditing(false)
         setDraft(null)
         if (Object.keys(patch).length) onUpdate && onUpdate(task.id, patch)
@@ -1128,6 +1200,7 @@ window.__ModuleLoader__.load({
             field('title', '标题', { autoFocus: true }),
             field('subject', '科目'),
             field('estimateMin', '预计分钟', { type: 'number', min: 0 }),
+            field('dueDate', '截止', { type: 'date' }),
             h('label', { className: 'sd-edit-row', key: 'note' }, [
               h('span', { className: 'sd-edit-label', key: 'l' }, '备注'),
               h('textarea', {
@@ -1175,6 +1248,10 @@ window.__ModuleLoader__.load({
           : null,
         h('div', { className: 'sd-card-foot', key: 'foot' }, [
           task.subject ? h('span', { className: 'sd-tag', key: 's' }, task.subject) : null,
+          task.dueDate ? h('span', {
+            className: 'sd-due' + (daysUntil(task.dueDate, Date.now()) <= 0 ? ' late' : ''),
+            key: 'dd', title: `截止 ${task.dueDate}`,
+          }, `⏰ ${dueLabel(task.dueDate)}`) : null,
           spent ? h('span', { className: 'sd-spent', key: 'm', title: '累计投入' }, fmtShort(spent)) : null,
           task.estimateMin && !spent ? h('span', { className: 'sd-est', key: 'e', title: '预计' }, '~' + fmtShort(task.estimateMin)) : null,
           scheduled
@@ -1217,24 +1294,32 @@ window.__ModuleLoader__.load({
       ])
     }
 
-    function Composer({ status, subjects, onAdd }) {
+    function Composer({ status, subjects, onAdd, openSignal }) {
       const [open, setOpen] = React.useState(false)
       const [text, setText] = React.useState('')
       const [subject, setSubject] = React.useState('')
       const ref = React.useRef(null)
 
       React.useEffect(() => { if (open && ref.current) ref.current.focus() }, [open])
+      // 快捷键 n 叫它开:openSignal 从 0 起跳,只在变大时拉开(收起由自己的 Escape/取消管)
+      React.useEffect(() => { if (openSignal) setOpen(true) }, [openSignal])
 
       const commit = () => {
         const raw = text.trim()
         if (!raw) { setOpen(false); return }
-        // Obsidian 肌肉记忆:正文里写 #标签 就直接落成标签
+        // Obsidian 肌肉记忆:正文里写 #标签 就直接落成标签;@YYYY-MM-DD 设截止日期
         const tags = []
+        let dueDate = ''
         const title = raw.replace(/(?:^|\s)#([^\s#,,、]{1,24})/g, (m, tag) => { tags.push(tag); return '' })
+          .replace(/(?:^|\s)@(\d{4}-\d{2}-\d{2})(?=\s|$)/g, (m, d) => { dueDate = d; return '' })
           .replace(/\s+/g, ' ')
           .trim()
-        if (!title) { onAdd({ title: tags.join(' · '), subject, status }); setText(''); return }
-        onAdd({ title, subject, status, tags })
+        if (!title) {
+          onAdd({ title: tags.join(' · ') || (dueDate ? '截止 ' + dueDate : ''), subject, status, dueDate: dueDate || undefined })
+          setText('')
+          return
+        }
+        onAdd({ title, subject, status, tags, dueDate: dueDate || undefined })
         setText('')
         if (ref.current) ref.current.focus()
       }
@@ -1273,7 +1358,7 @@ window.__ModuleLoader__.load({
       ])
     }
 
-    function Column({ meta, tasks, total, minutes, subjects, hover, setHover, confirmId, setConfirmId, dragging, setDragging, onMove, onDelete, onUpdate, onAdd, onToggleReview, onPickTag, activeTags, filtered }) {
+    function Column({ meta, tasks, total, minutes, subjects, hover, setHover, confirmId, setConfirmId, dragging, setDragging, onMove, onDelete, onUpdate, onAdd, onToggleReview, onPickTag, activeTags, filtered, openSignal }) {
       return h('section', {
         className: 'sd-col' + (hover === meta.id ? ' hover' : ''),
         onDragOver: (e) => { e.preventDefault(); setHover(meta.id) },
@@ -1287,6 +1372,7 @@ window.__ModuleLoader__.load({
         },
       }, [
         h('div', { className: 'sd-col-head', key: 'h' }, [
+          h('span', { className: 'sd-col-dot sd-col-dot-' + meta.id, key: 'd' }),
           h('span', { className: 'sd-col-latin', key: 'l' }, meta.latin),
           h('span', { className: 'sd-col-label', key: 'n' }, meta.label),
           h('span', { className: 'sd-col-count', key: 'c' }, filtered ? `${tasks.length}/${total}` : String(tasks.length)),
@@ -1311,7 +1397,10 @@ window.__ModuleLoader__.load({
             activeTags,
           })),
           tasks.length ? null : h('div', { className: 'sd-col-empty', key: 'e' }, filtered ? '这一列没有符合过滤条件的卡' : (meta.id === 'done' ? '还没有完成的卡' : '空的')),
-          h(Composer, { key: 'composer', status: meta.id, subjects, onAdd }),
+          meta.id === 'done' && !filtered && total > tasks.length
+            ? h('div', { className: 'sd-col-more', key: 'more' }, `只摆最近 30 张 · 共 ${total} 张`)
+            : null,
+          h(Composer, { key: 'composer', status: meta.id, subjects, onAdd, openSignal }),
         ]),
       ])
     }
@@ -1325,9 +1414,30 @@ window.__ModuleLoader__.load({
       const [tags, setTags] = React.useState([])
       const [dueOnly, setDueOnly] = React.useState(false)
       const [q, setQ] = React.useState('')
+      // 快捷键 n 的信号:从 0 起跳,只发给待办列的 Composer
+      const [composeTick, setComposeTick] = React.useState(0)
       const subjects = (s.state.settings && s.state.settings.subjects) || []
       const allTags = s.tags || []
 
+      // 键盘流:/ 聚焦搜索,n 打开待办列的新建框。
+      // 打字的时候这些是字符不是指令,isTypingTarget 把输入中的情况全挡掉。
+      React.useEffect(() => {
+        const onKey = (e) => {
+          if (e.metaKey || e.ctrlKey || e.altKey) return
+          if (isTypingTarget(e)) return
+          if (document.querySelector('.sd-focus-overlay')) return // 过卡模式开着时,键盘是它的
+          if (e.key === '/') {
+            const el = document.querySelector('.sd-search-input')
+            if (el) { e.preventDefault(); el.focus() }
+          } else if (e.key === 'n' || e.key === 'N') {
+            e.preventDefault()
+            setComposeTick((v) => v + 1)
+          }
+        }
+        window.addEventListener('keydown', onKey)
+        return () => window.removeEventListener('keydown', onKey)
+      }, [])
+
       const move = (id, status, beforeId) => store.apply('task.move', { id, status, beforeId: beforeId || undefined })
       const del = (id) => store.apply('task.delete', { id })
       const update = (id, patch) => store.apply('task.update', { id, patch })
@@ -1413,6 +1523,7 @@ window.__ModuleLoader__.load({
             onToggleReview: toggleReview,
             onPickTag: pickTag,
             activeTags: tags,
+            openSignal: meta.id === 'todo' ? composeTick : 0,
           })
         })),
       ])
@@ -1676,6 +1787,81 @@ window.__ModuleLoader__.load({
     // 今日复习队列
     // -----------------------------------------------------------------------
 
+    // -----------------------------------------------------------------------
+    // 过卡模式:全屏一次一张。备注默认藏着(回背类卡先自己回想),空格/点击翻开。
+    // 键盘监听挂在浮层元素上并 stopPropagation —— Board 的 / 与 n、队列的 1/2/3 都在
+    // window 上,事件从浮层冒到 window 之前就会被拦下,不会两头都打分。
+    // -----------------------------------------------------------------------
+
+    function FocusReview({ session, setSession, onClose, onGrade }) {
+      const cur = session.items[session.i] || null
+      const ref = React.useRef(null)
+      React.useEffect(() => { if (ref.current && ref.current.focus) ref.current.focus() }, [])
+
+      const advance = () => setSession((f) => ({ ...f, i: f.i + 1, revealed: false }))
+      const reveal = () => setSession((f) => ({ ...f, revealed: true }))
+      const grade = (g) => { if (cur) onGrade(cur.id, g); advance() }
+
+      const onKeyDown = (e) => {
+        e.stopPropagation()
+        if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
+        if (e.key === ' ' || e.key === 'Enter') {
+          e.preventDefault()
+          if (!session.revealed) reveal()
+          return
+        }
+        if (e.key === 'ArrowRight') { e.preventDefault(); advance(); return }
+        const g = { 1: 'again', 2: 'good', 3: 'easy' }[e.key]
+        if (g) { e.preventDefault(); grade(g) }
+      }
+
+      const done = !cur
+      return toTopLayer(h('div', {
+        className: 'sd-focus-overlay', ref, tabIndex: 0, onKeyDown,
+        role: 'dialog', 'aria-label': '复习过卡模式',
+      }, [
+        h('div', { className: 'sd-focus-card', key: 'c' }, done
+          ? [
+            h('div', { className: 'sd-focus-title', key: 't' }, '这一批过完了'),
+            h('div', { className: 'sd-focus-meta', key: 'm' }, `共 ${session.items.length} 张。打分记录都进了排期,明天见。`),
+            h('div', { className: 'sd-focus-acts', key: 'a' }, [
+              h('button', { key: 'ok', className: 'sd-textbtn primary', onClick: onClose }, '完成'),
+              h('button', { key: 'more', className: 'sd-textbtn', onClick: () => setSession({ items: session.items, i: 0, revealed: false }) }, '再过一遍'),
+            ]),
+          ]
+          : [
+            h('div', { className: 'sd-focus-progress', key: 'p' },
+              `${session.i + 1} / ${session.items.length} · 空格 显示备注 · → 跳过 · Esc 退出`),
+            h('div', { className: 'sd-focus-title', key: 't' }, cur.title),
+            h('div', { className: 'sd-focus-meta', key: 'm' }, [
+              cur.subject ? h('span', { className: 'sd-tag', key: 's' }, cur.subject) : null,
+              h('span', { className: 'sd-due', key: 'd' }, dueLabel(cur.review.due)),
+              h('span', { className: 'sd-review-cap', key: 'r' },
+                `第 ${cur.review.reps + 1} 遍 · 上次间隔 ${cur.review.intervalDays} 天 · 难度 ${cur.review.ease}`),
+              cur.curve ? h('span', { className: 'sd-review-curve', key: 'cv' }, [
+                h(MemoryCurve, { key: 'g', curve: cur.curve, width: 66, height: 20 }),
+                h('span', { className: 'sd-review-mem', key: 'mem' }, `记忆 ${cur.curve.todayRetention}%`),
+              ]) : null,
+            ]),
+            cur.note
+              ? (session.revealed
+                ? h('div', { className: 'sd-focus-note', key: 'n' }, cur.note)
+                : h('button', { className: 'sd-textbtn', key: 'n', onClick: reveal }, '显示备注(空格)'))
+              : null,
+            h('div', { className: 'sd-focus-acts', key: 'a' }, [
+              ...GRADES.map((g) => h('button', {
+                key: g, className: 'sd-grade lg ' + g,
+                title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍(快捷键 1)'
+                  : g === 'easy' ? '很简单:间隔拉长一点(快捷键 3)'
+                    : '记得:按难度推进间隔(快捷键 2)',
+                onClick: () => grade(g),
+              }, GRADE_LABEL[g])),
+              h('button', { key: 'skip', className: 'sd-textbtn', title: '先放着,等下再说(快捷键 →)', onClick: advance }, '跳过 →'),
+            ]),
+          ]),
+      ]))
+    }
+
     function ReviewQueue() {
       const s = useStore()
       const st = s.review || {}
@@ -1685,9 +1871,35 @@ window.__ModuleLoader__.load({
         setBusy(id)
         Promise.resolve(store.apply('review.grade', { id, grade: g })).finally(() => setBusy(null))
       }
+      // 缓一天:排期推到明天,难度/遍数/间隔原样保留(review.schedule 本来就认任意 due)
+      const snooze = (id) => {
+        setBusy(id)
+        Promise.resolve(store.apply('review.schedule', { id, due: dayKey(addDays(Date.now(), 1)) }))
+          .finally(() => setBusy(null))
+      }
       const limit = Number(s.state.settings.reviewQueueSize) || 10
       const shown = queue.slice(0, limit)
 
+      // 1/2/3 给队首那张打分:队列顺序就是 desk.js 排好的「最该先过」。
+      // queue 的引用只在快照到达时才换,所以这条监听不会跟着 500ms 的钟摆重挂。
+      React.useEffect(() => {
+        const onKey = (e) => {
+          if (e.metaKey || e.ctrlKey || e.altKey) return
+          if (isTypingTarget(e)) return
+          if (document.querySelector('.sd-focus-overlay')) return // 过卡模式开着时,按键归它自己管
+          const g = { 1: 'again', 2: 'good', 3: 'easy' }[e.key]
+          if (!g || busy) return
+          const first = queue.slice(0, limit)[0]
+          if (first) grade(first.id, g)
+        }
+        window.addEventListener('keydown', onKey)
+        return () => window.removeEventListener('keydown', onKey)
+      }, [queue, limit, busy])
+
+      // 过卡模式:一次一张全屏过,session 在进入那一刻定住队列(打分后快照会变,不能跟着漂)
+      const [focus, setFocus] = React.useState(null)
+      const startFocus = () => setFocus({ items: queue.slice(), i: 0, revealed: false })
+
       return h('section', { className: 'sd-card-block sd-review' }, [
         h('div', { className: 'sd-block-head', key: 'h' }, [
           h('h2', { className: 'sd-block-title', key: 't' }, [
@@ -1695,8 +1907,17 @@ window.__ModuleLoader__.load({
             h('span', { key: 'x' }, ' 今日复习'),
           ]),
           h('span', { className: 'sd-block-sub', key: 's' }, st.scheduled
-            ? `排期 ${st.scheduled} 张 · 待过 ${st.due}(逾期 ${st.overdue})· 今天已打分 ${st.doneToday} 次 · 本周 ${st.weekDone} 次`
+            ? `排期 ${st.scheduled} 张 · 待过 ${st.due}(逾期 ${st.overdue})· 今天已打分 ${st.doneToday} 次 · 本周 ${st.weekDone} 次${shown.length ? ' · 按 1/2/3 过第一张' : ''}`
             : '还没有卡进入复习循环'),
+          shown.length
+            ? h('div', { className: 'sd-row sd-row-end', key: 'b' }, [
+              h('button', {
+                className: 'sd-pill sm', key: 'f',
+                title: '一张一张过大卡:1/2/3 打分,空格显示备注,→ 跳过,Esc 退出',
+                onClick: startFocus,
+              }, '过卡模式'),
+            ])
+            : null,
         ]),
         h(PlanStrip, { key: 'p' }),
         shown.length
@@ -1717,15 +1938,22 @@ window.__ModuleLoader__.load({
                 h('span', { className: 'sd-review-cap', key: 'r' }, `第 ${t.review.reps + 1} 遍 · 上次间隔 ${t.review.intervalDays} 天 · 难度 ${t.review.ease}`),
               ]),
             ]),
-            h('div', { className: 'sd-review-acts', key: 'c' }, GRADES.map((g) => h('button', {
-              key: g,
-              className: 'sd-grade ' + g,
-              disabled: busy === t.id,
-              title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍'
-                : g === 'easy' ? '很简单:间隔拉长一点'
-                  : '记得:按难度推进间隔',
-              onClick: () => grade(t.id, g),
-            }, GRADE_LABEL[g]))),
+            h('div', { className: 'sd-review-acts', key: 'c' }, [
+              ...GRADES.map((g) => h('button', {
+                key: g,
+                className: 'sd-grade ' + g,
+                disabled: busy === t.id,
+                title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍'
+                  : g === 'easy' ? '很简单:间隔拉长一点'
+                    : '记得:按难度推进间隔',
+                onClick: () => grade(t.id, g),
+              }, GRADE_LABEL[g])),
+              h('button', {
+                key: 'snooze', className: 'sd-textbtn sm', disabled: busy === t.id,
+                title: '今天不想过它:排期改到明天,难度与间隔原样保留',
+                onClick: () => snooze(t.id),
+              }, '缓一天'),
+            ]),
           ])))
           : h('div', { className: 'sd-empty-block', key: 'e' }, st.scheduled
             ? '今天该复习的都过完了。想加练就把更多卡拉进复习循环(卡片右下角的 ↻)。'
@@ -1735,6 +1963,10 @@ window.__ModuleLoader__.load({
         queue.length > shown.length
           ? h('div', { className: 'sd-review-more', key: 'm' }, `队列里还有 ${queue.length - shown.length} 张没摆出来(每次摆几张在设置页里调)`)
           : null,
+        focus ? h(FocusReview, {
+          key: 'focus', session: focus, setSession: setFocus,
+          onClose: () => setFocus(null), onGrade: grade,
+        }) : null,
       ])
     }
 
@@ -1748,6 +1980,23 @@ window.__ModuleLoader__.load({
       const goal = (s.weekly && s.weekly.goalWeekly) || 0
       const max = Math.max(goal, ...weeks.map((w) => w.minutes), 60)
       const cur = weeks[weeks.length - 1]
+      // 复制周报:文本由宿主的 weeklyText 生成(和模型看到的同一份),这里只管进剪贴板
+      const [copied, setCopied] = React.useState('')
+      const copy = async () => {
+        try {
+          const res = await fetch(API, {
+            method: 'POST', headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ op: 'weekly.text' }),
+          })
+          const json = await res.json()
+          if (!json || !json.text) throw new Error((json && json.error) || '没拿到周报文本')
+          await navigator.clipboard.writeText(json.text)
+          setCopied('已复制,去 Obsidian 粘贴即可')
+        } catch (error) {
+          setCopied('复制失败:' + String((error && error.message) || error))
+        }
+        setTimeout(() => setCopied(''), 3000)
+      }
 
       return h('section', { className: 'sd-card-block' }, [
         h('div', { className: 'sd-block-head', key: 'h' }, [
@@ -1755,6 +2004,10 @@ window.__ModuleLoader__.load({
           h('span', { className: 'sd-block-sub', key: 's' }, cur
             ? `本周 ${fmtMinutes(cur.minutes)}${goal ? ` / 目标 ${fmtMinutes(goal)}` : ''}${cur.deltaPct === null ? '' : ` · 比上周${cur.deltaPct >= 0 ? '+' : ''}${cur.deltaPct}%`}`
             : '还没有记录'),
+          weeks.length ? h('div', { className: 'sd-row sd-row-end', key: 'c' }, [
+            copied ? h('span', { className: 'sd-block-sub', key: 'n' }, copied) : null,
+            h('button', { key: 'b', className: 'sd-textbtn sm', onClick: copy }, '复制周报'),
+          ]) : null,
         ]),
         weeks.length
           ? h('div', { className: 'sd-weeks', key: 'w' }, weeks.map((w) => {
@@ -1796,6 +2049,25 @@ window.__ModuleLoader__.load({
       React.useEffect(() => { setDraft(saved) }, [today])
 
       const save = () => store.apply('journal.set', { text: draft, date: today })
+
+      // 草稿曾经只活在组件状态里:切去单词打卡面板再回来就没了。现在停手 2 秒自动落盘,
+      // 卸载(换面板)那一刻再把没存上的部分补存一次;手动保存与撤销按钮照旧。
+      const latest = React.useRef(null)
+      latest.current = { draft, saved, today }
+      React.useEffect(() => {
+        if (!dirty) return
+        const id = setTimeout(() => {
+          const cur = latest.current
+          store.apply('journal.set', { text: cur.draft, date: cur.today })
+        }, 2000)
+        return () => clearTimeout(id)
+      }, [draft, saved])
+      React.useEffect(() => () => {
+        const cur = latest.current
+        if (cur && cur.draft !== cur.saved) {
+          store.apply('journal.set', { text: cur.draft, date: cur.today })
+        }
+      }, [])
       const recent = (s.journalLog || []).filter((x) => x.date !== today).slice(0, 6)
 
       return h('section', { className: 'sd-card-block sd-journal' }, [
@@ -1823,7 +2095,7 @@ window.__ModuleLoader__.load({
             className: 'sd-textbtn primary', disabled: !dirty, onClick: save,
           }, dirty ? '保存' : '已保存'),
           dirty ? h('button', { className: 'sd-textbtn', onClick: () => setDraft(saved) }, '撤销') : null,
-          h('span', { className: 'sd-block-sub', key: 'h' }, 'Ctrl+回车保存'),
+          h('span', { className: 'sd-block-sub', key: 'h' }, '停手 2 秒自动存 · Ctrl+回车立即存'),
         ]),
         recent.length
           ? h('div', { className: 'sd-journal-list', key: 'l' }, recent.map((x) => h('div', { className: 'sd-journal-item', key: x.date }, [
@@ -1850,10 +2122,11 @@ window.__ModuleLoader__.load({
             h(Head, { key: 'head', now }),
             h(TimerPanel, { key: 'timer', now }),
             s.error ? h('div', { className: 'sd-error', key: 'err' }, '工作台数据读取失败:' + s.error) : null,
+            h(DayPlanSection, { key: 'dayplan', now }),
             h(ReviewQueue, { key: 'review' }),
             h('div', { className: 'sd-section-title', key: 'st' }, [
               h('span', { key: 'a' }, '待办墙'),
-              h('span', { className: 'sd-section-hint', key: 'b' }, '拖动卡片换列 · 输入 #标签 直接打标 · ↻ 把卡拉进复习循环'),
+              h('span', { className: 'sd-section-hint', key: 'b' }, '拖动卡片换列 · #标签 直接打标 · @YYYY-MM-DD 设截止 · ↻ 拉进复习循环 · / 搜索 · n 新建'),
             ]),
             h(Board, { key: 'board' }),
             h('div', { className: 'sd-two', key: 'two' }, [
@@ -1871,6 +2144,47 @@ window.__ModuleLoader__.load({
       ])
     }
 
+    // -----------------------------------------------------------------------
+    // 每日计划表(主面板分区):哪个时间段做什么事。
+    // 区间数据与排序全部由宿主发来(desk.js 的 dailyPlanNow 单点算),
+    // 这里只做钟表运算——当前分钟落在哪个区间里,跟画倒计时是一档的事。
+    // -----------------------------------------------------------------------
+
+    /** 'HH:MM' → 当天分钟数。纯钟表换算,客户端/宿主各有一份(宿主的带校验)。 */
+    function pmin(hhmm) {
+      const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '').trim())
+      if (!m) return NaN
+      return Number(m[1]) * 60 + Number(m[2])
+    }
+
+    function DayPlanSection({ now }) {
+      const s = useStore()
+      const dp = (s.state && s.state.dailyPlan) || null
+      if (!dp || dp.enabled !== true || !Array.isArray(dp.items) || !dp.items.length) return null
+      const d = new Date(now)
+      const mins = d.getHours() * 60 + d.getMinutes()
+      const rows = dp.items.map((it) => {
+        const from = pmin(it.start)
+        const to = pmin(it.end)
+        const state = mins >= from && mins < to ? 'now' : mins >= to ? 'past' : 'todo'
+        return { it, state, left: state === 'now' ? Math.max(1, to - mins) : 0 }
+      })
+      return h('div', { key: 'wrap' }, [
+        h('div', { className: 'sd-section-title' }, [
+          h('span', { key: 'a' }, '每日计划表'),
+          h('span', { className: 'sd-section-hint', key: 'b' }, '时间段在设置页编辑;开着的时候模型也会从系统提示里知道现在该做什么'),
+        ]),
+        h('div', { className: 'sd-plan-card' }, rows.map(({ it, state, left }) =>
+          h('div', { className: 'sd-plan-row ' + state, key: it.id }, [
+            h('span', { className: 'sd-plan-time', key: 't' }, `${it.start}~${it.end}`),
+            h('span', { className: 'sd-plan-label', key: 'l', title: it.label }, it.label),
+            it.subject ? h('span', { className: 'sd-plan-subject', key: 's' }, it.subject) : null,
+            state === 'now' ? h('span', { className: 'sd-plan-flag', key: 'x' }, `剩 ${left} 分钟`) : null,
+            state === 'past' ? h('span', { className: 'sd-plan-flag past', key: 'x' }, '已过') : null,
+          ]))),
+      ])
+    }
+
     // -----------------------------------------------------------------------
     // 常驻迷你计时条(shell.overlay)
     // -----------------------------------------------------------------------
@@ -1923,7 +2237,12 @@ window.__ModuleLoader__.load({
 
     // 悬浮卡片被谁盖住,在浏览器里根本看不见:拿命中测试结果回传宿主写盘。
     // reportDiag 按 kind+where+message 去重,所以反复探也不会把诊断文件灌爆。
-    function probeOverlayHit(el) {
+    // 被盖分两档:盖的人住在宿主模态层(z≥1000:模态框 1000、菜单/气泡 1100)里,
+    // 是悬浮件「压在模态下」这条设计取舍的必然结果,不算事故 —— 单独记 -under-modal;
+    // 普通内容盖上来才是真回归,继续记 -covered。2026-11-05 那次误报就是设置页
+    // (z≥1000)的账号卡片盖住了它,翻诊断时差点当 bug 修。
+    // tag 区分两个悬浮件(mini=迷你条,plandock=每日计划表),诊断里能分开看。
+    function probeOverlayHit(el, tag = 'mini') {
       if (!el || typeof document.elementFromPoint !== 'function') return
       const rect = el.getBoundingClientRect()
       if (!rect.width || !rect.height) return
@@ -1932,13 +2251,24 @@ window.__ModuleLoader__.load({
       const top = document.elementFromPoint(cx, cy)
       if (!top || top === el || el.contains(top)) return
       const chain = []
-      for (let node = top; node && chain.length < 3; node = node.parentElement) {
-        const cls = typeof node.className === 'string' && node.className.trim()
-          ? '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.')
-          : ''
-        chain.push(node.tagName.toLowerCase() + cls)
+      let modalZ = 0
+      for (let node = top, i = 0; node && node !== document.body; node = node.parentElement, i += 1) {
+        if (i < 3) {
+          const cls = typeof node.className === 'string' && node.className.trim()
+            ? '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.')
+            : ''
+          chain.push(node.tagName.toLowerCase() + cls)
+        }
+        if (!modalZ) {
+          const z = Number(window.getComputedStyle(node).zIndex)
+          if (Number.isFinite(z) && z >= 1000) modalZ = z
+        }
       }
-      reportDiag({ kind: 'mini-covered', where: 'mini.命中测试', message: `中心 ${cx},${cy} 命中的是 ${chain.join(' < ')}` })
+      reportDiag({
+        kind: modalZ ? `${tag}-under-modal` : `${tag}-covered`,
+        where: `${tag}.命中测试`,
+        message: `中心 ${cx},${cy} 命中的是 ${chain.join(' < ')}` + (modalZ ? `(宿主模态层 z=${modalZ},设计内让位)` : ''),
+      })
     }
 
     function MiniBar() {
@@ -2033,8 +2363,8 @@ window.__ModuleLoader__.load({
       }, [])
 
       React.useEffect(() => {
-        probeOverlayHit(rootRef.current)
-        const id = setInterval(() => probeOverlayHit(rootRef.current), 5000)
+        probeOverlayHit(rootRef.current, 'mini')
+        const id = setInterval(() => probeOverlayHit(rootRef.current, 'mini'), 5000)
         return () => clearInterval(id)
       }, [])
 
@@ -2144,6 +2474,182 @@ window.__ModuleLoader__.load({
       ]))
     }
 
+    // -----------------------------------------------------------------------
+    // 每日计划表悬浮件(shell.overlay 第二件)
+    // 与迷你条同一套坑位规矩:portal 到 body、pointer-events:auto、z-index:900、
+    // no-drag、clampMiniPos 顶部避开标题栏拖拽带、落点存 localStorage。
+    // 开关:dailyPlan.enabled 开整个功能,dailyPlan.float 单独管这件悬浮。
+    // -----------------------------------------------------------------------
+
+    function readPlanPos() {
+      try {
+        const raw = window.localStorage.getItem(PLAN_POS_KEY)
+        if (!raw) return null
+        const p = JSON.parse(raw)
+        if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) return { x: p.x, y: p.y }
+      } catch (error) { /* 读不到就当没挪过 */ }
+      return null
+    }
+
+    function writePlanPos(p) {
+      try {
+        if (!p) window.localStorage.removeItem(PLAN_POS_KEY)
+        else window.localStorage.setItem(PLAN_POS_KEY, JSON.stringify({ x: Math.round(p.x), y: Math.round(p.y) }))
+      } catch (error) { /* 写不进去就这一次不记 */ }
+    }
+
+    function PlanDock() {
+      const s = useStore()
+      const now = useTick(1000)
+      usePoll(60000)
+      const [open, setOpen] = React.useState(false)
+      const [hidden, setHidden] = React.useState(false)
+      const [pos, setPos] = React.useState(readPlanPos)
+      const dragRef = React.useRef(null)
+      const rootRef = React.useRef(null)
+      const justDraggedRef = React.useRef(false)
+      // 钩子全部走完再决定渲不渲染:开关翻动的瞬间不能少调钩子
+      const dp = (s.state && s.state.dailyPlan) || null
+      const live = Boolean(dp && dp.enabled === true && dp.float === true
+        && Array.isArray(dp.items) && dp.items.length > 0)
+
+      React.useEffect(() => {
+        setPos((current) => {
+          if (!current) return current
+          const el = rootRef.current
+          if (!el) return current
+          const rect = el.getBoundingClientRect()
+          return clampMiniPos(current, rect.width, rect.height)
+        })
+      }, [hidden, open])
+
+      React.useEffect(() => {
+        function onResize() {
+          setPos((current) => {
+            if (!current) return current
+            const el = rootRef.current
+            if (!el) return current
+            const rect = el.getBoundingClientRect()
+            return clampMiniPos(current, rect.width, rect.height)
+          })
+        }
+        window.addEventListener('resize', onResize)
+        return () => window.removeEventListener('resize', onResize)
+      }, [])
+
+      React.useEffect(() => {
+        probeOverlayHit(rootRef.current, 'plandock')
+        const id = setInterval(() => probeOverlayHit(rootRef.current, 'plandock'), 5000)
+        return () => clearInterval(id)
+      }, [])
+
+      if (!live) return null
+
+      const d = new Date(now)
+      const mins = d.getHours() * 60 + d.getMinutes()
+      const current = dp.items.find((it) => mins >= pmin(it.start) && mins < pmin(it.end)) || null
+      const next = dp.items.find((it) => pmin(it.start) > mins) || null
+      const leftMin = current ? Math.max(1, pmin(current.end) - mins) : 0
+
+      // 拖动手势与迷你条同一套:非按钮处按下起拖、挪 4px 内不算拖、双击回默认位
+      const dragHandlers = {
+        onPointerDown: (event) => {
+          if (event.button !== 0) return
+          if (event.target && event.target.closest && event.target.closest('button')) return
+          const el = event.currentTarget
+          const rect = el.getBoundingClientRect()
+          dragRef.current = {
+            dx: event.clientX - rect.left,
+            dy: event.clientY - rect.top,
+            w: rect.width,
+            h: rect.height,
+            sx: event.clientX,
+            sy: event.clientY,
+            moved: false,
+          }
+          try { el.setPointerCapture(event.pointerId) } catch (error) { /* 拿不到捕获也照样能拖 */ }
+        },
+        onPointerMove: (event) => {
+          const dr = dragRef.current
+          if (!dr) return
+          if (event.buttons === 0) { dragRef.current = null; return }
+          if (!dr.moved && Math.abs(event.clientX - dr.sx) + Math.abs(event.clientY - dr.sy) < 4) return
+          dr.moved = true
+          setPos(clampMiniPos({ x: event.clientX - dr.dx, y: event.clientY - dr.dy }, dr.w, dr.h))
+        },
+        onPointerUp: (event) => {
+          const dr = dragRef.current
+          dragRef.current = null
+          if (!dr) return
+          try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (error) { /* 同上 */ }
+          if (dr.moved) {
+            justDraggedRef.current = true
+            setTimeout(() => { justDraggedRef.current = false }, 0)
+            const nextPos = clampMiniPos({ x: event.clientX - dr.dx, y: event.clientY - dr.dy }, dr.w, dr.h)
+            setPos(nextPos)
+            writePlanPos(nextPos)
+          }
+        },
+        onPointerCancel: () => { dragRef.current = null },
+        onLostPointerCapture: () => { dragRef.current = null },
+        onDoubleClick: (event) => {
+          if (event.target && event.target.closest && event.target.closest('button')) return
+          setPos(null); writePlanPos(null)
+        },
+      }
+      const posStyle = pos ? { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' } : null
+
+      if (hidden) {
+        return toTopLayer(h('button', Object.assign({
+          ref: rootRef,
+          className: 'sd-plandock-fab',
+          title: '展开每日计划(可拖动,双击回右侧)',
+          onClick: () => { if (!justDraggedRef.current) setHidden(false) },
+          style: posStyle,
+        }, dragHandlers), h(Icon, { name: 'plancal', size: 14 })))
+      }
+
+      const rows = dp.items.map((it) => {
+        const from = pmin(it.start)
+        const to = pmin(it.end)
+        const state = mins >= from && mins < to ? 'now' : mins >= to ? 'past' : 'todo'
+        return { it, state, left: state === 'now' ? Math.max(1, to - mins) : 0 }
+      })
+
+      return toTopLayer(h('div', Object.assign({
+        ref: rootRef,
+        className: 'sd-plandock' + (open ? ' open' : ''),
+        style: posStyle,
+      }, dragHandlers), [
+        h('div', { className: 'sd-plandock-main', key: 'm' }, [
+          h('span', { key: 'g', className: 'sd-mini-grip', title: '按住这里拖动(双击回右侧)' }),
+          h('span', { key: 'i', className: 'sd-plandock-icon' }, h(Icon, { name: 'plancal', size: 14 })),
+          h('span', { key: 'c', className: 'sd-plandock-now' },
+            current
+              ? `${current.label} · 剩 ${leftMin} 分钟`
+              : next
+                ? `下场 ${next.start} ${next.label}`
+                : '今日无安排'),
+          h('button', {
+            key: 'x',
+            className: 'sd-iconbtn sm',
+            title: open ? '收起' : '展开全天',
+            onClick: () => setOpen((v) => !v),
+          }, h(Icon, { name: open ? 'collapse' : 'expand', size: 14 })),
+        ]),
+        open ? h('div', { className: 'sd-plandock-panel', key: 'p' }, [
+          ...rows.map(({ it, state, left }) => h('div', { className: 'sd-plan-row ' + state, key: it.id }, [
+            h('span', { className: 'sd-plan-time', key: 't' }, `${it.start}~${it.end}`),
+            h('span', { className: 'sd-plan-label', key: 'l', title: it.label }, it.label),
+            state === 'now' ? h('span', { className: 'sd-plan-flag', key: 'x' }, `剩 ${left} 分`) : null,
+          ])),
+          h('div', { className: 'sd-mini-row sd-mini-actions', key: 'a' }, [
+            h('button', { key: 'hide', className: 'sd-textbtn', onClick: () => { setOpen(false); setHidden(true) } }, '收起成小圆点'),
+          ]),
+        ]) : null,
+      ]))
+    }
+
     // -----------------------------------------------------------------------
     // 设置页
     // -----------------------------------------------------------------------
@@ -2204,6 +2710,109 @@ window.__ModuleLoader__.load({
       ])
     }
 
+    /**
+     * 科目编辑:settings.subjects 是 [{id,label}],每次改动整份随 settings.update 提交。
+     * 宿主侧(desk.js updateSettings)对 subjects 的规矩是「空列表不动旧值」,
+     * 所以 UI 上最后一个科目不许删 —— 不然她以为删掉了,desk.json 里还在。
+     */
+    function SubjectsField({ value, onCommit }) {
+      const list = Array.isArray(value) ? value : []
+      const [draft, setDraft] = React.useState('')
+      const add = () => {
+        const label = draft.trim().slice(0, 24)
+        if (!label || list.some((x) => x.label === label)) { setDraft(''); return }
+        onCommit([...list, { id: 's' + Date.now().toString(36), label }])
+        setDraft('')
+      }
+      const remove = (id) => onCommit(list.filter((x) => x.id !== id))
+      return h('div', { className: 'sd-field' }, [
+        h('span', { className: 'sd-field-label', key: 'l' }, '科目'),
+        h('div', { className: 'sd-row', key: 'r' }, [
+          ...list.map((x) => h('span', { key: x.id, className: 'sd-subj-chip' }, [
+            h('span', { key: 'n' }, x.label),
+            h('button', {
+              key: 'x', className: 'sd-subj-x', disabled: list.length <= 1,
+              title: list.length <= 1
+                ? '至少留一个科目'
+                : '从科目列表移除(已写在卡上的科目名不动,只是下拉和过滤里不再出现)',
+              onClick: () => remove(x.id),
+            }, '×'),
+          ])),
+          h('input', {
+            key: 'i', className: 'sd-input', type: 'text', value: draft,
+            placeholder: '加一个科目,回车确认',
+            onChange: (e) => setDraft(e.target.value),
+            onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); add() } },
+          }),
+          h('button', { key: 'a', className: 'sd-textbtn', disabled: !draft.trim(), onClick: add }, '添加'),
+        ]),
+        h('span', { className: 'sd-field-hint', key: 'h' }, '科目用在卡片、过滤栏和近 30 天统计里;重名或空的会被忽略'),
+      ])
+    }
+
+    /**
+     * 每日计划表编辑器:一行一个时间段 `HH:MM~HH:MM 事情 【科目】`。
+     * 解析只做形状预检(起 < 终),真正落库的规矩在 desk.js normalizeDailyPlan 一处
+     * ——坏行被忽略,草稿文本不会被洗掉(洗掉会把她正在打的字吞了)。
+     */
+    function planItemsToText(items) {
+      return (items || []).map((it) =>
+        `${it.start}~${it.end} ${it.label}${it.subject ? ` 【${it.subject}】` : ''}`).join('\n')
+    }
+
+    function planItemsFromText(text) {
+      const out = []
+      for (const line of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
+        const m = /^\s*(\d{1,2}):\s*(\d{2})\s*~\s*(\d{1,2}):\s*(\d{2})\s+(.+?)\s*(?:【(.+?)】)?\s*$/.exec(line)
+        if (!m) continue
+        const start = `${m[1].padStart(2, '0')}:${m[2]}`
+        const end = `${m[3].padStart(2, '0')}:${m[4]}`
+        if (pmin(start) >= pmin(end)) continue
+        out.push({ start, end, label: m[5].trim(), subject: (m[6] || '').trim() })
+      }
+      return out
+    }
+
+    function DayPlanField({ value, onCommit }) {
+      const dp = value || { enabled: false, float: false, items: [] }
+      const text = planItemsToText(dp.items)
+      const [draft, setDraft] = React.useState(text)
+      React.useEffect(() => setDraft(text), [text])
+      const dirty = draft !== text
+      const patch = (p) => onCommit({ patch: p })
+      const commit = () => { if (dirty) patch({ items: planItemsFromText(draft) }) }
+      return h('div', { className: 'sd-field', key: 'dp' }, [
+        h('span', { className: 'sd-field-label' }, '每日计划表(哪个时间段做什么)'),
+        h('div', { className: 'sd-row', key: 'sw' }, [
+          h('label', { className: 'sd-field sd-field-inline', key: 'en' }, [
+            h('input', {
+              type: 'checkbox', checked: dp.enabled === true,
+              onChange: (e) => patch({ enabled: e.target.checked }),
+            }),
+            h('span', {}, '启用'),
+            h('span', { className: 'sd-field-hint' }, '开着时主面板会显示,模型也从系统提示里知道现在该做什么'),
+          ]),
+          h('label', { className: 'sd-field sd-field-inline', key: 'fl' }, [
+            h('input', {
+              type: 'checkbox', checked: dp.float === true,
+              onChange: (e) => patch({ float: e.target.checked }),
+            }),
+            h('span', {}, '悬浮在桌面上'),
+            h('span', { className: 'sd-field-hint' }, '和右下角迷你条一样可拖动、可收成小圆点'),
+          ]),
+        ]),
+        h('textarea', {
+          key: 't', className: 'sd-journal-input', rows: 6, value: draft,
+          placeholder: '08:00~09:30 背单词 【英语】\n14:00~16:00 864 论述练习\n16:00~16:20 休息',
+          onChange: (e) => setDraft(e.target.value),
+        }),
+        h('div', { className: 'sd-row', key: 'r' }, [
+          h('button', { className: 'sd-textbtn', disabled: !dirty, onClick: commit }, dirty ? '保存时间段' : '已保存'),
+          h('span', { className: 'sd-field-hint' }, '一行一条:HH:MM~HH:MM 事情,行尾可加【科目】;坏行会被忽略。这是每天的固定模板,不按日期存'),
+        ]),
+      ])
+    }
+
     function Settings() {
       const s = useStore()
       usePoll(60000)
@@ -2223,6 +2832,11 @@ window.__ModuleLoader__.load({
           hint: '格式:名称 2026-10-15~2026-10-24;日期拿不准就在行尾加「约」。坏行会被忽略,不会报错。',
           value: st.milestonesText || '', onCommit: (v) => patch({ milestonesText: v }),
         }),
+        h(SubjectsField, { key: 'subjects', value: st.subjects, onCommit: (list) => patch({ subjects: list }) }),
+        h(DayPlanField, {
+          key: 'dayplan', value: s.state.dailyPlan,
+          onCommit: (p) => store.apply('dailyPlan.update', p),
+        }),
         h('div', { className: 'sd-settings-grid', key: 'g' }, [
           h(NumField, { key: 'goal', label: '每日目标(分钟)', value: st.dailyGoalMin || 180, min: 10, max: 1440, onCommit: (v) => patch({ dailyGoalMin: v }) }),
           h(NumField, { key: 'focus', label: '专注时长(分钟)', value: st.focusMin || 25, min: 1, max: 240, onCommit: (v) => patch({ focusMin: v }) }),
@@ -2240,6 +2854,14 @@ window.__ModuleLoader__.load({
           h('span', { key: 'l' }, '每轮对话把「今天该做什么、该复习什么、昨天卡在哪」告诉模型'),
           h('span', { className: 'sd-field-hint', key: 'h' }, '关掉能省一点上下文'),
         ]),
+        h('label', { className: 'sd-field sd-field-inline', key: 'nfy' }, [
+          h('input', {
+            key: 'c', type: 'checkbox', checked: st.notifySessionOnTimerEnd !== false,
+            onChange: (e) => patch({ notifySessionOnTimerEnd: e.target.checked }),
+          }),
+          h('span', { key: 'l' }, 'AI 起的番茄钟走完后,往开钟的那个会话发一条自动提醒(督促用)'),
+          h('span', { className: 'sd-field-hint', key: 'h' }, '手动起的钟不提醒;会话已关闭时不补发'),
+        ]),
         h('div', { className: 'sd-settings-file', key: 'f' }, `数据文件:${s.deskFile || '—'}`),
         h('div', { className: 'sd-settings-file', key: 'f2' },
           `Markdown:${s.markdownFile || '(还没导出过)'} —— 把它软链进 Obsidian vault 就能当笔记改,改完在工作台点「从 Markdown 读回」`),
@@ -2255,7 +2877,7 @@ window.__ModuleLoader__.load({
       // 挂在四个根上:整页面板、设置节、迷你条、收起态的小圆点 —— 迷你条是 portal 到 body 的,
       // 不在 .sd-page 里面,所以必须单独列出来,否则它取不到阶梯值。
       // SVG 内部(饼图圆心那两行)的 font-size 是 viewBox 用户单位,不是设备像素,刻意不走这套阶梯。
-      '.sd-page,.sd-settings,.sd-mini,.sd-mini-fab,.sd-error{--sd-fs-tick:10px;--sd-fs-micro:11px;--sd-fs-cap:12px;--sd-fs-body:13px;--sd-fs-sub:15px;--sd-fs-mid:20px;--sd-fs-num:26px;--sd-fs-hero:32px;--sd-gap-block:16px;--sd-gap-card:12px;--sd-gap-row:8px;--sd-radius:16px;--sd-radius-sm:11px}',
+      '.sd-page,.sd-settings,.sd-mini,.sd-mini-fab,.sd-plandock,.sd-plandock-fab,.sd-error{--sd-fs-tick:10px;--sd-fs-micro:11px;--sd-fs-cap:12px;--sd-fs-body:13px;--sd-fs-sub:15px;--sd-fs-mid:20px;--sd-fs-num:26px;--sd-fs-hero:32px;--sd-gap-block:16px;--sd-gap-card:12px;--sd-gap-row:8px;--sd-radius:16px;--sd-radius-sm:11px}',
       // 页面骨架
       // 面板外壳:中心列是 overflow:hidden 的 flex 列,滚动条只能由我们自己出
       '.sd-panel{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;width:100%;height:100%;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
@@ -2269,7 +2891,7 @@ window.__ModuleLoader__.load({
       '.sd-head-main{display:flex;flex-direction:column;gap:4px}',
       '.sd-title{margin:0;font-size:var(--sd-fs-num);font-weight:600;letter-spacing:.01em;font-family:var(--dsw-font-family)}',
       '.sd-subtitle{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-tertiary);letter-spacing:.02em}',
-      '.sd-count{display:flex;align-items:baseline;gap:4px;padding:6px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1)}',
+      '.sd-count{display:flex;align-items:baseline;gap:4px;padding:6px 14px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 22%,var(--dsw-alias-border-l1));border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,var(--dsw-alias-bg-layer-1))}',
       '.sd-count-num{font-size:var(--sd-fs-num);font-weight:600;line-height:1;font-variant-numeric:tabular-nums;font-family:var(--dsw-font-family-brand);color:var(--dsw-alias-brand-primary)}',
       '.sd-count-cap{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}',
       '.sd-count-label{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);margin-left:6px;letter-spacing:.04em}',
@@ -2294,7 +2916,9 @@ window.__ModuleLoader__.load({
       // 今日进度条 = 今天已投入的分钟数,跟热力图/周报同一个绿;计时器那条继续走品牌色(语义不同)
       '.sd-bar-study .sd-bar-fill{background:var(--dsw-static-green-500)}',
       // 计时卡
-      '.sd-timer-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:16px 18px;display:flex;flex-direction:column;gap:12px;box-shadow:0 1px 2px rgba(0,0,0,.05)}',
+      '.sd-timer-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:16px 18px;display:flex;flex-direction:column;gap:12px;box-shadow:0 1px 2px rgba(0,0,0,.05);transition:border-color .2s ease}',
+      // 走钟时卡沿淡淡描一圈品牌色:余光扫一眼就知道钟在跑,不用去读数字
+      '.sd-timer-card.running{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 32%,var(--dsw-alias-border-l1))}',
       '.sd-timer-name-row{display:flex;align-items:center;gap:7px;flex-wrap:wrap}',
       '.sd-timer-name-row .sd-input.wide{flex:1;min-width:12em}',
       '.sd-name-empty::placeholder{color:var(--dsw-alias-label-dimmed)}',
@@ -2309,15 +2933,18 @@ window.__ModuleLoader__.load({
       '.sd-timer-task{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40ch}',
       '.sd-timer-sub{color:var(--dsw-alias-label-tertiary)}',
       '.sd-timer-bar{margin-top:2px}',
+      '.sd-timer-bar .sd-bar,.sd-today-bar .sd-bar{height:6px}',
       '.sd-timer-side{display:flex;align-items:center;gap:6px;flex:none}',
       '.sd-timer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;flex-wrap:wrap}',
       // 通用控件
       '.sd-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
       '.sd-row-end{margin-left:auto}',
       '.sd-mini-cap{font-size:var(--sd-fs-micro);letter-spacing:.06em;color:var(--dsw-alias-label-tertiary)}',
-      '.sd-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:border-color .12s ease,color .12s ease,background .12s ease}',
+      '.sd-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:border-color .12s ease,color .12s ease,background .12s ease,transform .12s ease}',
       '.sd-iconbtn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}',
+      '.sd-iconbtn:active{transform:scale(.94)}',
       '.sd-iconbtn-lg{width:42px;height:42px;border-radius:12px;color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent)}',
+      '.sd-iconbtn-lg:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 10%,transparent)}',
       '.sd-iconbtn.sm{width:22px;height:22px;border-radius:6px}',
       '.sd-textbtn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-cap);padding:3px 10px;border-radius:7px;cursor:pointer;transition:color .12s ease,border-color .12s ease}',
       '.sd-textbtn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}',
@@ -2328,9 +2955,10 @@ window.__ModuleLoader__.load({
       '.sd-pill.sm{font-size:var(--sd-fs-micro);padding:2px 8px}',
       '.sd-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:3px 8px;font-size:var(--sd-fs-cap);max-width:200px;outline:none}',
       '.sd-select:disabled{opacity:.55}',
+      '.sd-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
       '.sd-select.sm{font-size:var(--sd-fs-cap)}',
       '.sd-input{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:4px 9px;font-size:var(--sd-fs-body);width:88px;outline:none}',
-      '.sd-input:focus{border-color:var(--dsw-alias-brand-primary)}',
+      '.sd-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
       '.sd-input.wide{width:100%;min-width:0}',
       // 分区标题:层级靠字重和留白建立,不靠画线
       '.sd-section-title{display:flex;align-items:baseline;gap:10px;font-size:var(--sd-fs-cap);font-weight:600;letter-spacing:.16em;color:var(--dsw-alias-label-tertiary)}',
@@ -2338,7 +2966,8 @@ window.__ModuleLoader__.load({
       // 看板
       '.sd-boardwrap{display:flex;flex-direction:column;gap:10px}',
       '.sd-filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
-      '.sd-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary)}',
+      '.sd-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);transition:border-color .12s ease,box-shadow .12s ease}',
+      '.sd-search:focus-within{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
       '.sd-search-input{border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-cap);width:170px}',
       '.sd-tagrow{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}',
       '.sd-tagchip{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-micro);padding:1px 8px;border-radius:999px;cursor:pointer;transition:color .12s ease,border-color .12s ease,background .12s ease}',
@@ -2359,14 +2988,20 @@ window.__ModuleLoader__.load({
       '.sd-col{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:12px;display:flex;flex-direction:column;gap:var(--sd-gap-row);min-height:120px;transition:border-color .15s ease,background .15s ease}',
       '.sd-col.hover{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-1))}',
       '.sd-col-head{display:flex;align-items:center;gap:7px;padding:2px 4px 4px}',
+      // 列头状态点:待办=琥珀(欠着的债,与到期预报同族)、进行中=蓝(分类第一色)、完成=绿(已投入)
+      '.sd-col-dot{width:7px;height:7px;border-radius:50%;flex:none}',
+      '.sd-col-dot-todo{background:var(--dsw-static-amber-500)}',
+      '.sd-col-dot-doing{background:var(--dsw-static-deepseek-500)}',
+      '.sd-col-dot-done{background:var(--dsw-static-green-500)}',
+      '.sd-col-more{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);text-align:center}',
       '.sd-col-latin{font-size:var(--sd-fs-tick);letter-spacing:.16em;color:var(--dsw-alias-label-dimmed)}',
       '.sd-col-label{font-size:var(--sd-fs-body);font-weight:600;color:var(--dsw-alias-label-secondary)}',
       '.sd-col-count{margin-left:auto;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;background:var(--dsw-alias-bg-layer-2);border-radius:999px;padding:1px 8px}',
       '.sd-col-body{display:flex;flex-direction:column;gap:8px}',
       '.sd-col-empty{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-dimmed);padding:10px 6px;text-align:center}',
       // 卡片
-      '.sd-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius-sm);background:var(--dsw-alias-bg-base);padding:10px 12px;display:flex;flex-direction:column;gap:6px;cursor:grab;transition:border-color .12s ease,box-shadow .12s ease,opacity .12s ease}',
-      '.sd-card:hover{border-color:var(--dsw-alias-border-l3);box-shadow:0 1px 4px rgba(0,0,0,.07)}',
+      '.sd-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius-sm);background:var(--dsw-alias-bg-base);padding:10px 12px;display:flex;flex-direction:column;gap:6px;cursor:grab;transition:border-color .12s ease,box-shadow .12s ease,opacity .12s ease,transform .12s ease}',
+      '.sd-card:hover{border-color:var(--dsw-alias-border-l3);box-shadow:0 1px 4px rgba(0,0,0,.07);transform:translateY(-1px)}',
       '.sd-card.dragging{opacity:.45}',
       '.sd-card.done{opacity:.68}',
       '.sd-card.done .sd-card-title{text-decoration:line-through;color:var(--dsw-alias-label-tertiary)}',
@@ -2386,7 +3021,7 @@ window.__ModuleLoader__.load({
       '.sd-mini:hover{box-shadow:0 10px 30px rgba(0,0,0,.34)}',
       '.sd-mini-fab:hover{box-shadow:0 10px 30px rgba(0,0,0,.34)}',
       // 尊重系统的「减少动态效果」
-      '@media (prefers-reduced-motion:reduce){.sd-mini,.sd-mini-fab,.sd-card-acts,.sd-act{transition:none}}',
+      '@media (prefers-reduced-motion:reduce){.sd-mini,.sd-mini-fab,.sd-card-acts,.sd-act,.sd-card,.sd-iconbtn{transition:none}.sd-card:hover{transform:none}.sd-iconbtn:active{transform:none}}',
       // 键盘可见焦点
       '.sd-iconbtn:focus-visible,.sd-act:focus-visible,.sd-pill:focus-visible,.sd-textbtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
       '.sd-act{display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 5px;border-radius:5px;font-size:var(--sd-fs-micro);transition:color .12s ease,background .12s ease}',
@@ -2398,11 +3033,11 @@ window.__ModuleLoader__.load({
       '.sd-edit-row{display:flex;align-items:center;gap:8px}',
       '.sd-edit-label{flex:none;width:4.6em;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary)}',
       '.sd-edit-input{flex:1;min-width:0;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-cap);line-height:1.5;padding:4px 7px;outline:none;font-family:inherit;resize:vertical}',
-      '.sd-edit-input:focus{border-color:var(--dsw-alias-brand-primary)}',
+      '.sd-edit-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
       '.sd-act.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}',
       // 新建
       '.sd-add{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;border:1px dashed var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:var(--sd-fs-cap);padding:7px;border-radius:9px;cursor:pointer;transition:color .12s ease,border-color .12s ease}',
-      '.sd-add:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}',
+      '.sd-add:hover{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,var(--dsw-alias-border-l1))}',
       '.sd-composer{display:flex;flex-direction:column;gap:7px;border:1px solid var(--dsw-alias-brand-primary);border-radius:10px;padding:8px;background:var(--dsw-alias-bg-base)}',
       '.sd-composer-input{width:100%;resize:vertical;border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.55;font-family:inherit}',
       '.sd-composer-foot{display:flex;align-items:center;gap:8px}',
@@ -2431,6 +3066,15 @@ window.__ModuleLoader__.load({
       '.sd-cell.lv3{background:color-mix(in srgb,var(--dsw-static-green-500) 74%,var(--dsw-alias-bg-layer-3))}',
       '.sd-cell.lv4{background:var(--dsw-static-green-500)}',
       '.sd-cell.today{outline:1px solid var(--dsw-alias-label-primary);outline-offset:1px}',
+      '.sd-cell:hover{box-shadow:inset 0 0 0 1px var(--dsw-alias-label-tertiary)}',
+      '.sd-cell:not(.empty){cursor:pointer}',
+      // 点击某天格子展开的当天明细:原始 session 列表(不是聚合)
+      '.sd-heat-day-detail{border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px;display:flex;flex-direction:column;gap:5px}',
+      '.sd-heat-day-head{display:flex;align-items:center;gap:8px;font-size:var(--sd-fs-cap)}',
+      '.sd-heat-day-row{display:grid;grid-template-columns:3.2em minmax(0,1fr) auto;gap:8px;align-items:center;font-size:var(--sd-fs-cap)}',
+      '.sd-heat-day-time{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
+      '.sd-heat-day-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
+      '.sd-heat-day-min{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
       '.sd-cell.legend{display:inline-block;vertical-align:middle;margin:0 2px}',
       '.sd-heat-foot{display:flex;align-items:center;gap:14px;flex-wrap:wrap;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary)}',
       '.sd-legend{display:inline-flex;align-items:center;gap:3px;margin-left:auto}',
@@ -2462,13 +3106,16 @@ window.__ModuleLoader__.load({
       '.sd-recent-row{display:grid;grid-template-columns:minmax(0,3.6em) minmax(0,1fr) auto;align-items:center;gap:9px;font-size:var(--sd-fs-cap)}',
       '.sd-recent-date{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
       '.sd-recent-bar{height:5px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}',
-      '.sd-recent-bar i{display:block;height:100%;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 70%,transparent)}',
+      '.sd-recent-bar i{display:block;height:100%;border-radius:999px;background:color-mix(in srgb,var(--dsw-static-green-500) 78%,transparent)}',
       '.sd-recent-num{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap}',
       // 科目分布
       '.sd-subj-list{display:flex;flex-direction:column;gap:7px}',
       '.sd-subj{display:grid;grid-template-columns:minmax(0,7.5em) minmax(0,1fr) auto;align-items:center;gap:9px;font-size:var(--sd-fs-cap)}',
       '.sd-subj-label{color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
       '.sd-subj-bar{height:5px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}',
+      // 科目投入条跟热力图/周报同族(绿=已投入)。此前继承 .sd-bar-fill 的品牌色,
+      // 暗色主题下解析成近白,是整页最刺眼的一根条。
+      '.sd-subj-bar .sd-bar-fill{background:var(--dsw-static-green-500)}',
       '.sd-subj-min{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
       '.sd-empty-block{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-dimmed);line-height:1.6}',
       // 番茄分布饼图
@@ -2495,11 +3142,25 @@ window.__ModuleLoader__.load({
       '.sd-review-acts{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto}',
       '.sd-grade{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-cap);padding:3px 11px;border-radius:8px;cursor:pointer;transition:color .12s ease,border-color .12s ease,background .12s ease}',
       '.sd-grade:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}',
+      // 打分键 hover 按各自的语义色淡染:指尖悬在哪一档,颜色先应一声
+      '.sd-grade.again:hover{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
+      '.sd-grade.good:hover{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 10%,transparent)}',
+      '.sd-grade.easy:hover{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}',
       '.sd-grade:disabled{opacity:.5;cursor:default}',
       '.sd-grade.again{color:var(--dsw-alias-state-error-primary)}',
       '.sd-grade.good{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent)}',
       '.sd-grade.easy{color:var(--dsw-alias-state-success-primary)}',
       '.sd-review-more{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed)}',
+      // 过卡模式:全屏浮层。z-index 压在宿主模态(1000)之下;Windows 顶部那条 drag 带
+      // 会吞键盘焦点区的点击 —— 浮层必须显式 no-drag(坑 3 同款)。
+      '.sd-focus-overlay{position:fixed;inset:0;z-index:920;background:var(--dsw-alias-bg-base);-webkit-app-region:no-drag;display:flex;align-items:center;justify-content:center;padding:36px;outline:none}',
+      '.sd-focus-card{width:min(720px,100%);max-height:100%;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding:26px 28px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);box-shadow:0 10px 40px rgba(0,0,0,.18)}',
+      '.sd-focus-title{font-size:var(--sd-fs-hero);font-weight:600;line-height:1.4;word-break:break-word}',
+      '.sd-focus-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}',
+      '.sd-focus-note{font-size:var(--sd-fs-body);line-height:1.75;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;border-left:2px solid var(--dsw-alias-border-l2);padding-left:12px}',
+      '.sd-focus-acts{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}',
+      '.sd-focus-progress{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}',
+      '.sd-grade.lg{font-size:var(--sd-fs-sub);padding:7px 20px;border-radius:10px}',
       // 遗忘曲线:颜色跟着「今天还剩多少记忆」走,绿=还稳、蓝=该看了、红=快塌了
       '.sd-curve{flex:none;overflow:visible}',
       '.sd-curve-line{fill:none;stroke:currentColor;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}',
@@ -2546,7 +3207,7 @@ window.__ModuleLoader__.load({
       '.sd-week-label{font-size:var(--sd-fs-tick);color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}',
       // 每日复盘
       '.sd-journal-input{width:100%;resize:vertical;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.6;padding:8px 10px;outline:none;font-family:inherit}',
-      '.sd-journal-input:focus{border-color:var(--dsw-alias-brand-primary)}',
+      '.sd-journal-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
       '.sd-journal-list{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px}',
       '.sd-journal-item{display:grid;grid-template-columns:3.4em 1fr;gap:8px;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}',
       '.sd-journal-date{color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}',
@@ -2570,6 +3231,29 @@ window.__ModuleLoader__.load({
       '.sd-mini-late{color:var(--dsw-alias-state-error-primary)}',
       '.sd-mini-fab{position:fixed;right:18px;bottom:18px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;width:34px;height:34px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none;box-shadow:0 6px 20px rgba(0,0,0,.25)}',
       '.sd-mini-fab:hover{color:var(--dsw-alias-brand-primary)}',
+      // 每日计划表悬浮件:坑位规矩照抄迷你条(portal / pointer-events / no-drag / z-index:900),
+      // 默认位在迷你条上方(bottom:64px),落点自己存 plan-pos,互不打架
+      '.sd-plandock{position:fixed;right:18px;bottom:64px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;touch-action:none;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));box-shadow:0 8px 26px rgba(0,0,0,.28);backdrop-filter:blur(10px);font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-primary);max-width:280px}',
+      '.sd-plandock-main{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:grab;touch-action:none;user-select:none}',
+      '.sd-plandock-main:active{cursor:grabbing}',
+      '.sd-plandock-icon{display:inline-flex;color:var(--dsw-alias-brand-primary);flex:none}',
+      '.sd-plandock-now{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
+      '.sd-plandock-panel{border-top:1px solid var(--dsw-alias-border-l1);padding:9px 10px;display:flex;flex-direction:column;gap:5px;max-height:320px;overflow-y:auto}',
+      '.sd-plandock-fab{position:fixed;right:18px;bottom:64px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none;box-shadow:0 6px 20px rgba(0,0,0,.25)}',
+      '.sd-plandock-fab:hover{color:var(--dsw-alias-brand-primary)}',
+      // 计划表分区卡 + 行(主面板与悬浮件共用同一套行样式)
+      '.sd-plan-card{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
+      '.sd-plan-row{display:flex;align-items:center;gap:10px;padding:8px 14px;font-size:var(--sd-fs-body);color:var(--dsw-alias-label-secondary);border-top:1px solid var(--dsw-alias-border-l1)}',
+      '.sd-plan-row:first-child{border-top:none}',
+      '.sd-plan-time{flex:none;font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-tertiary)}',
+      '.sd-plan-label{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}',
+      '.sd-plan-subject{flex:none;font-size:var(--sd-fs-micro);padding:1px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}',
+      '.sd-plan-flag{flex:none;font-size:var(--sd-fs-micro)}',
+      '.sd-plan-row.now{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}',
+      '.sd-plan-row.now .sd-plan-time,.sd-plan-row.now .sd-plan-flag{color:var(--dsw-alias-state-business-primary)}',
+      '.sd-plan-row.now .sd-plan-label{color:var(--dsw-alias-label-primary);font-weight:600}',
+      '.sd-plan-row.past{opacity:.55}',
+      '.sd-plan-row.past .sd-plan-flag{color:var(--dsw-alias-label-tertiary)}',
       '.sd-error{margin:16px;padding:14px 16px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.6}',
       '.sd-error-stack{margin:10px 0 0;padding:10px;max-height:320px;overflow:auto;white-space:pre-wrap;font-size:var(--sd-fs-micro);line-height:1.5;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-radius:6px}',
       // 设置
@@ -2581,6 +3265,11 @@ window.__ModuleLoader__.load({
       '.sd-field-label{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}',
       '.sd-field-hint{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);line-height:1.5}',
       '.sd-settings-file{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);word-break:break-all}',
+      // 设置页的科目 chip:带一颗删除叉,最后一个不许删(宿主对空列表不动旧值)
+      '.sd-subj-chip{display:inline-flex;align-items:center;gap:3px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-micro);padding:2px 4px 2px 9px;border-radius:999px}',
+      '.sd-subj-x{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:var(--sd-fs-cap);line-height:1;padding:0 5px;border-radius:50%;transition:color .12s ease,background .12s ease}',
+      '.sd-subj-x:hover{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger)}',
+      '.sd-subj-x:disabled{opacity:.4;cursor:default}',
       '@media (max-width:900px){.sd-board{grid-template-columns:minmax(0,1fr)}.sd-two{grid-template-columns:minmax(0,1fr)}}',
     ].join('')
 
@@ -2688,7 +3377,7 @@ window.__ModuleLoader__.load({
       // 左侧栏图标:id 与 main 的 key 同名,侧栏点它就会 dispatch 到 main 的同一格
       register('sidebar.panellist',
         { name: 'sidebar.panellist', id: PANEL_ID, order: 40, label: '学习工作台' },
-        (props) => h(Icon, { name: 'board', size: (props && props.size) || 18 }),
+        (props) => h(Icon, { name: 'deskcard', size: (props && props.size) || 18 }),
         'sidebar.panellist')
 
       // 整页工作台
@@ -2703,6 +3392,13 @@ window.__ModuleLoader__.load({
         () => h(MiniBar, { key: 'mini' }),
         'shell.overlay')
 
+      // 每日计划表悬浮件:与迷你条并列的第二个 shell.overlay。
+      // 自身渲染与否由 dailyPlan.enabled && dailyPlan.float 决定(关了返回 null,槽位仍占着)
+      register('shell.overlay',
+        { name: 'shell.overlay', id: 'study-desk-plan', order: 81, label: '每日计划表' },
+        () => h(PlanDock, { key: 'plandock' }),
+        'shell.overlay.plandock')
+
       // 设置页
       register('settings.section',
         { name: 'settings.section', id: 'study-desk', order: 27, label: '学习工作台' },
@@ -2714,7 +3410,7 @@ window.__ModuleLoader__.load({
       //   layout.selectPanel: main panel "…" is not registered
       register('sidebar.panellist',
         { name: 'sidebar.panellist', id: BBCDC_PANEL_ID, order: 41, label: '单词打卡' },
-        (props) => h(Icon, { name: 'tag', size: (props && props.size) || 18 }),
+        (props) => h(Icon, { name: 'book', size: (props && props.size) || 18 }),
         'sidebar.panellist.bbdc')
 
       register('main',
@@ -2722,7 +3418,7 @@ window.__ModuleLoader__.load({
         () => h(BbdcPanel, { key: 'bbdc' }),
         'main.bbdc')
 
-      reportDiag({ kind: 'slots-registered', where: 'client', message: '六个槽位注册调用已完成' })
+      reportDiag({ kind: 'slots-registered', where: 'client', message: '七个槽位注册调用已完成' })
     }
 
     exports.apply = apply
diff --git a/desk.js b/desk.js
index fdff403..30a6366 100644
--- a/desk.js
+++ b/desk.js
@@ -184,6 +184,7 @@ function freshState() {
     journal: {},
     timer: null,
     settings: { ...DEFAULT_SETTINGS, subjects: DEFAULT_SETTINGS.subjects.map((s) => ({ ...s })) },
+    dailyPlan: { enabled: false, float: false, items: [] },
     createdAt: Date.now(),
     updatedAt: Date.now(),
   }
@@ -241,6 +242,7 @@ export function normalize(raw) {
     reviews: Array.isArray(raw.reviews) ? raw.reviews : [],
     journal: raw.journal && typeof raw.journal === 'object' ? raw.journal : {},
     settings: { ...base.settings, ...(raw.settings && typeof raw.settings === 'object' ? raw.settings : {}) },
+    dailyPlan: normalizeDailyPlan(raw.dailyPlan),
   }
   if (!Array.isArray(state.settings.subjects) || state.settings.subjects.length === 0) {
     state.settings.subjects = base.settings.subjects.map((s) => ({ ...s }))
@@ -254,6 +256,7 @@ export function normalize(raw) {
       subject: String(t.subject || ''),
       note: String(t.note || ''),
       estimateMin: Math.max(0, Math.floor(Number(t.estimateMin) || 0)),
+      dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String(t.dueDate || '')) ? String(t.dueDate) : null,
       createdAt: Number(t.createdAt) || Date.now(),
       updatedAt: Number(t.updatedAt) || Number(t.createdAt) || Date.now(),
       doneAt: Number(t.doneAt) || null,
@@ -391,6 +394,7 @@ export function addTask(state, input, now = Date.now()) {
     subject: String((input && input.subject) || ''),
     note: String((input && input.note) || ''),
     estimateMin: Math.max(0, Math.floor(Number(input && input.estimateMin) || 0)),
+    dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String((input && input.dueDate) || '')) ? String(input.dueDate) : null,
     createdAt: now,
     updatedAt: now,
     doneAt: status === 'done' ? now : null,
@@ -411,6 +415,11 @@ export function updateTask(state, ref, patch, now = Date.now()) {
   if (typeof p.note === 'string') task.note = p.note
   if (typeof p.subject === 'string') task.subject = p.subject
   if (p.estimateMin !== undefined) task.estimateMin = Math.max(0, Math.floor(Number(p.estimateMin) || 0))
+  if (p.dueDate !== undefined) {
+    // 卡片自己的硬截止(区别于复习排期 review.due):只认 YYYY-MM-DD,别的形状一律清空
+    const v = String(p.dueDate || '').trim()
+    task.dueDate = /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null
+  }
   if (p.pinned !== undefined) task.pinned = !!p.pinned
   if (p.tags !== undefined) task.tags = normalizeTags(p.tags)
   if (p.review === true && !task.review.due) task.review = { ...emptyReview(), due: dayKey(now) }
@@ -491,6 +500,7 @@ export function updateSettings(state, patch) {
     }
   }
   if (p.injectPrompt !== undefined) next.injectPrompt = !!p.injectPrompt
+  if (p.notifySessionOnTimerEnd !== undefined) next.notifySessionOnTimerEnd = !!p.notifySessionOnTimerEnd
   for (const k of ['goalLabel', 'goalNote']) {
     if (p[k] !== undefined) next[k] = String(p[k]).trim().slice(0, 40)
   }
@@ -516,6 +526,82 @@ export function updateSettings(state, patch) {
   return next
 }
 
+// ---------------------------------------------------------------------------
+// 每日计划表:哪个时间段做什么事。每天的固定模板(不按日期存,改模板=改每一天)。
+// 数据在 state.dailyPlan;「现在该做什么」由 dailyPlanNow 单点算——
+// 注入 system prompt 与快照都从它取,客户端只画不算。
+// ---------------------------------------------------------------------------
+
+/** 'HH:MM' → 当天分钟数。非法输入返回 NaN。 */
+export function planMinutes(hhmm) {
+  const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '').trim())
+  if (!m) return NaN
+  const h = Number(m[1])
+  const min = Number(m[2])
+  if (h > 23 || min > 59) return NaN
+  return h * 60 + min
+}
+
+const MAX_PLAN_ITEMS = 48
+
+function normalizeDailyPlan(raw) {
+  const base = { enabled: false, float: false, items: [] }
+  if (!raw || typeof raw !== 'object') return base
+  const seen = new Set()
+  const items = []
+  for (const it of Array.isArray(raw.items) ? raw.items : []) {
+    if (!it || typeof it !== 'object') continue
+    const start = String(it.start || '').trim()
+    const end = String(it.end || '').trim()
+    const label = String(it.label || '').trim().slice(0, 60)
+    if (!/^\d{2}:\d{2}$/.test(start) || !/^\d{2}:\d{2}$/.test(end)) continue
+    if (!label) continue
+    const s = planMinutes(start)
+    const e = planMinutes(end)
+    if (!Number.isFinite(s) || !Number.isFinite(e) || s >= e) continue // 跨午夜不支持;写反的整条丢掉
+    let id = String(it.id || '').trim()
+    if (!id || seen.has(id)) id = randomUUID()
+    seen.add(id)
+    items.push({ id, start, end, label, subject: String(it.subject || '').trim().slice(0, 30) })
+    if (items.length >= MAX_PLAN_ITEMS) break
+  }
+  items.sort((a, b) => planMinutes(a.start) - planMinutes(b.start) || planMinutes(a.end) - planMinutes(b.end))
+  return {
+    enabled: raw.enabled === true,
+    float: raw.float === true,
+    items,
+  }
+}
+
+export function updateDailyPlan(state, patch) {
+  const p = patch || {}
+  const cur = normalizeDailyPlan(state.dailyPlan)
+  state.dailyPlan = normalizeDailyPlan({
+    enabled: p.enabled !== undefined ? p.enabled : cur.enabled,
+    float: p.float !== undefined ? p.float : cur.float,
+    items: p.items !== undefined ? p.items : cur.items,
+  })
+  return state.dailyPlan
+}
+
+/** 现在在哪个计划区间里、下一场是什么。enabled=false 或没条目时 items 照发,current/next 为 null。 */
+export function dailyPlanNow(state, now = Date.now()) {
+  const plan = state.dailyPlan || { enabled: false, items: [] }
+  const items = Array.isArray(plan.items) ? plan.items : []
+  const empty = { current: null, next: null, items }
+  if (plan.enabled !== true || !items.length) return empty
+  const d = new Date(now)
+  const mins = d.getHours() * 60 + d.getMinutes()
+  let current = null
+  let next = null
+  for (const it of items) {
+    const s = planMinutes(it.start)
+    if (mins >= s && mins < planMinutes(it.end)) current = it
+    else if (s > mins && !next) next = it
+  }
+  return { current, next, items }
+}
+
 // ---------------------------------------------------------------------------
 // 间隔重复与日复盘
 // ---------------------------------------------------------------------------
@@ -1059,12 +1145,12 @@ export function summary(state, now = Date.now()) {
       today: state.journal[todayKey] || '',
       yesterday: state.journal[dayKey(addDays(now, -1))] || '',
     },
-    doing: cols.doing.map((t) => ({ id: t.id, title: t.title, subject: t.subject, minutes: byTask.get(t.id) || 0 })),
+    doing: cols.doing.map((t) => ({ id: t.id, title: t.title, subject: t.subject, dueDate: t.dueDate || null, minutes: byTask.get(t.id) || 0 })),
     todo: cols.todo
       .slice()
       .sort((a, b) => (b.pinned - a.pinned) || (a.order - b.order))
       .slice(0, 12)
-      .map((t) => ({ id: t.id, title: t.title, subject: t.subject, pinned: t.pinned, estimateMin: t.estimateMin })),
+      .map((t) => ({ id: t.id, title: t.title, subject: t.subject, pinned: t.pinned, dueDate: t.dueDate || null, estimateMin: t.estimateMin })),
     doneToday: doneToday.map((t) => ({ id: t.id, title: t.title })).slice(0, 12),
     week: heatmap(state, 1, now).weeks[0].days.filter(Boolean).map((d) => ({ date: d.key, minutes: d.minutes })),
   }
diff --git a/index.js b/index.js
index 885ebc1..021d753 100644
--- a/index.js
+++ b/index.js
@@ -17,6 +17,7 @@
  * 数据落在 /study-desk/desk.json,路径不写死在 patch 里(写死别人装完起不来)。
  */
 
+import { randomUUID } from 'node:crypto'
 import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -26,6 +27,7 @@ import {
   STATUSES,
   addTask,
   allTags,
+  dailyPlanNow,
   dayKey,
   daysUntil,
   deskDir,
@@ -53,6 +55,7 @@ import {
   summary,
   todayMinutes,
   unscheduleTask,
+  updateDailyPlan,
   updateSettings,
   updateTask,
   weeklyReport,
@@ -192,6 +195,44 @@ function serial(fn) {
   return run
 }
 
+// ---------------------------------------------------------------------------
+// 番茄钟走完 → 给起钟的那个 DSH 会话发自动提醒(督促用)。
+// 钩子在 apply() 里挂上(需要 ctx 去拿 agents 服务);链路:
+//   起钟(工具,exec.agent.id 存进 state.timer.sessionId)
+//   → 任意一侧读写 desk 时 reconcileTimer 结账,把走完的段收集进 sink
+//   → emitTimerEnd 过滤开关/手动/过期 → 钩子用 agents.get(sessionId).followup(消息)
+//     唤醒那个会话(官方 goal-round-driver 同款姿势;空闲就开新一轮,跑着就排下一轮)。
+// 手动停钟(客户端按钮 / 工具 off=true)不提醒;段走完超过 10 分钟才被结账的也不提醒。
+// ---------------------------------------------------------------------------
+
+let timerEndHook = null
+
+function timerEndText(info) {
+  if (info.kind === 'focus') {
+    return `【番茄钟·自动提醒】「${info.title || '未命名'}」这轮 ${info.minutes} 分钟专注计时走完了。`
+      + '这是 study_desk 的系统自动消息,不是用户发言:请用一两句话跟用户确认这轮做了什么,然后提醒休息;'
+      + '不要自动开始下一轮专注,等用户发话。'
+  }
+  return `【番茄钟·自动提醒】${info.minutes} 分钟的休息计时结束了。`
+    + '这是 study_desk 的系统自动消息:提醒用户回到学习;用户要继续时用 study_desk 的 focus 起下一轮'
+    + '(task 必须挂一张「进行中」的卡),不要未经用户确认自动连开。'
+}
+
+function emitTimerEnd(state, endedSegments) {
+  if (!Array.isArray(endedSegments) || !endedSegments.length) return
+  if (state.settings && state.settings.notifySessionOnTimerEnd === false) return
+  if (typeof timerEndHook !== 'function') return
+  for (const info of endedSegments) {
+    // AI 起的才提醒;拖了太久才被结账的段(人早就不在场)也跳过
+    if (!info.sessionId || (Date.now() - info.endedAt) > 10 * 60000) continue
+    try {
+      timerEndHook(info)
+    } catch (error) {
+      console.error('[dsh-study-desk] 番茄钟提醒发送失败:', error && error.message)
+    }
+  }
+}
+
 /**
  * 在串行临界区里读状态 → 顺手结掉走完的计时器 → 跑 mutator → 需要时落盘。
  * mutator 为 null 时是纯读,但计时器结账导致的改动仍会写回。
@@ -199,11 +240,13 @@ function serial(fn) {
 function withDesk(mutator) {
   return serial(() => {
     const state = readDesk()
-    const reconciled = reconcileTimer(state)
+    const endedSegments = []
+    const reconciled = reconcileTimer(state, Date.now(), endedSegments)
     let result
     if (mutator) result = mutator(state)
     if (mutator || reconciled) {
       const saved = writeDesk(state)
+      emitTimerEnd(saved, endedSegments)
       return { state: saved, result }
     }
     return { state, result }
@@ -233,6 +276,8 @@ function snapshot(state) {
     // 饼图:按「这一段的名字」聚合,今天和近 30 天各算一份,客户端只画
     focusPie: { today: focusByName(state, 1, now), month: focusByName(state, 30, now) },
     plan: reviewPlan(state, now, 3),
+    // 每日计划表:宿主单点算好「现在该做什么/下一场是什么」,客户端只画
+    dayPlan: dailyPlanNow(state, now),
     tags: allTags(state),
     weekly: weeklyReport(state, 8, now),
     journal: journalRecent(state, now, 14),
@@ -301,6 +346,7 @@ const MUTATIONS = {
   'task.delete': (state, p) => removeTask(state, p.id || p.title),
   'session.log': (state, p) => logSession(state, p),
   'settings.update': (state, p) => updateSettings(state, p.patch || p),
+  'dailyPlan.update': (state, p) => updateDailyPlan(state, p.patch || p),
   'review.grade': (state, p) => gradeTask(state, p.id || p.title, p.grade),
   'review.schedule': (state, p) => scheduleTask(state, p.id || p.title, p.due),
   'review.unschedule': (state, p) => unscheduleTask(state, p.id || p.title),
@@ -356,6 +402,12 @@ async function handleApi(req, res) {
       sendJson(res, 200, snapshot(state))
       return
     }
+    if (op === 'weekly.text') {
+      // 周报的纯文本(与 study_desk 工具 report 同一份 weeklyText,不另写一套格式)
+      const { state } = await withDesk(null)
+      sendJson(res, 200, { ok: true, text: weeklyText(state, Math.max(1, Math.round(Number(payload.weeks) || 8))) })
+      return
+    }
     if (op === 'diag.report') {
       // 客户端诊断:不碰 desk.json,只追加到 client-diag.json
       const report = writeDiag(payload)
@@ -404,13 +456,24 @@ function boardText(state) {
   const ms = nextMilestone(state)
   if (ms) lines.push(`- 最近节点:${ms.label} ${ms.start}${ms.end !== ms.start ? '~' + ms.end : ''}(${ms.text}${ms.approx ? ',日期按往年惯例估' : ''})`)
   lines.push(`- 今日专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)},连续打卡 ${s.streak} 天`)
+  // 每日计划表:开了就告诉模型「现在按计划该做什么」,它才能对着计划督促
+  const dayPlan = dailyPlanNow(state)
+  if (dayPlan.items.length) {
+    const hhmm = new Date().toTimeString().slice(0, 5)
+    if (dayPlan.current) {
+      lines.push(`- 按每日计划,现在(${hhmm})应该在:「${dayPlan.current.label}」(${dayPlan.current.start}~${dayPlan.current.end})${dayPlan.current.subject ? `【${dayPlan.current.subject}】` : ''}`)
+    } else {
+      lines.push(`- 按每日计划,现在(${hhmm})不在任何计划时间段里`)
+    }
+    if (dayPlan.next) lines.push(`- 下一场 ${dayPlan.next.start} 开始:「${dayPlan.next.label}」${dayPlan.next.subject ? `【${dayPlan.next.subject}】` : ''}`)
+  }
   if (s.doing.length) {
-    lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | '))
+    lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.dueDate ? `【${t.dueDate} 截止】` : ''}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | '))
   } else {
     lines.push('- 进行中 0:现在没有正在做的卡')
   }
   if (s.todo.length) {
-    lines.push('- 待办 ' + s.counts.todo + ':' + s.todo.slice(0, 8).map((t) => t.pinned ? '★' + t.title : t.title).join(' | '))
+    lines.push('- 待办 ' + s.counts.todo + ':' + s.todo.slice(0, 8).map((t) => (t.pinned ? '★' + t.title : t.title) + (t.dueDate ? `【${t.dueDate} 截止】` : '')).join(' | '))
   } else {
     lines.push('- 待办 0')
   }
@@ -543,7 +606,10 @@ function makeTool() {
       '学习工作台:读写用户的待办墙(待办/进行中/已完成三列)、记录专注时长、查统计与热力图、起停番茄钟、'
       + '管间隔重复复习队列、写每日复盘、出周报。用户说「今天要做什么」「把 X 加到进行中」「这件做完了」'
       + '"我今天学了多久""开始 25 分钟专注""今天该复习什么""这个我忘了/记住了""帮我记一下今天的复盘""最近几周学得怎么样"'
-      + '这类话时用它,而不是自己另开一份待办清单。倒计时的目标与节点由用户在设置页自己填(考试、答辩、投稿都可以)。action 取值:'
+      + '这类话时用它,而不是自己另开一份待办清单。倒计时的目标与节点由用户在设置页自己填(考试、答辩、投稿都可以)。'
+      + 'AI 起番茄钟必须走完整流程:action=add 建卡(或挑已有卡)→ action=move 移到 status=doing → action=focus 且 task=那张卡;'
+      + '不挂卡或卡不在「进行中」会被拒绝。番茄钟走完/休息结束时本工具会自动往起钟的会话发一条【番茄钟·自动提醒】(设置页可关)。'
+      + 'action 取值:'
       + 'board=看当前看板全貌;add=新建卡;move=卡片换列/排序;update=改卡片字段;remove=删卡;'
       + 'focus=起停番茄钟(off=true 停;minutes 控制时长;title 给这段命名,饼图按名字聚合);stats=查最近 N 天统计;'
       + 'review=看今日复习队列(带 id/title + grade 时改为打分);journal=看最近复盘(带 text 时写入);'
@@ -557,11 +623,12 @@ function makeTool() {
       note: { type: 'string', description: '备注' },
       tags: { type: 'string', description: '卡片标签,逗号分隔(如「名词解释, 错题」),add/update 用' },
       estimateMin: { type: 'number', description: '预计要花多少分钟' },
+      dueDate: { type: 'string', description: '截止日期 YYYY-MM-DD(add/update 用;卡片自己的硬截止,区别于复习排期的 due)' },
       pinned: { type: 'boolean', description: '是否置顶(add/update)' },
       review: { type: 'boolean', description: 'add 时给 true = 直接把这张卡拉进间隔重复复习循环' },
       beforeId: { type: 'string', description: 'move 时插到这张卡之前;省略则排到该列末尾' },
       minutes: { type: 'number', description: 'focus 的时长(分钟);stats 时表示看最近多少天(默认 30)' },
-      task: { type: 'string', description: 'focus 时把专注挂到哪张卡上(id 或标题)' },
+      task: { type: 'string', description: 'focus 时把专注挂到哪张卡上(id 或标题);AI 起钟必填,且必须是「进行中」的卡——没有就先 add 建卡、move 到 doing' },
       off: { type: 'boolean', description: 'focus 且 off=true 时停止番茄钟' },
       grade: { type: 'string', enum: GRADES, description: 'review 的打分:again 忘了 / good 记得 / easy 很简单' },
       due: { type: 'string', description: '排期日期 YYYY-MM-DD(review 排期用;省略=今天)' },
@@ -576,7 +643,7 @@ function makeTool() {
       schema: { type: 'json' },
       render: (_args, value) => [{ type: 'text', text: String((value && value.message) || '') }],
     },
-    async execute(args) {
+    async execute(args, exec) {
       const action = String(args.action || 'board')
       if (!STUDY_DESK_ACTIONS.includes(action)) return { ok: false, message: '未知 action:' + action }
       try {
@@ -595,6 +662,7 @@ function makeTool() {
             note: args.note,
             status: args.status || 'todo',
             estimateMin: args.estimateMin,
+            dueDate: args.dueDate,
             pinned: args.pinned,
             tags: args.tags,
             review: args.review,
@@ -619,7 +687,7 @@ function makeTool() {
           const ref = args.id || args.title
           if (!ref) return { ok: false, message: 'update 需要 id 或 title' }
           const patch = {}
-          for (const k of ['title', 'note', 'subject', 'estimateMin', 'pinned', 'status', 'tags']) {
+          for (const k of ['title', 'note', 'subject', 'estimateMin', 'dueDate', 'pinned', 'status', 'tags']) {
             if (args[k] !== undefined) patch[k] = args[k]
           }
           if (args.review !== undefined) patch.review = !!args.review
@@ -637,17 +705,42 @@ function makeTool() {
             const { state } = await withDesk((s) => resetTimer(s))
             return { ok: true, message: `番茄钟已停。今天已专注 ${fmtMinutes(todayMinutes(state))}。` }
           }
-          const taskRef = args.task
           const named = String(args.title || '').trim().slice(0, 24)
-          const { state, result } = await withDesk((s) => startTimer(s, {
-            minutes: args.minutes,
-            taskId: taskRef,
-            name: named || undefined,
-          }))
+          // exec.agent.id 就是会话 id:存进计时器,走完时自动提醒发回这个会话
+          const agentId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : null
+          const taskRef = String(args.task || '').trim()
+          const { state, result } = await withDesk((s) => {
+            // AI 起钟的门禁:必须挂一张「进行中」的卡。刻意让 AI 走完
+            // 添加待办 → 移到进行中 → 挂卡起钟 这段流程,AI 安排的任务才会
+            // 真的落在待办墙上,周报和近 30 天统计才对得上。
+            if (!taskRef) {
+              throw new Error('AI 起番茄钟必须挂卡。流程:先 action=add 新建卡(或用 board 挑一张已有卡),'
+                + '再 action=move 把它移到 status=doing,最后 action=focus 且 task=<那张卡的 id 或标题>。')
+            }
+            const task = s.tasks.find((t) => t.id === taskRef
+              || t.title === taskRef
+              || t.title.toLowerCase() === taskRef.toLowerCase())
+            if (!task) {
+              throw new Error(`找不到卡「${taskRef}」。先 action=add 建卡,再用 action=move 移到 status=doing,然后 focus 挂它。`)
+            }
+            if (task.status !== 'doing') {
+              const where = task.status === 'todo' ? '待办' : '已完成'
+              throw new Error(`「${task.title}」现在是「${where}」,不在「进行中」。先 action=move 把它移到 status=doing,再 focus 挂它。`)
+            }
+            return startTimer(s, {
+              minutes: args.minutes,
+              taskId: task.id,
+              name: named || undefined,
+              by: 'ai',
+              sessionId: agentId,
+            })
+          })
           const s = summary(state)
           return {
             ok: true,
-            message: `开始 ${result.minutes} 分钟${result.kind === 'focus' ? '专注' : '休息'}${result.title ? `(${result.title})` : ''}。今天已专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)}。`,
+            message: `开始 ${result.minutes} 分钟${result.kind === 'focus' ? '专注' : '休息'}${result.title ? `(${result.title})` : ''}。`
+              + `今天已专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)}。`
+              + (agentId ? '走完时会自动往本会话发一条提醒。' : ''),
           }
         }
         if (action === 'review') {
@@ -750,6 +843,34 @@ export function apply(ctx, config) {
     state: () => withDesk(null),
   }
 
+  // 番茄钟提醒的发送端:agents 是宿主的会话服务(官方 goal-round-driver 同款用法)。
+  // 会话已关闭 / 宿主重启后 agents 里没有它 → 静默放弃,不排队不补发。
+  timerEndHook = (info) => {
+    const agents = ctx.get('agents')
+    const agent = agents && typeof agents.get === 'function' ? agents.get(info.sessionId) : null
+    if (!agent || typeof agent.followup !== 'function') return
+    agent.followup({
+      id: randomUUID(),
+      role: 'user',
+      content: [{ type: 'text', text: timerEndText(info) }],
+      source: { kind: 'plugin', plugin: name, event: 'timer-end', at: Date.now() },
+    })
+  }
+
+  // 看门狗:面板没开时没人轮询 desk,提醒就得靠它兜底。
+  // 每 10 秒看一眼;只有真有钟在跑且已到点才进串行区结账(顺带触发提醒),平时零写入。
+  ctx.effect(() => {
+    const id = setInterval(() => {
+      try {
+        const st = readDesk()
+        if (st.timer && st.timer.running && st.timer.endsAt <= Date.now()) {
+          withDesk(null).catch(() => {})
+        }
+      } catch { /* 下一轮再看 */ }
+    }, 10000)
+    return () => clearInterval(id)
+  }, 'dsh-study-desk: timer watchdog')
+
   // 给别的插件留一个入口(本插件自己不用,纯便利):注册服务要用 ctx.provide,
   // ctx.set 只能覆盖已经 provide 过的服务,直接 set 会抛
   //   Error: cannot set property "studyDesk" without provide
diff --git a/markdown.js b/markdown.js
index 34098bd..74c3931 100644
--- a/markdown.js
+++ b/markdown.js
@@ -135,6 +135,7 @@ export function toMarkdown(state, now = Date.now(), extra = {}) {
           ['est', t.estimateMin || null],
           ['spent', byTask.get ? byTask.get(t.id) : null],
           ['tags', (t.tags || []).join('|') || null],
+          ['ddl', t.dueDate],
           ['due', t.review && t.review.due],
           ['interval', t.review && t.review.due ? t.review.intervalDays : null],
           ['ease', t.review && t.review.due ? t.review.ease : null],
@@ -233,6 +234,7 @@ export function fromMarkdown(text) {
         status: meta.done ? 'done' : status,
         note: '',
         estimateMin: Number(meta.est) || 0,
+        dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String(meta.ddl || '')) ? String(meta.ddl) : null,
         tags: normalizeTags((meta.tags || '').split('|')),
         review: meta.due
           ? {
@@ -270,7 +272,7 @@ export function mergeMarkdown(state, parsed) {
   for (const card of parsed.cards || []) {
     const existing = card.id ? state.tasks.find((t) => t.id === card.id) : null
     if (existing) {
-      const changed = ['title', 'subject', 'note', 'estimateMin', 'tags', 'review'].some((k) => {
+      const changed = ['title', 'subject', 'note', 'estimateMin', 'dueDate', 'tags', 'review'].some((k) => {
         const a = JSON.stringify(existing[k])
         const b = JSON.stringify(card[k])
         if (a === b) return false
@@ -295,6 +297,7 @@ export function mergeMarkdown(state, parsed) {
       subject: card.subject,
       note: card.note,
       estimateMin: card.estimateMin,
+      dueDate: card.dueDate || null,
       createdAt: Date.now(),
       updatedAt: Date.now(),
       doneAt: card.status === 'done' ? (card.doneAt || Date.now()) : null,
diff --git a/package.json b/package.json
index cba572c..586a33e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
 {
   "name": "dsh-study-desk",
   "version": "0.7.0",
+  "icon": "./assets/icon.svg",
   "description": "学习工作台 DSH 插件:Notion 式待办墙与标签过滤 + 间隔重复复习队列(简化 SM-2,带记忆曲线、未来 7 天到期预报与今日建议)+ 按实际复习时长点亮的热力图与周报 + 每日复盘 + Markdown 导出读回 + 跨面板存活的番茄钟 + 独立的不背单词打卡面板(只读本机 App 数据)。只消费主题 token,融入现有主题而不改主题。",
   "type": "module",
   "main": "index.js",
@@ -64,6 +65,7 @@
     "bbdc.js",
     "markdown.js",
     "timer.js",
+    "assets/icon.svg",
     "test/",
     "tools/client-loader.mjs",
     "README.md",
diff --git a/test/desk.test.js b/test/desk.test.js
index 123500d..ee33788 100644
--- a/test/desk.test.js
+++ b/test/desk.test.js
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
 
 import {
   addTask,
+  dailyPlanNow,
   dayKey,
   daysUntil,
   dueQueue,
@@ -16,6 +17,7 @@ import {
   normalize,
   normalizeTags,
   parseMilestones,
+  planMinutes,
   reorderTask,
   reviewForecast,
   reviewPlan,
@@ -27,7 +29,9 @@ import {
   subjectBreakdown,
   summary,
   todayMinutes,
+  updateDailyPlan,
   updateSettings,
+  updateTask,
   weeklyReport,
 } from '../desk.js'
 import { completeTimer, makeTimer, pauseTimer, reconcileTimer, remainingMs, resetTimer, startTimer } from '../timer.js'
@@ -563,3 +567,157 @@ test('「结束这一段」按真实时长记账,「放弃」不记账', () =>
   resetTimer(dropped)
   assert.equal(dropped.sessions.length, 0, '放弃不该记账')
 })
+
+test('截止日期:addTask/updateTask 只认 YYYY-MM-DD,别的形状一律清空', () => {
+  const s = normalize(null)
+  const a = addTask(s, { title: '交开题报告', dueDate: '2026-12-01' }, T0)
+  assert.equal(a.dueDate, '2026-12-01')
+  updateTask(s, a.id, { dueDate: '2026-12-15' }, T0)
+  assert.equal(a.dueDate, '2026-12-15', '合法改期能改上')
+  updateTask(s, a.id, { dueDate: '下周五' }, T0)
+  assert.equal(a.dueDate, null, '非法日期清空,而不是保留旧值')
+  const b = addTask(s, { title: '没有截止', dueDate: '明天' }, T0)
+  assert.equal(b.dueDate, null)
+  updateTask(s, b.id, { dueDate: '' }, T0)
+  assert.equal(b.dueDate, null, '空串就是清除')
+})
+
+test('normalize 认得 dueDate:合法保留、非法丢掉,旧文件没这个字段也不炸', () => {
+  const s = normalize({ tasks: [
+    { id: 'a', title: '有截止', dueDate: '2026-11-20' },
+    { id: 'b', title: '坏截止', dueDate: '12月20日' },
+    { id: 'c', title: '没字段' },
+  ] })
+  assert.equal(s.tasks[0].dueDate, '2026-11-20')
+  assert.equal(s.tasks[1].dueDate, null)
+  assert.equal(s.tasks[2].dueDate, null)
+})
+
+test('summary 的 doing/todo 带上 dueDate(系统提示要拿它提醒她)', () => {
+  const s = normalize(null)
+  const a = addTask(s, { title: '写综述', dueDate: '2026-10-20' }, T0)
+  assert.equal(summary(s, T0).todo[0].dueDate, '2026-10-20')
+  updateTask(s, a.id, { status: 'doing' }, T0)
+  assert.equal(summary(s, T0).doing[0].dueDate, '2026-10-20', '挪到进行中之后仍然带着截止')
+})
+
+// ---------------------------------------------------------------------------
+// AI 起钟的会话标记与会话提醒素材(番茄钟督促链路)
+// ---------------------------------------------------------------------------
+
+test('AI 起的钟带会话标记,自动摆的休息继承标记,到点收走提醒素材', () => {
+  const s = normalize(null)
+  updateSettings(s, { focusMin: 25, shortBreakMin: 5 })
+  startTimer(s, { kind: 'focus', by: 'ai', sessionId: 'sess-A' }, T0)
+  assert.equal(s.timer.by, 'ai')
+  assert.equal(s.timer.sessionId, 'sess-A')
+
+  const ended = []
+  assert.equal(reconcileTimer(s, T0 + 25 * MIN, ended), true)
+  assert.equal(ended.length, 1)
+  assert.equal(ended[0].kind, 'focus')
+  assert.equal(ended[0].sessionId, 'sess-A')
+  assert.equal(ended[0].endedAt, T0 + 25 * MIN)
+
+  // 休息继承链条:休息走完时提醒还发给同一个会话
+  assert.equal(s.timer.kind, 'break')
+  assert.equal(s.timer.sessionId, 'sess-A')
+  ended.length = 0
+  assert.equal(reconcileTimer(s, T0 + 30 * MIN, ended), true)
+  assert.equal(ended.length, 1)
+  assert.equal(ended[0].kind, 'break')
+  assert.equal(ended[0].sessionId, 'sess-A')
+})
+
+test('手动起的钟没有会话标记,不产生会话提醒素材', () => {
+  const s = normalize(null)
+  updateSettings(s, { focusMin: 25, shortBreakMin: 5 })
+  startTimer(s, { kind: 'focus', minutes: 25 }, T0)
+  assert.equal(s.timer.by, null)
+  assert.equal(s.timer.sessionId, null)
+  const ended = []
+  reconcileTimer(s, T0 + 25 * MIN, ended)
+  assert.equal(ended.length, 1)
+  assert.equal(ended[0].sessionId, null)
+})
+
+test('手动提前结束后,AI 链条的休息仍然继承会话标记', () => {
+  const s = normalize(null)
+  updateSettings(s, { focusMin: 25, shortBreakMin: 5 })
+  startTimer(s, { kind: 'focus', by: 'ai', sessionId: 'sess-B' }, T0)
+  pauseTimer(s, T0 + 10 * MIN)
+  completeTimer(s, T0 + 20 * MIN)
+  assert.equal(s.timer.kind, 'break')
+  assert.equal(s.timer.sessionId, 'sess-B')
+})
+
+test('updateSettings 能开关 notifySessionOnTimerEnd,不传不动它', () => {
+  const s = normalize(null)
+  assert.equal(s.settings.notifySessionOnTimerEnd, undefined, '默认没写进文件(undefined = 开)')
+  updateSettings(s, { notifySessionOnTimerEnd: false })
+  assert.equal(s.settings.notifySessionOnTimerEnd, false)
+  updateSettings(s, { notifySessionOnTimerEnd: true })
+  assert.equal(s.settings.notifySessionOnTimerEnd, true)
+  updateSettings(s, { focusMin: 30 })
+  assert.equal(s.settings.notifySessionOnTimerEnd, true, '别的设置项不该动它')
+})
+
+// ---------------------------------------------------------------------------
+// 每日计划表:时间段模板 + 「现在该做什么」单点计算
+// ---------------------------------------------------------------------------
+
+test('normalizeDailyPlan:坏行丢掉、好行保留、按开始时刻排序', () => {
+  const s = normalize({ dailyPlan: { enabled: true, items: [
+    { start: '14:00', end: '16:00', label: '864 论述练习' },
+    '这不是对象',
+    { start: '08:00', end: '09:30', label: '背单词', subject: '英语' },
+    { start: '09:30', end: '08:00', label: '起终写反' },
+    { start: '25:00', end: '26:00', label: '非法时刻' },
+    { start: '08:00', end: '09:00', label: '' },            // 没名字的丢
+    { start: '12:00', end: '13:00', label: '午休', id: 'x' },
+    { start: '07:00', end: '07:40', label: '早读', id: 'x' }, // id 撞了会重发一个
+  ] } })
+  const items = s.dailyPlan.items
+  assert.equal(items.length, 4)
+  assert.deepEqual(items.map((x) => x.label), ['早读', '背单词', '午休', '864 论述练习'], '按开始时刻排好了')
+  assert.notEqual(items[1].id, 'x', '撞 id 的那条要重新发 id')
+  assert.equal(s.dailyPlan.enabled, true)
+  assert.equal(s.dailyPlan.float, false)
+})
+
+test('dailyPlanNow:区间命中与下一场', () => {
+  const s = normalize({ dailyPlan: { enabled: true, items: [
+    { start: '08:00', end: '09:30', label: '背单词' },
+    { start: '14:00', end: '16:00', label: '864 论述练习' },
+  ] } })
+  // 用固定一天里的时刻构造 now(本地时区)
+  const at = (h, m) => new Date(2026, 10, 5, h, m).getTime()
+  assert.equal(dailyPlanNow(s, at(8, 15)).current.label, '背单词')
+  assert.equal(dailyPlanNow(s, at(8, 15)).next.label, '864 论述练习')
+  assert.equal(dailyPlanNow(s, at(9, 30)).current, null, 'end 时刻已切出区间')
+  assert.equal(dailyPlanNow(s, at(9, 30)).next.label, '864 论述练习')
+  assert.equal(dailyPlanNow(s, at(23, 59)).current, null)
+  assert.equal(dailyPlanNow(s, at(23, 59)).next, null, '当天没有下一场了')
+  assert.equal(dailyPlanNow(s, at(15, 0)).current.minutes, undefined, 'current 就是条目本身')
+})
+
+test('dailyPlanNow:没启用就只发条目不发判定', () => {
+  const s = normalize({ dailyPlan: { items: [{ start: '08:00', end: '09:00', label: '背单词' }] } })
+  const now = dailyPlanNow(s)
+  assert.equal(now.current, null)
+  assert.equal(now.items.length, 1, '条目照发,客户端设置页编辑器还要用它')
+  assert.equal(dailyPlanNow(normalize(null)).items.length, 0)
+})
+
+test('updateDailyPlan:单独开关 enabled/float,互不牵连', () => {
+  const s = normalize({ dailyPlan: { enabled: true, float: true, items: [
+    { start: '08:00', end: '09:00', label: '背单词' },
+  ] } })
+  updateDailyPlan(s, { float: false })
+  assert.equal(s.dailyPlan.enabled, true)
+  assert.equal(s.dailyPlan.float, false)
+  assert.equal(s.dailyPlan.items.length, 1, '开关不动条目')
+  updateDailyPlan(s, { enabled: false, items: [] })
+  assert.equal(s.dailyPlan.enabled, false)
+  assert.equal(s.dailyPlan.items.length, 0)
+})
diff --git a/test/markdown.test.js b/test/markdown.test.js
index 426eefa..fda9d2c 100644
--- a/test/markdown.test.js
+++ b/test/markdown.test.js
@@ -26,6 +26,7 @@ function seeded() {
   addTask(s, { title: '英语阅读 2018 Text1', subject: '英语', review: true }, T0)
   const done = addTask(s, { title: '马原原理背诵', subject: '政治', status: 'done' }, T0)
   updateTask(s, a.id, { note: '重点:议程设置\n多看两页' }, T0)
+  updateTask(s, a.id, { dueDate: '2026-11-01' }, T0)
   scheduleTask(s, a.id, '2026-10-06', T0)
   gradeTask(s, done.id, 'good', T0)
   logSession(s, { minutes: 50, at: T0, taskId: a.id })
@@ -52,6 +53,7 @@ test('导出再读回:卡片、标签、笔记、复习排期与复盘都对
   assert.equal(a.subject, '631 公共管理')
   assert.equal(a.status, 'todo')
   assert.equal(a.estimateMin, 45)
+  assert.equal(a.dueDate, '2026-11-01', 'ddl 机器字段应该往返保留')
   assert.deepEqual(a.tags, ['名词解释', '错题'])
   assert.equal(a.note, '重点:议程设置\n多看两页')
   assert.equal(a.review.due, '2026-10-06')
@@ -128,4 +130,19 @@ test('导出的 Markdown 里不含本机路径,注释能被 Obsidian 阅读模
   assert.ok(!/[A-Za-z]:[\\/]/.test(md), '不该出现 Windows 绝对路径')
   assert.match(md, /', '',
+  ].join('\n')
+  mergeMarkdown(s, fromMarkdown(md))
+  assert.equal(s.tasks[0].dueDate, '2026-11-30')
+  const again = mergeMarkdown(s, fromMarkdown(md))
+  assert.deepEqual(again, { added: 0, updated: 0 }, '原样再读一遍不该算改动')
+  mergeMarkdown(s, fromMarkdown(md.replace(' ddl:2026-11-30', '')))
+  assert.equal(s.tasks[0].dueDate, null, 'ddl 没了 = 她删掉了截止,读回时清空')
 })
diff --git a/test/mini-dock.test.js b/test/mini-dock.test.js
index 8b2102d..340bd7f 100644
--- a/test/mini-dock.test.js
+++ b/test/mini-dock.test.js
@@ -63,3 +63,38 @@ test('迷你条不许停进 Windows 标题栏那条拖拽带', () => {
   attrs.delete('data-windows-titlebar')
   assert.equal(clampMiniPos({ x: 500, y: 6 }, W, H).y, 6, '非 Windows 标题栏不该凭空留 40px')
 })
+
+// 每日计划表悬浮件是第二个 shell.overlay:坑位规矩必须与迷你条一致,
+// 否则就是「看得见、点不动、拖不动」那类真机故障的复刻。
+
+test('计划表悬浮件声明 no-drag 与自持命中(pointer-events / z-index:900)', () => {
+  const { CSS } = loadClient()
+  for (const sel of ['.sd-plandock', '.sd-plandock-fab']) {
+    const rule = CSS.split('}').find((r) => r.startsWith(`${sel}{`))
+    assert.ok(rule, `${sel} 规则没找到`)
+    assert.match(rule, /-webkit-app-region:\s*no-drag/, `${sel} 没声明 no-drag`)
+    assert.match(rule, /pointer-events:\s*auto/, `${sel} 没声明 pointer-events:auto`)
+    assert.match(rule, /z-index:\s*900/, `${sel} 没声明 z-index:900`)
+  }
+})
+
+test('计划表悬浮件默认位在迷你条上方,两个悬浮件默认位不打架', () => {
+  const { CSS } = loadClient()
+  const ruleFor = (sel) => CSS.split('}').find((r) => r.startsWith(`${sel}{`))
+  const mini = ruleFor('.sd-mini')
+  const plan = ruleFor('.sd-plandock')
+  assert.match(mini, /bottom:\s*18px/, '迷你条默认 bottom 变了的话这条也要跟着改')
+  assert.match(plan, /bottom:\s*64px/, '计划表悬浮件默认压在迷你条身上了')
+})
+
+test('计划表悬浮件也走 clampMiniPos(标题栏拖拽带的守卫自动继承)', () => {
+  // 拖动手势与钳制函数是同一份:源码里 PlanDock 用的就是 clampMiniPos,
+  // 这里用形状校验锁住「没另起炉灶」——另写一份迟早漏掉标题栏那条带。
+  const code = readClientSource()
+  const dockStart = code.indexOf('function PlanDock(')
+  const dockEnd = code.indexOf('// 设置页', dockStart)
+  assert.ok(dockStart > 0 && dockEnd > dockStart, 'PlanDock 没找到')
+  const dock = code.slice(dockStart, dockEnd)
+  assert.ok(dock.includes('clampMiniPos'), 'PlanDock 没用 clampMiniPos 钳制落点')
+  assert.ok(!/function\s+clamp/.test(dock), 'PlanDock 里另写了一份钳制实现')
+})
diff --git a/test/style.test.js b/test/style.test.js
index d5cf0f3..1a8b296 100644
--- a/test/style.test.js
+++ b/test/style.test.js
@@ -43,6 +43,8 @@ test('投入类图表走绿色阶、到期预报走暖色,不再用会掉成
   }
   assert.match(rule('.sd-week.current .sd-week-fill'), /--dsw-static-green-500/, '周报当前周该跟热力图同族')
   assert.match(rule('.sd-bar-study .sd-bar-fill'), /--dsw-static-green-500/, '今日进度条该跟热力图同族')
+  assert.match(rule('.sd-subj-bar .sd-bar-fill'), /--dsw-static-green-500/, '近30天/周报的科目投入条该跟热力图同族(brand 在暗色主题解析成近白)')
+  assert.match(rule('.sd-recent-bar i'), /--dsw-static-green-500/, '单词打卡近 7 天的复习条也是投入语义,走绿族')
   assert.match(rule('.sd-fbar-fill'), /--dsw-static-amber-500/, '到期预报柱体该走暖色')
   assert.match(rule('.sd-fbar.today .sd-fbar-fill'), /--dsw-static-red-500/, '今天那一根该转红')
   // 计时器那条她没让改,别顺手改齐
diff --git a/timer.js b/timer.js
index 454acfe..35d1c9a 100644
--- a/timer.js
+++ b/timer.js
@@ -67,6 +67,10 @@ export function makeTimer(state, input, now = Date.now()) {
   // 番茄ToDo 式命名:session.title 是这个番茄的「户口」,饼图按它聚合——起名发生在起钟这一刻。
   // 优先级:手动写的名字 > 挂的卡片标题 > 兜底桶名(账不丢)。
   const custom = String((input && (input.name || input.title)) || '').trim().slice(0, 24)
+  // AI 起的钟记下起钟会话:走完时宿主要往那个会话发自动提醒(督促用)。
+  // by 不是 'ai' 就一律当手动,不存会话,也不提醒——「只有大模型调整番茄钟才用」。
+  const by = input && input.by === 'ai' ? 'ai' : null
+  const sessionId = by === 'ai' ? (String((input && input.sessionId) || '') || null) : null
   const span = minutes * MINUTE
   return {
     kind,
@@ -75,6 +79,8 @@ export function makeTimer(state, input, now = Date.now()) {
     taskId: task ? task.id : null,
     title: custom || (task ? task.title : (kind === 'focus' ? '专注(未命名)' : '')),
     subject: task ? task.subject : String((input && input.subject) || ''),
+    by,
+    sessionId,
     startedAt: now,
     endsAt: now + span,
     running: true,
@@ -130,8 +136,11 @@ function pushFocus(state, timer, minutes, at) {
  * 把已经走完的计时器结账。返回 state 是否被改动。
  * 专注走完 → 记一条 session,并按节奏自动摆上休息;
  * 若人已经离开很久(连休息也过去了),就不再摆一个已经过期的计时器。
+ *
+ * endedSink(可选)收走「哪一段走完了」的素材:宿主拿它给起钟会话发自动提醒。
+ * 传数组进来,走完一段就 push 一条 {kind,title,subject,minutes,by,sessionId,endedAt}。
  */
-export function reconcileTimer(state, now = Date.now()) {
+export function reconcileTimer(state, now = Date.now(), endedSink = null) {
   const t = state.timer
   if (!t || !t.running || !(t.endsAt <= now)) return false
   if (t.kind === 'focus') {
@@ -147,6 +156,9 @@ export function reconcileTimer(state, now = Date.now()) {
         taskId: null,
         title: '',
         subject: '',
+        // 休息继承起钟会话:这条链是 AI 起的,休息走完也要提醒它收尾
+        by: t.by,
+        sessionId: t.sessionId,
         startedAt: t.endsAt,
         endsAt,
         running: true,
@@ -154,8 +166,14 @@ export function reconcileTimer(state, now = Date.now()) {
         spentMs: 0,
       }
       : null
+    if (endedSink) {
+      endedSink.push({ kind: 'focus', title: t.title, subject: t.subject, minutes: t.minutes, by: t.by, sessionId: t.sessionId, endedAt: t.endsAt })
+    }
   } else {
     state.timer = null
+    if (endedSink) {
+      endedSink.push({ kind: 'break', minutes: t.minutes, by: t.by, sessionId: t.sessionId, endedAt: t.endsAt })
+    }
   }
   return true
 }
@@ -176,6 +194,8 @@ export function completeTimer(state, now = Date.now()) {
       taskId: null,
       title: '',
       subject: '',
+      by: t.by,
+      sessionId: t.sessionId,
       startedAt: now,
       endsAt: now + minutes * MINUTE,
       running: true,
diff --git a/tools/compose-final.py b/tools/compose-final.py
new file mode 100644
index 0000000..3f038f7
--- /dev/null
+++ b/tools/compose-final.py
@@ -0,0 +1,51 @@
+# 最终确认图:旧色 vs 新色,浅底/深底各一列。
+import os
+from PIL import Image, ImageDraw, ImageFont
+
+S = os.path.join(os.path.dirname(__file__), '..', '.shots')
+p = lambda n: os.path.join(S, n)
+try:
+    F = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 19)
+    FS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 14)
+except Exception:
+    F = FS = ImageFont.load_default()
+BG, FG = (24, 26, 30), (234, 236, 240)
+# 裁切框来自 harness 的 diag(两种主题同一套布局)
+BOX = {'heat': (236, 1206, 646, 1446), 'bars': (258, 640, 1216, 782)}
+SCALE = {'heat': 2, 'bars': 1}
+
+
+def sheet(kind, title, old_src, new_src):
+    sc = SCALE[kind]
+    rows = []
+    for label, src in [('旧:品牌色(主题里掉成灰阶)', old_src), ('新:' + {'heat': '绿色阶', 'bars': '暖色(今天转红)'}[kind], new_src)]:
+        cells = []
+        for dk, tag in [(0, '浅底'), (1, '深底')]:
+            im = Image.open(p(f'{src}-{dk}.png')).convert('RGB').crop(BOX[kind])
+            im = im.resize((im.width * sc, im.height * sc), Image.LANCZOS)
+            bar = Image.new('RGB', (im.width, im.height + 22), BG)
+            ImageDraw.Draw(bar).text((8, 3), tag, font=FS, fill=FG)
+            bar.paste(im, (0, 22))
+            cells.append(bar)
+        roww = sum(c.width for c in cells) + 16
+        row = Image.new('RGB', (roww, max(c.height for c in cells)), BG)
+        x = 0
+        for c in cells:
+            row.paste(c, (x, 0))
+            x += c.width + 16
+        rows.append((label, row))
+    lw = 330
+    out = Image.new('RGB', (lw + max(r.width for _, r in rows) + 32, 56 + sum(r.height + 30 for _, r in rows)), BG)
+    d = ImageDraw.Draw(out)
+    d.text((16, 14), title, font=F, fill=FG)
+    y = 56
+    for label, row in rows:
+        d.text((16, y + 6), label, font=FS, fill=FG)
+        out.paste(row, (lw, y))
+        y += row.height + 30
+    out.save(p(f'final-{kind}.png'))
+    print('final-%s.png' % kind, out.size)
+
+
+sheet('heat', '学习热力图 · 改色前后', 'g-cur', 'final')
+sheet('bars', '今日复习 · 未来 7 天柱状图 改色前后', 'g-cur', 'final')
diff --git a/tools/compose-gallery.py b/tools/compose-gallery.py
new file mode 100644
index 0000000..c9ae397
--- /dev/null
+++ b/tools/compose-gallery.py
@@ -0,0 +1,61 @@
+# 把三套候选色 + 现在这版拼成两张可选对照表(热力图、复习柱状图),深浅底各一列。
+import os
+from PIL import Image, ImageDraw, ImageFont
+
+S = os.path.join(os.path.dirname(__file__), '..', '.shots')
+p = lambda n: os.path.join(S, n)
+try:
+    F = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 20)
+    FS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 15)
+except Exception:
+    F = FS = ImageFont.load_default()
+
+BG = (24, 26, 30)
+FG = (234, 236, 240)
+VARIANTS = [('cur', '现在这版(品牌色,深色主题下接近灰白)'), ('green', 'A 绿色阶梯'), ('warm', 'B 暖色番茄(琥珀→红)'), ('teal', 'C 青色')]
+# rect 来自 harness 的 diag:heat=281,1246,230,131  forecast=281,672,913,71(两种主题同一套布局)
+BOX = {'heat': (236, 1210, 640, 1442), 'bars': (258, 640, 1216, 780)}
+
+
+def sheet(kind, title):
+    scale = 2 if kind == 'heat' else 1
+    rows = []
+    for key, label in VARIANTS:
+        ims = []
+        for dk in (0, 1):
+            im = Image.open(p(f'g-{key}-{dk}.png')).convert('RGB').crop(BOX[kind])
+            im = im.resize((im.width * scale, im.height * scale), Image.LANCZOS)
+            ims.append(im)
+        h = min(i.height for i in ims)
+        cells = []
+        for i, tag in zip(ims, ['浅底', '深底']):
+            bar = Image.new('RGB', (i.width, i.height + 24), BG)
+            d = ImageDraw.Draw(bar)
+            d.text((8, 3), tag, font=FS, fill=FG)
+            bar.paste(i, (0, 24))
+            cells.append(bar.crop((0, 0, i.width, h + 24)))
+        roww = sum(c.width for c in cells) + 16
+        row = Image.new('RGB', (roww, max(c.height for c in cells)), BG)
+        x = 0
+        for c in cells:
+            row.paste(c, (x, 0))
+            x += c.width + 16
+        rows.append((label, row))
+
+    labelw = 460
+    W = labelw + max(r.width for _, r in rows) + 32
+    H = 60 + sum(28 + r.height for _, r in rows) + 16 * len(rows)
+    out = Image.new('RGB', (W, H), BG)
+    d = ImageDraw.Draw(out)
+    d.text((16, 16), title, font=F, fill=FG)
+    y = 60
+    for label, row in rows:
+        d.text((16, y + row.height // 2 - 10), label, font=FS, fill=FG)
+        out.paste(row, (labelw, y))
+        y += row.height + 16 + 12
+    out.save(p(f'gallery-{kind}.png'))
+    print('gallery-%s.png' % kind, out.size)
+
+
+sheet('heat', '学习热力图:四套候选色')
+sheet('bars', '今日复习卡 · 未来 7 天柱状图:四套候选色')
diff --git a/tools/compose-shots.py b/tools/compose-shots.py
new file mode 100644
index 0000000..5b549eb
--- /dev/null
+++ b/tools/compose-shots.py
@@ -0,0 +1,69 @@
+# 把 .shots 里的截图拼成两张对照图:整页 2x2、饼图放大对照。
+# 只在本地生成(.shots 已 gitignore),不上传任何东西。
+import os
+from PIL import Image, ImageDraw, ImageFont
+
+S = os.path.join(os.path.dirname(__file__), '..', '.shots')
+p = lambda n: os.path.join(S, n)
+
+try:
+    FONT = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 22)
+    FONTS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 17)
+except Exception:
+    FONT = FONTS = ImageFont.load_default()
+
+BG = (24, 26, 30)
+FG = (232, 234, 238)
+
+
+def labeled(img, text, font):
+    bar = font.size + 16
+    out = Image.new('RGB', (img.width, img.height + bar), BG)
+    out.paste(img, (0, bar))
+    d = ImageDraw.Draw(out)
+    d.text((12, 6), text, font=font, fill=FG)
+    return out
+
+
+def grid(items, cols, w, gap=20, labels_above=True):
+    cells = []
+    for img, text in items:
+        scale = w / img.width
+        im = img.resize((w, int(img.height * scale)), Image.LANCZOS)
+        cells.append(labeled(im, text, FONTS) if labels_above else im)
+    rows = [cells[i:i + cols] for i in range(0, len(cells), cols)]
+    rh = [max(c.height for c in r) for r in rows]
+    W = sum(c.width for c in rows[0]) + gap * (cols + 1)
+    H = sum(rh) + gap * (len(rows) + 1)
+    out = Image.new('RGB', (W, H), BG)
+    y = gap
+    for r, h in zip(rows, rh):
+        x = gap
+        for c in r:
+            out.paste(c, (x, y))
+            x += c.width + gap
+        y += h + gap
+    return out
+
+
+# ---- 1. 整页对照
+pairs = [('old-light.png', '改前 · 浅色'), ('new-light.png', '改后 · 浅色'),
+         ('old-dark.png', '改前 · 深色'), ('new-dark.png', '改后 · 深色')]
+imgs = [(Image.open(p(n)).convert('RGB'), t) for n, t in pairs]
+grid(imgs, 2, 600).save(p('compare.png'))
+
+# ---- 2. 饼图放大对照(rect 从 harness 的 diag 里读到)
+RECTS = {'old': (862, 1044, 335, 112), 'new': (861, 1082, 333, 117)}
+PAD = 14
+crops = []
+for v, label in [('old', '改前 · 灰阶语义色(5 色里有 3 个其实是灰的)'), ('new', '改后 · 宿主色板分色')]:
+    x, y, w, h = RECTS[v]
+    box = (max(0, x - PAD), max(0, y - PAD), x + w + PAD, y + h + PAD)
+    im = Image.open(p(f'{v}-light.png')).convert('RGB').crop(box)
+    im = im.resize((im.width * 3, im.height * 3), Image.LANCZOS)
+    crops.append((im, label))
+grid(crops, 1, 1000, labels_above=True).save(p('pie-compare.png'))
+
+for n in ['compare.png', 'pie-compare.png']:
+    im = Image.open(p(n))
+    print(n, im.size, os.path.getsize(p(n)) // 1024, 'KB')
diff --git a/tools/shot-harness.mjs b/tools/shot-harness.mjs
new file mode 100644
index 0000000..e021917
--- /dev/null
+++ b/tools/shot-harness.mjs
@@ -0,0 +1,360 @@
+/**
+ * 视觉验证用的「真挂载」外壳生成器。
+ *
+ * 为什么要它:apply-probe 只跑到「factory 返回了 Boundary」,看不到布局;而界面改动的坑
+ * (中心列 overflow:hidden、层叠上下文、字号阶梯没挂到根上)只有真渲染才暴露得出来。
+ * 这里按 AGENTS.md 记载的做法:真 React 18 UMD + 复刻宿主外壳 + 宿主自己的 --dsw-* token,
+ * 然后把 client.js 当真插件加载进来、把槽位挂出来。
+ *
+ * 数据用她本机真实的 desk.json(拷到临时 DSH_HOME,绝不写回原文件),截图才是她会看到的那一屏。
+ *
+ * 用法:node tools/shot-harness.mjs   → 产物在 .shots/(已 gitignore)
+ */
+import { execFileSync } from 'node:child_process'
+import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
+const ASAR = 'D:/Program Files/DeepSeek Harness/resources/app.asar'
+const REACT_DIR = 'D:/chatbox/node_modules'
+const REAL_HOME = 'D:/DSH/.dsh'
+const OUT = join(ROOT, '.shots')
+
+mkdirSync(OUT, { recursive: true })
+
+// ---------------------------------------------------------------- 1. 真快照
+// 宿主半边的 snapshot() 没导出,所以照 apply-probe 的办法起个 stub ctx,走真路由取快照。
+// DSH_HOME 先指到临时目录并拷进她的 desk.json —— 读真实数据,但不碰真实文件。
+const HOME = mkdtempSync(join(tmpdir(), 'desk-shot-'))
+mkdirSync(join(HOME, 'study-desk'), { recursive: true })
+for (const f of ['desk.json', 'bbdc.json']) {
+  try { cpSync(join(REAL_HOME, 'study-desk', f), join(HOME, 'study-desk', f)) } catch { /* 没有就算了 */ }
+}
+process.env.DSH_HOME = HOME
+
+const routes = []
+const disposers = []
+const ctx = {
+  tools: { register: () => () => {} },
+  webServer: { register: (r) => { routes.push(r); return () => {} } },
+  // 快照取完就把 effect 的清理函数都跑掉:宿主半边现在带着 10 秒看门狗
+  // setInterval(番茄钟会话提醒的兜底),不清理这个进程永远退不出去。
+  effect: (f) => { const d = f(); if (typeof d === 'function') disposers.push(d); return () => {} },
+  on: () => () => {},
+  set: (k, v) => { services[k] = v },
+  provide: (k, v) => { services[k] = v; return () => {} },
+  get: (k) => services[k],
+  log: () => {},
+  logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
+  dirs: { home: HOME },
+}
+const services = {}
+const host = await import('../index.js')
+host.apply(ctx, {})
+for (const d of disposers) { try { d() } catch { /* 清理失败无所谓 */ } }
+
+function fakeRes() {
+  const res = { code: 0, chunks: [] }
+  res.setHeader = () => {}
+  res.writeHead = (code) => { res.code = code }
+  res.write = (s) => { res.chunks.push(s); return true }
+  res.end = (s) => { if (s !== undefined) res.chunks.push(s); res.body = res.chunks.join('') }
+  return res
+}
+const route = routes.find((x) => x.path === '/api/dsh-study-desk')
+if (!route) throw new Error(`没注册路由,拿到的是 ${JSON.stringify(routes.map((r) => r && r.path))}`)
+const res = fakeRes()
+await route.handler({ method: 'GET', url: '/api/dsh-study-desk', headers: {}, on() {} }, res)
+const snapshot = JSON.parse(res.body)
+
+// 顺手验 POST 通路的 weekly.text op(「复制周报」按钮用的纯文本,格式与工具 report 同一份)
+const resW = fakeRes()
+await route.handler({
+  headers: {},
+  on(ev, cb) {
+    if (ev === 'data') cb(Buffer.from(JSON.stringify({ op: 'weekly.text' })))
+    if (ev === 'end') cb()
+  },
+}, resW)
+let weeklyText = ''
+try { weeklyText = JSON.parse(resW.body).text || '' } catch { /* op 挂了就留空,下面会报 */ }
+console.log('[weekly.text] ok:', weeklyText.includes('周(每周从周一算起)'), '| head:', (weeklyText.split('\n')[0] || 'n/a').slice(0, 40))
+rmSync(HOME, { recursive: true, force: true })
+// 快照里带着临时 home 的绝对路径,会原样印进页脚 —— 截图是要给人看的,换成占位写法。
+for (const key of ['deskFile', 'markdownFile']) {
+  if (snapshot[key]) snapshot[key] = `/study-desk/${String(snapshot[key]).split(/[\\/]/).pop()}`
+}
+console.log('[snapshot] keys:', Object.keys(snapshot).join(','))
+console.log('[snapshot] focusPie today slices:', (snapshot.focusPie?.today || []).length,
+  '| month:', (snapshot.focusPie?.month || []).length,
+  '| board:', JSON.stringify(Object.fromEntries(Object.entries(snapshot).filter(([k]) => /task|board|column|state/.test(k)).map(([k, v]) => [k, Array.isArray(v) ? v.length : typeof v]))))
+
+// ---------------------------------------------------------------- 2. 宿主 token
+function extractTokens() {
+  const asar = readFileSync(ASAR, 'latin1')
+  const blocks = []
+  const re = /(body(?:\[data-ds-dark-theme\])?)\{(--dsw-[^}]*)\}/g
+  let m
+  while((m = re.exec(asar))) blocks.push({ sel: m[1], first: m[2].split(';')[0].split(':')[0], css: m[0] })
+  // 同一个 selector + 同一组变量的重复定义里挑最完整的那份(asar 里有 minified / 非 minified 两份拷贝)
+  const best = new Map()
+  for (const b of blocks) {
+    const key = b.sel + '|' + b.first.slice(0, 22)
+    if (!best.has(key) || best.get(key).length < b.css.length) best.set(key, b.css)
+  }
+  const dark = [...best.values()].filter((c) => c.startsWith('body[data-ds-dark-theme]')).join('\n')
+  const light = [...best.values()].filter((c) => !c.startsWith('body[data-ds-dark-theme]')).join('\n')
+  return { light, dark }
+}
+const tokens = extractTokens()
+console.log('[tokens] light', tokens.light.length, 'B | dark', tokens.dark.length, 'B')
+
+// ---------------------------------------------------------------- 3. 两版 client.js
+const clientNew = readFileSync(join(ROOT, 'client.js'), 'utf8')
+const clientOld = execFileSync('git', ['show', 'HEAD:client.js'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
+writeFileSync(join(OUT, 'client.new.js'), clientNew)
+writeFileSync(join(OUT, 'client.old.js'), clientOld)
+cpSync(join(REACT_DIR, 'react/umd/react.production.min.js'), join(OUT, 'react.js'))
+cpSync(join(REACT_DIR, 'react-dom/umd/react-dom.production.min.js'), join(OUT, 'react-dom.js'))
+
+// ---------------------------------------------------------------- 4. 外壳
+// 热力图 + 「今日复习」卡里那排柱状图的三套候选色,全部由宿主 token 派生。
+const G = 'var(--dsw-static-green-500)'
+const A4 = 'var(--dsw-static-amber-400)'
+const A5 = 'var(--dsw-static-amber-500)'
+const R5 = 'var(--dsw-static-red-500)'
+const TEAL = 'color-mix(in srgb,var(--dsw-static-blue-450) 55%,var(--dsw-static-green-400))'
+const BG3 = 'var(--dsw-alias-bg-layer-3)'
+const ramp = (c) => [
+  `.sd-cell.lv1{background:color-mix(in srgb,${c} 24%,${BG3})}`,
+  `.sd-cell.lv2{background:color-mix(in srgb,${c} 48%,${BG3})}`,
+  `.sd-cell.lv3{background:color-mix(in srgb,${c} 74%,${BG3})}`,
+  `.sd-cell.lv4{background:${c}}`,
+].join('')
+const HEAT_VARIANTS = {
+  green: ramp(G) + `.sd-fbar-fill{background:color-mix(in srgb,${G} 46%,transparent)}.sd-fbar.today .sd-fbar-fill{background:${G}}`,
+  teal: ramp(TEAL) + `.sd-fbar-fill{background:color-mix(in srgb,${TEAL} 46%,transparent)}.sd-fbar.today .sd-fbar-fill{background:${TEAL}}`,
+  // 暖色不是等比拉饱和:低档用琥珀、高档压到红,才看得出「越久越热」而不是「越来越橙」
+  warm: [
+    `.sd-cell.lv1{background:color-mix(in srgb,${A4} 34%,${BG3})}`,
+    `.sd-cell.lv2{background:${A5}}`,
+    `.sd-cell.lv3{background:color-mix(in srgb,${A5} 55%,${R5})}`,
+    `.sd-cell.lv4{background:${R5}}`,
+    `.sd-fbar-fill{background:color-mix(in srgb,${A5} 52%,transparent)}`,
+    `.sd-fbar.today .sd-fbar-fill{background:${R5}}`,
+  ].join(''),
+}
+
+const boot = String.raw`
+const Q = new URLSearchParams(location.search)
+const DARK = Q.get('dark') === '1'
+if (DARK) document.body.setAttribute('data-ds-dark-theme', '')
+// ?heat=green|warm|teal 给热力图与复习柱状图套一层候选配色(同优先级、写在后面就能覆盖)。
+// 色值一律走宿主 --dsw-static-*,和插件的「绝不写死颜色」规矩一致。
+const HEAT = ${JSON.stringify(HEAT_VARIANTS)}
+if (Q.get('heat') && HEAT[Q.get('heat')]) {
+  const st = document.createElement('style')
+  st.textContent = HEAT[Q.get('heat')]
+  document.head.appendChild(st)
+}
+window.__ModuleLoader__ = { load: (def) => { window.__def = def } }
+const API = '/api/dsh-study-desk'
+window.__calls = []
+window.addEventListener('error', (e) => { window.__bootError = (window.__bootError || '') + ' ' + e.message })
+window.fetch = async (url, opts) => {
+  const body = opts && opts.body ? JSON.parse(opts.body) : {}
+  window.__calls.push(body.op || 'GET')
+  // 行为断言有时要看完整 payload(比如 task.add 里的 dueDate),跟 op 名分开记
+  ;(window.__callBodies = window.__callBodies || []).push(body)
+  if (body.op === 'diag.report') return { ok: true, status: 200, json: async () => ({ ok: true }) }
+  // 让假宿主稍微带点状态:settings.update 真把 patch 合进快照,
+  // 这样才能验「输入 90 → 秒表显示 90:00」这条链,而不是只验渲染出来了个输入框。
+  if (body.op === 'settings.update' && body.patch) Object.assign(window.__SNAPSHOT__.state.settings, body.patch)
+  // ?plan=1 验每日计划表时要能点设置页开关:假宿主把 patch 合回 dailyPlan
+  if (body.op === 'dailyPlan.update' && body.patch) {
+    const dp = window.__SNAPSHOT__.state.dailyPlan = window.__SNAPSHOT__.state.dailyPlan || { enabled: false, float: false, items: [] }
+    if ('enabled' in body.patch) dp.enabled = !!body.patch.enabled
+    if ('float' in body.patch) dp.float = !!body.patch.float
+    if (body.patch.items) dp.items = body.patch.items
+  }
+  return { ok: true, status: 200, json: async () => window.__SNAPSHOT__ }
+}
+`
+
+const mount = String.raw`
+const require = (name) => (name === 'react' ? window.React : name === 'react-dom' ? window.ReactDOM : {})
+const mod = window.__def.factory(require)
+const slots = []
+// client.js 只走 slots.inject(slot, () => slots.register(meta, factory)),
+// 而 meta.name 就是槽位名,所以登记处按 meta.name 收即可。
+const slotsSvc = {
+  inject: (slot, fn) => { fn(); return () => {} },
+  register: (meta, factory) => { slots.push({ meta, factory }); return () => {} },
+}
+const ctx = { get: (k) => (k === 'slots' ? slotsSvc : undefined), effect: (f) => { f(); return () => {} }, provide: () => () => {}, log: () => {} }
+mod.apply(ctx)
+
+const hostOf = (meta) => {
+  if (meta.name === 'main') return meta.key === 'study-desk' ? document.getElementById('main') : document.getElementById('main2')
+  if (meta.name === 'shell.overlay') return document.getElementById('overlay')
+  if (meta.name === 'sidebar.panellist') return document.getElementById('nav')
+  if (meta.name === 'settings.section') return document.getElementById('settings')
+  return null
+}
+// 先让 apply() 里的 store.refresh() 把快照取回来,再挂载:一挂载就有数据,
+// 不用等第二次并发重渲染(虚拟时间未必泵得动 MessageChannel)。
+setTimeout(() => {
+  for (const s of slots) {
+    const host = hostOf(s.meta)
+    if (!host) continue
+    // 无头 --dump-dom/--screenshot 靠 --virtual-time-budget 推进时间,而 React 18 的并发 root
+    // 是 MessageChannel 调度的,虚拟时间不一定泵得动 —— flushSync 强制同步提交。
+    ReactDOM.flushSync(() => ReactDOM.createRoot(host).render(s.factory()))
+  }
+  window.__slotCount = slots.length
+  // ?range=month:切到「近 30 天」,她那个月饼才有 5 段,颜色分不分得开才看得出来。
+  // React 的 onClick 会在 dispatch 里同步跑,外面套 flushSync 才能让这次状态更新立刻提交。
+  if (Q.get('range') === 'month') {
+    const pill = document.querySelectorAll('.sd-range-tabs button')[1]
+    if (pill) ReactDOM.flushSync(() => pill.dispatchEvent(new MouseEvent('click', { bubbles: true })))
+  }
+  // ?setmin=90:往「自定义」那个框里填值并回车提交,验「输入 → settings → 秒表」这条链
+  const setMin = Q.get('setmin')
+  if (setMin) {
+    const el = document.querySelector('.sd-timer-card input[type=number]')
+    if (el) {
+      const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
+      setter.call(el, setMin)
+      el.dispatchEvent(new Event('input', { bubbles: true }))
+      el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+    }
+  }
+  // ?planopen=1:把计划表悬浮件展开(验全天列表渲染得出来)
+  if (Q.get('planopen') === '1') {
+    const btn = document.querySelector('.sd-plandock .sd-plandock-main button')
+    if (btn) ReactDOM.flushSync(() => btn.dispatchEvent(new MouseEvent('click', { bubbles: true })))
+  }
+  // ?planfloatoff=1:从设置页把「悬浮在桌面上」关掉(验开关 → 悬浮件消失这条链)。
+  // 设置页里的 checkbox 顺序:计划表启用(0) / 计划表悬浮(1) / 注入提示(2) / 走完提醒(3)
+  if (Q.get('planfloatoff') === '1') {
+    const cb = document.querySelectorAll('#settings input[type=checkbox]')[1]
+    if (cb) ReactDOM.flushSync(() => cb.dispatchEvent(new MouseEvent('click', { bubbles: true })))
+  }
+  setTimeout(() => {
+    const diag = document.getElementById('diag')
+    const rectOf = (sel) => { const el = document.querySelector(sel); if (!el) return 'none'; const r = el.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)].join(',') }
+    const page = document.querySelector('.sd-page')
+    const err = document.querySelector('.sd-error')
+    const pie = (window.__SNAPSHOT__.focusPie || {})
+    diag.textContent = [
+      'react=' + window.React.version,
+      'slots=' + slots.length,
+      'mounted=' + Boolean(page),
+      'pageH=' + Math.round(page ? page.getBoundingClientRect().height : 0),
+      'mainKids=' + document.getElementById('main').childElementCount,
+      'pieMonth=' + ((pie.month || []).length),
+      'cards=' + document.querySelectorAll('.sd-card').length,
+      'heatCells=' + document.querySelectorAll('.sd-cell').length,
+      'slices=' + document.querySelectorAll('.sd-pie path').length,
+    'timerNumFields=' + document.querySelectorAll('.sd-timer-card input[type=number]').length,
+    'focusMinNow=' + window.__SNAPSHOT__.state.settings.focusMin,
+    'idleClock=' + ((document.querySelector('.sd-timer-clock') || {}).textContent || 'none'),
+    'playTitle=' + ((document.querySelector('.sd-timer-card .sd-iconbtn-lg') || {}).title || 'none'),
+      'pieRect=' + rectOf('.sd-pie-wrap'),
+    'heatRect=' + rectOf('.sd-heat'),
+    'forecastRect=' + rectOf('.sd-forecast'),
+      'mini=' + document.querySelectorAll('.sd-mini').length,
+      'plandock=' + document.querySelectorAll('.sd-plandock').length,
+      'plandockFab=' + document.querySelectorAll('.sd-plandock-fab').length,
+      'plandockPanel=' + document.querySelectorAll('.sd-plandock-panel').length,
+      'planRows=' + document.querySelectorAll('.sd-plan-row').length,
+      'planNow=' + document.querySelectorAll('.sd-plan-row.now').length,
+      'planSection=' + document.querySelectorAll('.sd-plan-card').length,
+      'calls=' + window.__calls.join(','),
+      'errText=' + (err ? String(err.textContent).slice(0, 160) : 'none'),
+      'bootErr=' + (window.__bootError || 'none'),
+    ].join(' | ')
+    window.__mounted = true
+  }, 260)
+}, 300)
+`
+
+const shell = `
+html,body{margin:0;height:100%;font-family:var(--dsw-font-family);}
+.frame{display:grid;grid-template-columns:236px minmax(0,1fr);height:100vh;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}
+.side{background:var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1));border-right:1px solid var(--dsw-alias-border-l1);padding:14px 10px;display:flex;flex-direction:column;gap:10px;overflow:hidden}
+.centerCol{display:flex;flex-direction:column;overflow:hidden;position:relative;background:var(--dsw-alias-bg-layer-1)}
+.overlayLayer{position:absolute;inset:0;z-index:20;pointer-events:none}
+.overlayLayer>*{pointer-events:auto}
+.navbtn{font-size:13px;color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
+#main2,#settings{display:none}
+`
+
+// ?plan=1 时伪造一份「开了每日计划表」的快照:围绕当前钟点构造几段,
+// 让「现在」永远落在中间那段里(跨午夜的边角由 :59 收尾兜住)。
+// 悬浮件开不开、当前时段有没有,从诊断行的 plandock*/planRows/planNow 读。
+const planPatch = String.raw`
+if (new URLSearchParams(location.search).get('plan') === '1') {
+  const d = new Date()
+  const hh = (n) => String(n).padStart(2, '0')
+  const nowH = d.getHours()
+  const items = []
+  if (nowH > 0) items.push({ id: 'p0', start: hh(nowH - 1) + ':00', end: hh(nowH) + ':00', label: '上一段(已过)', subject: '政治' })
+  items.push({ id: 'p1', start: hh(nowH) + ':00', end: hh(nowH) + ':59', label: '现在的时段', subject: '631 公共管理' })
+  if (nowH < 22) items.push({ id: 'p2', start: hh(nowH + 1) + ':00', end: hh(nowH + 2) + ':00', label: '下一场', subject: '英语' })
+  window.__SNAPSHOT__.state.dailyPlan = { enabled: true, float: true, items }
+  window.__SNAPSHOT__.dayPlan = { current: items[1], next: items[2] || null, items }
+}
+`
+
+const html = `
+study-desk harness
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+`
+
+writeFileSync(join(OUT, 'panel-new.html'), html.replace('__CLIENT__', 'new'))
+writeFileSync(join(OUT, 'panel-old.html'), html.replace('__CLIENT__', 'old'))
+
+// ---------------------------------------------------------------- 5. 色板对照页
+// 她那个月饼只有 5 段,看不出 8 色好不好分;这一页把 8 个 tone 都铺出来,
+// 深浅底各一行,并写上每个色值出自哪个 token(方便核对「没有写死颜色」这条)。
+const { PIE_TONES } = (await import('./client-loader.mjs')).loadClient()
+const toneLabel = (t) => t
+  .replace(/var\(--dsw-static-([\w-]+)\)/g, '$1')
+  .replace(/color-mix\(in srgb,var\(--dsw-static-([\w-]+)\) (\d+)%,var\(--dsw-static-([\w-]+)\)\)/g, '$1 + $3 @$2%')
+const palettePage = (dark) => `
+
+
+

饼图分类色板 · 8 色 · 全部取自宿主 --dsw-static-*

+
${PIE_TONES.map((t, i) => `
${i + 1}
`).join('')}
+
${PIE_TONES.map((t, i) => `${i + 1} ${toneLabel(t)}`).join('
')}
+` +writeFileSync(join(OUT, 'palette-light.html'), palettePage(false)) +writeFileSync(join(OUT, 'palette-dark.html'), palettePage(true)) +console.log('[done] .shots/panel-{new,old}.html .shots/palette-{light,dark}.html (' + PIE_TONES.length + ' tones)') +// client-loader 载入 client.js 会把盯倒计时的 setInterval 挂起来(apply-probe 同款坑),显式退出 +process.exit(0)