mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #740 from deepseek-harness/feat/todo-tool-ui
feat: optimize ui of todo tool
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md
|
||||
2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b
|
||||
2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38
|
||||
2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d
|
||||
2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be
|
||||
|
||||
@@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it
|
||||
|
||||
### TodoPanel: the durable list as a persistent strip
|
||||
|
||||
The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
|
||||
The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper.
|
||||
|
||||
### TodoRow: the per-call row through the keyed toolview slot
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
### TodoPanel:长驻列表作为一条常驻横条
|
||||
|
||||
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
|
||||
面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。
|
||||
|
||||
### TodoRow:经 keyed toolview slot 的逐调用行
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
|
||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
||||
|
||||
// Header spans are adjacent inline nodes; textContent joins "To-dos" +
|
||||
// "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
|
||||
expect({
|
||||
row: visibleText(row),
|
||||
rowState: row.getAttribute('data-state'),
|
||||
@@ -143,19 +145,19 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
|
||||
})),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"panelHeader": "Plan1/3",
|
||||
"panelHeader": "To-dos1/3 tasks · 1 in progress",
|
||||
"panelItems": [
|
||||
{
|
||||
"status": "completed",
|
||||
"text": "✓梳理需求",
|
||||
"text": "梳理需求",
|
||||
},
|
||||
{
|
||||
"status": "in_progress",
|
||||
"text": "●实现 fixture 样本",
|
||||
"text": "实现 fixture 样本",
|
||||
},
|
||||
{
|
||||
"status": "pending",
|
||||
"text": "○浏览器验收",
|
||||
"text": "浏览器验收",
|
||||
},
|
||||
],
|
||||
"row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
|
||||
@@ -164,7 +166,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
|
||||
`)
|
||||
})
|
||||
|
||||
it('collapses the plan strip to the in-progress hint and restores it', async () => {
|
||||
it('collapses the plan strip to the count summary and restores it', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -179,7 +181,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async ()
|
||||
listGone: panel.querySelector('ul') === null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"collapsedHeader": "Plan1/3实现 fixture 样本",
|
||||
"collapsedHeader": "To-dos1/3 tasks · 1 in progress",
|
||||
"listGone": true,
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: b242812411d513931ecd2767622f9e23fb0aaa34
|
||||
README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12
|
||||
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
|
||||
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
|
||||
|
||||
@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
|
||||
|
||||
@@ -33,3 +33,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
|
||||
|
||||
@@ -33,3 +33,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
padding: 8px 32px 12px;
|
||||
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
|
||||
margin + 6px here); error/status strips still carry their own margin. */
|
||||
padding: 6px 32px 12px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
/* Plan strip pinned above the composer: bordered card on the composer card's
|
||||
axis (776px column inside 32px side padding). Colors resolve through
|
||||
--dsw-alias-* tokens only; the active row rides the business blue, done
|
||||
rows fade to tertiary. */
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
|
||||
|
||||
.root {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 8px auto 0;
|
||||
width: calc(100% - 64px);
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 88px);
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 510;
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.progress {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.activeHint {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -58,13 +56,15 @@
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0 12px 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
@@ -72,40 +72,45 @@
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.item[data-status='completed'] .content {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.item[data-status='completed'] .glyph {
|
||||
.glyphCompleted {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.item[data-status='in_progress'] .content {
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.item[data-status='in_progress'] .glyph {
|
||||
.glyphProgress {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
animation: todo-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes todo-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Figma strip is single-line; long items ellipsize with no inline expand. */
|
||||
.content {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// no data of its own, hidden while the list is empty. Mounted through the
|
||||
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
|
||||
// the selecting, so the panel takes the plain list and stays framework-free.
|
||||
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -16,45 +17,97 @@ export interface TodoPanelProps {
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */
|
||||
const STATUS_GLYPHS: Record<TodoItem['status'], string> = {
|
||||
completed: '✓', in_progress: '●', pending: '○',
|
||||
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unreachable todo status: ${String(value)}`)
|
||||
}
|
||||
|
||||
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
|
||||
function CompletedGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path
|
||||
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** In-progress: business-blue ring fading out; CSS spins the svg. */
|
||||
function ProgressGlyph() {
|
||||
const gradientId = useId()
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
|
||||
function PendingGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||||
switch (status) {
|
||||
case 'completed': return <CompletedGlyph />
|
||||
case 'in_progress': return <ProgressGlyph />
|
||||
case 'pending': return <PendingGlyph />
|
||||
/* v8 ignore next -- closed TodoItem status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||||
function progressLabel(todos: readonly TodoItem[]): string {
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.filter(t => t.status === 'in_progress').length
|
||||
return `${done}/${todos.length} tasks · ${active} in progress`
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
|
||||
return (
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>Plan</span>
|
||||
<span className={css.progress}>{done}/{todos.length}</span>
|
||||
{collapsed && active !== undefined && (
|
||||
<span className={css.activeHint}>{active.content}</span>
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
|
||||
<div className={css.body}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>To-dos</span>
|
||||
<span className={css.progress}>{progressLabel(todos)}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className={css.list}>
|
||||
{todos.map(item => (
|
||||
<li key={item.content} className={css.item} data-status={item.status}>
|
||||
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
|
||||
<span className={css.content}>{item.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className={css.list}>
|
||||
{todos.map(item => (
|
||||
<li key={item.content} className={css.item} data-status={item.status}>
|
||||
<span className={css.glyph} aria-hidden>{STATUS_GLYPHS[item.status]}</span>
|
||||
<span className={css.content}>{item.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse with active hint), its TodoDock adapter (selects the plan off
|
||||
* the session snapshot and follows changes), and the todo_write toolview row
|
||||
* (progress summary from args, generic fallback on malformed JSON, error badge,
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, error badge,
|
||||
* keyboard activation).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
@@ -31,32 +31,36 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status, and strikes done items', () => {
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('1/3')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
expect(screen.getByText('写组件')).toBeTruthy()
|
||||
// Each status row carries an SVG glyph (not a text bullet).
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list and surfaces the active item in the header; expand restores', () => {
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header carries the in-progress content as the one-line hint.
|
||||
expect(screen.getByText('写组件')).toBeTruthy()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header omits the hint when nothing is in progress', () => {
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1')).toBeTruthy()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +75,7 @@ describe('TodoDock', () => {
|
||||
render(<TodoDock {...dockProps(store)} />)
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ todos: LIST }) })
|
||||
expect(screen.getByText('1/3')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
// A rollback to the empty list retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ todos: [] }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 6aeda0e04bf690f5cc8a6d52eea95b1b2782a143
|
||||
README.zh.md: 9fbc2ed8392ae3ceba453517d9af5ef41f039d2d
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: 26e909b96412985792eeae72d51a2ab2a315c943
|
||||
README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`(依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
|
||||
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
/**
|
||||
* Global theme DOM applier: projects the resolved ThemeSnapshot onto
|
||||
* document.body — the `data-ds-dark-theme` palette switch plus the active
|
||||
* theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
|
||||
* React involvement; the presenter only ever retracts what it wrote itself,
|
||||
* so foreign body attributes and inline styles survive apply/dispose.
|
||||
* Global theme DOM applier: projects the resolved ThemeSnapshot onto the
|
||||
* document — `html { color-scheme }` for native UA chrome (scrollbars, form
|
||||
* controls), `body[data-ds-dark-theme]` for the token palette, and the active
|
||||
* theme's alias-token overrides as inline CSS variables on body. Pure DOM
|
||||
* writes, no React involvement; the presenter only ever retracts what it wrote
|
||||
* itself, so foreign attributes and inline styles survive apply/dispose.
|
||||
*/
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
/** Body attribute selecting the dark base palette in the token stylesheets. */
|
||||
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
/** Applies theme snapshots to document.body; one instance per plugin fiber. */
|
||||
/** Applies theme snapshots to the document; one instance per plugin fiber. */
|
||||
export class ThemePresenter {
|
||||
/** Token names this presenter wrote in the last apply (its retraction set). */
|
||||
private appliedTokens: string[] = []
|
||||
|
||||
/**
|
||||
* Project a snapshot onto the body: switch the palette attribute from
|
||||
* `active.colorScheme` (never the id — `system` is resolved upstream) and
|
||||
* replace the previously applied token variables with `active.tokens`.
|
||||
* Project a snapshot onto the document: set root `color-scheme` and the body
|
||||
* palette attribute from `active.colorScheme` (never the id — `system` is
|
||||
* resolved upstream), then replace the previously applied token variables
|
||||
* with `active.tokens`.
|
||||
* @param snapshot - resolved theme snapshot from ctx.theme.
|
||||
*/
|
||||
apply(snapshot: ThemeSnapshot): void {
|
||||
const scheme = snapshot.active.colorScheme
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
const body = document.body
|
||||
if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
else body.removeAttribute(DARK_ATTRIBUTE)
|
||||
for (const name of this.appliedTokens) body.style.removeProperty(name)
|
||||
this.appliedTokens = []
|
||||
@@ -33,8 +37,9 @@ export class ThemePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
|
||||
/** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
|
||||
dispose(): void {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
const body = document.body
|
||||
body.removeAttribute(DARK_ATTRIBUTE)
|
||||
for (const name of this.appliedTokens) body.style.removeProperty(name)
|
||||
|
||||
@@ -63,15 +63,19 @@ describe('ui-layout client apply', () => {
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
// Initial getter application: jsdom has no matchMedia, system resolves light.
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
const theme = ctx.get('theme') as ThemeService
|
||||
theme.setTheme('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
// Listener is off: further theme changes no longer reach the body.
|
||||
// Listener is off: further theme changes no longer reach the document.
|
||||
theme.setTheme('light')
|
||||
theme.setTheme('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// ThemePresenter behavior account: the palette attribute follows
|
||||
// active.colorScheme only, token variables replace the previous apply's set,
|
||||
// and dispose retracts everything the presenter wrote.
|
||||
// ThemePresenter behavior account: root color-scheme and the palette attribute
|
||||
// follow active.colorScheme only, token variables replace the previous apply's
|
||||
// set, and dispose retracts everything the presenter wrote.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
@@ -14,22 +14,26 @@ function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
document.body.removeAttribute('style')
|
||||
})
|
||||
|
||||
describe('ThemePresenter', () => {
|
||||
it('light scheme leaves the dark attribute absent', () => {
|
||||
it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('dark scheme sets the attribute; switching back to light removes it', () => {
|
||||
it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -44,11 +48,12 @@ describe('ThemePresenter', () => {
|
||||
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
|
||||
})
|
||||
|
||||
it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
|
||||
it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => {
|
||||
document.body.style.setProperty('--foreign', 'kept')
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
|
||||
presenter.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
|
||||
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216
|
||||
README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5
|
||||
README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
Reference in New Issue
Block a user