From 6d6f8f044c043bf812cfc23ef3e79d2f538645a9 Mon Sep 17 00:00:00 2001 From: ihsiang Date: Fri, 21 Aug 2026 13:17:02 +0800 Subject: [PATCH] feat(ui): adaptive content width and font-size control Add conversation adaptive content width and a Settings font-size control, with theme presenter, font-size row, snapshots, tests, and agent notes. --- ...versation-adaptive-content-width.i18n.yaml | 6 + ...-18-conversation-adaptive-content-width.md | 35 ++++ ...-conversation-adaptive-content-width.zh.md | 35 ++++ ...08-18-settings-font-size-control.i18n.yaml | 6 + .../2026-08-18-settings-font-size-control.md | 31 +++ ...026-08-18-settings-font-size-control.zh.md | 31 +++ .../settings-chrome/dialog-en.expected.md | 7 +- .../settings-chrome/dialog.expected.md | 7 +- apps/web/tests/settings-chrome.e2e.ts | 45 ++++ .../client/chat/AssistantMarkdown.module.css | 12 +- .../src/client/chat/MessageItem.module.css | 18 +- .../src/client/chat/ReasoningRow.module.css | 8 +- .../ui-conversation/src/client/locales.ts | 2 + .../skeleton/ConversationRoot.module.css | 97 ++++++++- .../src/client/skeleton/ConversationRoot.tsx | 197 +++++++++++++++++- .../src/client/skeleton/InputBar.module.css | 8 +- .../tests/skeleton.client.spec.tsx | 90 +++++++- .../client/GoalCommandInputView.module.css | 10 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/theme-presenter.ts | 24 ++- .../tests/theme-presenter.client.spec.ts | 15 +- .../src/markdown/CodeBlock.module.css | 6 +- .../src/markdown/MarkdownText.module.css | 4 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 4 +- packages/client/ui-theme/README.zh.md | 4 +- packages/client/ui-theme/src/boot-theme.ts | 17 +- .../src/client/FontSizeRow.module.css | 106 ++++++++++ .../ui-theme/src/client/FontSizeRow.tsx | 69 ++++++ packages/client/ui-theme/src/client/index.ts | 73 ++++++- .../client/ui-theme/src/client/locales.ts | 10 +- .../ui-theme/src/client/settings-store.ts | 38 +++- packages/client/ui-theme/src/index.ts | 19 +- .../src/styles/gradient-shadow-text.css | 110 +++++----- .../client/ui-theme/src/theme-settings.ts | 15 ++ .../ui-theme/tests/apply.client.spec.ts | 72 +++++-- .../ui-theme/tests/boot-theme.client.spec.ts | 13 +- .../tests/font-size-row.client.spec.tsx | 90 ++++++++ .../client/ui-theme/tests/host.client.spec.ts | 12 +- .../tests/settings-store.client.spec.ts | 21 +- .../ui-theme/tests/theme.client.spec.ts | 48 ++++- .../src/client/api-catalog.ts | 7 +- .../src/client/slot-catalog.ts | 1 + scripts/gen-cordis-inspect-catalog.ts | 2 +- 46 files changed, 1281 insertions(+), 156 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md create mode 100644 packages/client/ui-theme/src/client/FontSizeRow.module.css create mode 100644 packages/client/ui-theme/src/client/FontSizeRow.tsx create mode 100644 packages/client/ui-theme/tests/font-size-row.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml new file mode 100644 index 0000000000..30d960c3d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md +2026-08-18-conversation-adaptive-content-width.md: ee59990e27b7dc7486edca30dbc085c94bf0224e +2026-08-18-conversation-adaptive-content-width.zh.md: e68f13262f61d05865c6acb0cc8e0a54f362e205 diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md new file mode 100644 index 0000000000..ee59990e27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md @@ -0,0 +1,35 @@ +# Agent Note: Adaptive and drag-resizable conversation content width + +Status: implemented + +English | [中文](2026-08-18-conversation-adaptive-content-width.zh.md) + +## Problem + +The conversation column's shared width axis (`--dsh-chat-content-width`) was the fixed figma constant 748px. On wide monitors (a 4000px display leaves a ~3500px column) the transcript occupied under a quarter of the column with dead margins on both sides. Every derived surface — the input card (W + 32px), dock cards, takeover panels, StatsLine, the back-to-bottom padding formula — rides this one variable, so any change had to keep the whole column's alignment relations intact. Alongside the adaptive default, users asked for direct control: hover the transcript's side margins to get a col-resize cursor and drag either edge, with both edges moving symmetrically. + +## Decision + +**The axis becomes a user override over an adaptive clamp.** `ConversationRoot.module.css` declares `--dsh-chat-content-width: var(--dsh-chat-user-width, clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px))`. The floor is 680px — one step under the figma 748px, after full-width reading felt wide on every screen — wider columns take 64% of the column, and 920px caps line length for readability (~113 characters at the base font). A dragged preference replaces the adaptive term wholesale. + +**The column width is published by a ResizeObserver, not container queries.** The component publishes the root's `offsetWidth` as `--dsh-conversation-column-width` in px (the same callback-ref pattern as the existing composer seat height observer). `container-type: inline-size` was rejected: the conversation subtree contains portal-free `position: fixed` descendants (Tooltip, Menu, JsonTree copy anchors) whose viewport anchoring a size container would capture — the same class of trap the `.composerHero` comment records for transforms. A bare `%` in the variable was rejected because custom-property percentages resolve per consumer against different containing blocks, breaking the input-card = W + 32px invariant; `vw` was rejected because the column is not the viewport (sidebar fold changes the column only). + +**Drag handles are 40px strips beside the transcript, symmetric by construction.** Each strip's inner edge sits 24px outside the content column and extends 40px outward, with the outer edge clamped to keep a 24px safe zone from the column edges (24 + 40 + 24 = the 88px-per-side budget below); when the margin cannot fit inset + strip + safe zone the computed width goes negative and the strip resolves to zero. Both handles write the one centered width — outward travel widens by 2× the pointer distance — reusing AppFrame's DragHandle capture model (pointer capture + rAF throttle + drag-start snapshot); only a gesture with actual pointer travel commits to storage, so a bare press-and-release on a window-clamped width cannot overwrite the wider stored preference. The hover indicator is a 3px glow riding the pointer's Y (published as `--dsh-width-handle-pointer-y` on pointermove): a 24px solid core fading over 40px each side, in the scrollbar hover tint because border-token alphas disappear against the base fill. Handles render only in the active phase; views that elect a composer overlay (trajectory) hide them, and the header lifts above them (z-index 9) to stay clickable. + +**The preference persists in `localStorage` (`dsh.conversation.contentWidth`) and clamps without rewriting.** The displayed width re-clamps to `[640px, column − 176px]` when the column shrinks (88px per side keeps the handles fully placeable — a wider drag would push its own handles off the column), but the stored preference survives — widening the window restores it, the same rule AppFrame's sidebar drag follows. Double-clicking a handle clears the preference and returns to the adaptive clamp; the handle's `title` documents drag and double-click. + +**The user bubble cap follows the axis.** `min(525px, 82%)` becomes `min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)` (0.702 = 525/748, the figma bubble share of the figma column) in both `ui-conversation` MessageItem and the symmetric `ui-goal` command bubble, so bubbles scale with the column. The 748px fallback covers mounts outside the conversation column. + +## Alternatives considered + +**Raise the constant (748 → ~850).** Rejected: every mid-width window's line length grows too, hurting readability where most users live. + +**Wide-content bleed (code blocks and tool cards break out of the prose column).** Best reading ergonomics but touches MarkdownText and every tool card's layout; deferred as a possible second phase. + +**A settings-backed "wide mode" toggle.** Adds a persistent settings surface for what drag + double-click already covers; not needed. + +**A 12px handle strip beside the input card.** Shipped first and unusable in practice: on a wide screen the strip was a sliver in a thousand-plus pixels of margin, and the sticky input card overlapped it. Replaced by the 40px strip anchored to the glow line's position. + +## Consequences + +Ordinary windows read slightly narrower than the figma baseline (680px floor). Wide columns widen the transcript to at most 920px, and a drag can take it anywhere in `[640px, column − 176px]`, both without touching any derived surface: input card, dock cards, takeover panels, and the back-to-bottom formula follow the axis they already consumed. A known ~4px centering offset between the handle (column-centered) and the content box (centered after scrollbar-gutter reservation) stays well inside the 40px strip. The 680px / 64% / 920px numbers are one declaration in `ConversationRoot.module.css` mirrored by `resolveContentWidth` in the component; retuning them touches nothing else. diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md new file mode 100644 index 0000000000..e68f13262f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md @@ -0,0 +1,35 @@ +# Agent Note:会话正文宽度自适应与拖拽调宽 + +Status: implemented + +[English](2026-08-18-conversation-adaptive-content-width.md) | 中文 + +## 问题 + +会话列的共享宽度轴(`--dsh-chat-content-width`)是 figma 定值 748px。在宽显示器上(4000px 屏幕的会话列约 3500px)正文只占列宽不到四分之一,两侧是大片空白边距。所有派生表面——输入卡(W + 32px)、dock 卡片、takeover 面板、StatsLine、回底按钮的 padding 公式——都由这一个变量推导,任何改动都必须保持整列的对齐关系。在自适应默认值之外,用户还要求直接控制:hover 正文两侧边距出现 col-resize 光标,拖任一侧、两侧对称联动。 + +## 决策 + +**宽度轴变为"用户覆盖 + 自适应 clamp"。** `ConversationRoot.module.css` 声明 `--dsh-chat-content-width: var(--dsh-chat-user-width, clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px))`。下限 680px——比 figma 的 748px 低一档,因为满宽阅读在各种屏幕上都显宽——更宽的列取列宽的 64%,920px 封顶保证行长可读性(基准字号下约 113 字符)。拖拽偏好存在时整体替换自适应项。 + +**列宽由 ResizeObserver 发布,不用容器查询。** 组件把根节点的 `offsetWidth` 以 px 发布为 `--dsh-conversation-column-width`(与既有 composer seat 高度 observer 相同的 callback-ref 模式)。拒绝 `container-type: inline-size`:会话子树内有不经 portal 的 `position: fixed` 后代(Tooltip、Menu、JsonTree 复制锚点),尺寸容器会捕获它们的视口定位——与 `.composerHero` 注释记录的 transform 陷阱同类。拒绝变量里的裸 `%`:自定义属性百分比在各消费点按不同包含块解析,破坏输入卡 = W + 32px 不变量;拒绝 `vw`:列不等于视口(侧栏折叠只改列宽)。 + +**拖拽手柄是正文两侧 40px 宽的条,对称是构造性的。** 每条内边缘位于内容列外 24px、向外延伸 40px,外边缘被钳制在距列缘至少 24px 的安全区(24 + 40 + 24 = 下文每侧 88px 的预算);边距装不下"内偏移 + 热区 + 安全区"时计算宽度为负、热区解析为零。两个手柄写同一个居中宽度——向外拖按指针位移 2 倍变宽——复用 AppFrame DragHandle 的捕获模型(指针捕获 + rAF 节流 + 拖拽起点快照);只有指针确实产生位移的手势才提交存储,因此在被窗口钳制的宽度上按下即松开不会用钳制后的显示值覆盖更宽的已存偏好。hover 提示是跟随指针 Y 的 3px 光带(pointermove 发布 `--dsh-width-handle-pointer-y`):24px 实色核心、两侧各 40px 渐变,用滚动条 hover 色——border token 的透明度在底色上几乎不可见。手柄只在 active 阶段渲染;选举了 composer overlay 的视图(trajectory)隐藏手柄,header 提升到手柄之上(z-index 9)保持可点。 + +**偏好持久化在 `localStorage`(`dsh.conversation.contentWidth`),钳制不改写。** 列收窄时显示宽度重新钳制到 `[640px, 列宽 − 176px]`(每侧预留 88px 保证手柄永远放得下),但存储的偏好保留——拉宽窗口自动恢复,与 AppFrame 侧栏拖拽同规则。双击手柄清除偏好、回到自适应 clamp;手柄的 `title` 说明拖拽与双击用法。 + +**用户气泡上限跟随宽度轴。** `min(525px, 82%)` 改为 `min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)`(0.702 = 525/748,即 figma 气泡占 figma 列宽的比例),`ui-conversation` MessageItem 与对称的 `ui-goal` 命令气泡同步,气泡随列缩放。748px 缺省值覆盖会话列之外的挂载。 + +## 备选方案 + +**调大常量(748 → 约 850)。** 拒绝:所有中等宽度窗口的行长一起变长,伤及多数用户的可读性。 + +**宽内容出血(代码块、工具卡片突破散文列)。** 阅读工效最佳但涉及 MarkdownText 和所有工具卡片布局;作为可能的二期推迟。 + +**settings 支持的"宽屏模式"开关。** 为拖拽 + 双击已覆盖的能力增加持久设置面;不需要。 + +**输入卡旁 12px 手柄条。** 首版实现,实践中不可用:宽屏上千余像素的边距里只有一条细缝,且 sticky 输入卡遮挡它。改为锚定在光带位置的 40px 条。 + +## 影响 + +普通窗口的阅读宽度比 figma 基线略窄(下限 680px)。宽列正文最多放宽到 920px,拖拽可取 `[640px, 列宽 − 176px]` 内任意值,两者都不触碰任何派生表面:输入卡、dock 卡片、takeover 面板和回底公式沿用它们本就消费的宽度轴。手柄(按列居中)与内容盒(按滚动条预留后居中)之间约 4px 的已知偏差完全落在 40px 热区内。680px / 64% / 920px 三个数值在 `ConversationRoot.module.css` 一处声明、由组件内 `resolveContentWidth` 镜像;重调它们不影响其他代码。 diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml new file mode 100644 index 0000000000..19e3ea91e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md +2026-08-18-settings-font-size-control.md: 56c4d2d1ba72af5096c7d44266d27143362daf51 +2026-08-18-settings-font-size-control.zh.md: a195c855de1f007e23c460e9518f6d48dc2e41c0 diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md new file mode 100644 index 0000000000..56c4d2d1ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md @@ -0,0 +1,31 @@ +# Agent Note: Settings-backed conversation content font size + +Status: implemented + +English | [中文](2026-08-18-settings-font-size-control.zh.md) + +## Problem + +The conversation's body text size was fixed (14px after the 0.875 markdown-ladder rescale). Users asked for a Settings control: a "字号大小" row under General → Appearance with a stepper, range 12–17, default 14, that resizes the transcript body text and the composer input text together. + +## Decision + +**The theme plugin owns the setting.** `ThemeSettingsSchema` gains `fontSize` (`z.number().step(1).min(12).max(17).default(14)`) beside `preference` in the existing `ui-theme` namespace — one durable section, one settings scope, one adoption path. `ThemeRuntime` carries `fontSize` in `ThemeSnapshot`, exposes `setFontSize(px)` (integer-and-range validated, throws a teaching error), and republishes on `theme/change`. The same plugin registers the FontSizeRow into `settings.general.item` at order 11, directly under the Appearance cubes (order 10). + +**Presentation rides the existing snapshot pipeline.** The service never touches the DOM: ui-layout's `ThemePresenter` writes `--dsh-content-font-size` on `body` from each snapshot (and retracts it on dispose), and the Host boot script embeds the durable value in the index response so first paint uses the chosen size — the same pre-plugin path the dark-mode attribute takes, avoiding a font-size flash. + +**One CSS delta variable moves the ladder.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Table, small, and code variants stay fixed: they are dense secondary text whose 12–13px defaults would collide with the base size when stepped down. Consumers outside the token ladder — the assistant narration root, the user bubble, and the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design) — read `var(--dsh-content-font-size, 14px)` and `calc( + var(--dsh-content-font-delta, 0px))` directly. The 14px fallbacks keep every surface pixel-identical when the variable is absent (tests, storybook-like mounts, remote compositions before adoption). + +**The stepper is a pill, not a menu.** The row reuses the selector-pill geometry (h36 r18 module fill) with the value centered in the pill, the up/down arrow column revealed on hover/focus-within and absolutely anchored to the pill's right edge (so revealing never moves the value), and a `px` unit label after the pill. Arrows disable at the bounds; the display follows the store mirror, never the click echo — the same store/face pattern as the Appearance row. + +## Alternatives considered + +**A separate settings namespace or plugin.** Rejected: the font size is an appearance preference with the same persistence, adoption, and remote-browser semantics as the theme preference; a second namespace duplicates the scope machinery for one integer. + +**Scaling via a multiplier (`em`/percentage) instead of a px delta.** Rejected: multiplying spreads the 12–17px range disproportionately across the ladder (21px h1 would swing ~18–25.5px) and produces fractional line heights; the fixed px shift keeps every step integer and the hierarchy's px gaps intact. + +**Scaling every font token (tables, code, small).** Rejected: those variants are secondary/dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. + +## Consequences + +The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at delta 0 every axis consumer is pixel-identical to that rescaled baseline, and surfaces without the variable fall back to the same 14px. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`. diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md new file mode 100644 index 0000000000..a195c855de --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Settings 支撑的会话正文字号 + +状态:已实现 + +[English](2026-08-18-settings-font-size-control.md) | 中文 + +## 问题 + +会话正文字号是固定的(markdown 阶梯按 0.875 重缩放后为 14px)。用户需要一个设置项:在 General → Appearance 下加一行「字号大小」,用步进器交互,范围 12–17,默认 14,同时调整转录正文与 composer 输入框的文字大小。 + +## 决策 + +**主题插件拥有该设置。**`ThemeSettingsSchema` 在既有 `ui-theme` namespace 的 `preference` 旁新增 `fontSize`(`z.number().step(1).min(12).max(17).default(14)`)——一个持久化 section、一个 settings scope、一条采纳路径。`ThemeRuntime` 在 `ThemeSnapshot` 中携带 `fontSize`,暴露 `setFontSize(px)`(校验整数与范围,越界抛教学式错误),并通过 `theme/change` 重新发布。同一插件把 FontSizeRow 注册进 `settings.general.item`,order 11,紧挨外观方块(order 10)之下。 + +**呈现走既有快照管线。**服务绝不接触 DOM:ui-layout 的 `ThemePresenter` 依据每份快照在 `body` 上写 `--dsh-content-font-size`(dispose 时收回),Host 引导脚本把持久化值嵌入 index 响应,让首帧就使用所选字号——与暗色属性同一条插件前路径,避免字号闪变。 + +**一个 CSS 增量变量平移阶梯。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。表格、small 与 code 变体保持固定:它们是密集次级文本,其 12–13px 默认值在字号下调时会与正文相撞。token 阶梯之外的消费方——助手正文根节点、用户气泡、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量)——直接读取 `var(--dsh-content-font-size, 14px)` 与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`。14px 回退让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。 + +**步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。 + +## 已考虑的替代方案 + +**独立 settings namespace 或独立插件。**否决:字号与主题偏好具有相同的持久化、采纳与远程浏览器语义,属外观偏好;为一个整数复制一套 scope 机制不值得。 + +**用倍率(`em`/百分比)而非像素增量缩放。**否决:乘法会让 12–17px 的范围在阶梯上不成比例地放大(21px 的 h1 会摆动到约 18–25.5px),并产生小数行高;固定像素平移让每一档都是整数,层级间的像素差保持不变。 + +**缩放全部字体 token(表格、code、small)。**否决:这些变体按设计就是次级/密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。 + +## 后果 + +0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;增量为 0 时所有轴消费方与该重缩放基线逐像素一致,无变量的表面回退到同样的 14px。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。 diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 605e2fe328..ba739fe337 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -39,7 +39,12 @@ - button "System" [pressed]: - img - text: System - - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - text: Font size 14 + - button "Increase font size": + - img + - button "Decrease font size": + - img + - text: px Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior - button "Queue": - text: Queue - img diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 89cff5df3f..cf6695cfa9 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -39,7 +39,12 @@ - button "跟随系统" [pressed]: - img - text: 跟随系统 - - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 + - text: 字号大小 14 + - button "增大字号": + - img + - button "减小字号": + - img + - text: px 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送": - text: 排队发送 - img diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 27521b8a57..b36508aa5e 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -346,6 +346,51 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('steps the content font size, applies it to body, and persists across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size')) + const readFontSize = async (target: Page = page): Promise => await target.evaluate( + () => document.body.style.getPropertyValue('--dsh-content-font-size'), + ) + expect(await readFontSize()).toBe('14px') + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + // The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16. + await dialog.getByText('14', { exact: true }).hover() + const increase = dialog.getByRole('button', { name: '增大字号' }) + await increase.click() + await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + await increase.click() + await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) + await page.keyboard.press('Escape') + + // Reload: the boot script embeds the durable size and ThemeRuntime seeds + // its initial snapshot from the boot-written body variable, so activation + // never flashes the default while the settings read is in flight. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + + // Restore the default for the specs that follow (and the dialog golden). + await page.getByRole('button', { name: '设置', exact: true }).click() + const restored = page.getByRole('dialog', { name: '设置' }) + await restored.waitFor({ timeout: 10_000 }) + await restored.getByText('16', { exact: true }).hover() + const decrease = restored.getByRole('button', { name: '减小字号' }) + await decrease.click() + await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + await decrease.click() + await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px') + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index fc82c77cb9..832494eee6 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -1,8 +1,16 @@ +/* Assistant flow body: full-width narration (figma 16/28 scaled 0.875 → 14/24 + with the markdown token ladder), block gap 16. The Settings font-size + preference rides the body-published axis: size follows + --dsh-content-font-size and the line height keeps the 10px leading via the + shared px delta. IconActions sit below the body with an explicit 16px top + margin (figma 43:32997) — separate from the body's internal gap so the + footer spacing stays fixed when the body is a single block. */ + .root { display: flex; flex-direction: column; - font-size: 16px; - line-height: 28px; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 54e5f6f98d..765a8da56c 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -14,17 +14,25 @@ align-items: flex-end; gap: 8px; min-width: 0; - max-width: min(525px, 82%); + /* The figma 525px cap held as a share of the width axis (525/748 ≈ 0.702): + resolves to the original 525px on the default column and widens with a + dragged or adaptive column. The 748px fallback covers mounts outside the + conversation column, where the axis variable is absent. */ + max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%); } .bubble { - /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ + /* Figma's 525px cap held as a share of the width axis (525/748 ≈ 0.702) so + the bubble widens with a user-dragged or adaptive column; percentage + keeps narrow windows sane. */ max-width: 100%; background: var(--dsw-specific-bubble); border-radius: 22px; - /* 44px single-line bubble: 24 line + 10 vertical padding each side. */ + /* 42px single-line bubble at the default size: 22 line + 10 vertical + padding each side; the Settings font-size preference moves size and line + height together through the body-published axis. */ padding: 10px 16px; - font-size: 16px; - line-height: 24px; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 6b2f6c0cd3..e888204ac1 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -55,8 +55,8 @@ overflow: hidden; flex: 1 1 auto; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 12px; + line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } @@ -68,8 +68,8 @@ .thinkBody { padding: 4px 0 4px 22px; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 12px; + line-height: 20px; white-space: pre-wrap; word-break: break-word; } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3ab8410590..9b635eb845 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -19,6 +19,7 @@ export const zh = { 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', + 'width.handle.title': '拖动调整正文宽度;双击恢复自适应', 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', @@ -164,6 +165,7 @@ export const en = { 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', + 'width.handle.title': 'Drag to resize the transcript; double-click to restore the adaptive width', 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 6357c8cb8b..0451f499c3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,4 +1,5 @@ .root { + position: relative; /* width handles are absolute against the column */ display: flex; flex-direction: column; height: 100%; @@ -13,8 +14,21 @@ chat scroller and the takeover frames pad clearance + 16px per side while the input card clears the bare clearance, so the input card stays exactly content + 32px at every width. Declared on the root because the - transcript and the composer seat are sibling subtrees. */ - --dsh-chat-content-width: 748px; + transcript and the composer seat are sibling subtrees. + + W adapts to the column, not the viewport (a collapsed sidebar widens the + column without resizing the window): the component publishes the column's + live width as --dsh-conversation-column-width (px, via ResizeObserver — + container-type would re-anchor the subtree's portal-free position:fixed + descendants) and the clamp keeps a 680px floor (a step under the figma + 748px — the full-width reading felt wide on every screen), takes 64% of + wider columns, and caps at 920px for line-length + readability. A dragged user preference (--dsh-chat-user-width, published + by the width handles) replaces the adaptive term wholesale. */ + --dsh-chat-content-width: var( + --dsh-chat-user-width, + clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px) + ); --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); --dsh-composer-side-clearance: 16px; --dsh-composer-dock-inset: 8px; @@ -22,6 +36,9 @@ .header { position: relative; + /* Above the width handles (which start at top: 0) so the breadcrumb row and + header buttons stay clickable. */ + z-index: 9; flex: none; padding: 12px 28px 0 20px; border-bottom: 1px solid transparent; @@ -184,6 +201,82 @@ min-height: 0; } +/* Width handles: 40px col-resize strips beside the transcript, absolute in + .root (NOT the scrollport: an absolute strip there would extend the + scrollable range). Inner edge sits 24px outside the content column — the + same offset the glow line paints at — and the strip extends 40px outward + from there; the outer edge is clamped to keep a 24px safe zone against the + column edges (sidebar side and scrollbar side stay drag-free). When the + margin cannot fit inset + strip + safe zone the computed width goes + negative and the strip resolves to zero — no mispositioned hit area on + narrow columns. Known half-scrollbar (~4px) centering offset vs the + gutter-reserved content box is accepted: it stays well inside the strip. */ +.widthHandle { + position: absolute; + top: 0; + bottom: 0; + z-index: 8; + width: min( + 40px, + calc((100% - var(--dsh-chat-content-width)) / 2 - 24px - 24px) + ); + cursor: col-resize; +} + +.widthHandle[data-side='left'] { + right: calc(50% + var(--dsh-chat-content-width) / 2 + 24px); +} + +.widthHandle[data-side='right'] { + left: calc(50% + var(--dsh-chat-content-width) / 2 + 24px); +} + +/* The glow: a short 3px bar riding the pointer's Y, inset 16px outward from + the strip's inner edge (40px from the content column), + not a full-height rule — a 24px solid core centered on the pointer + (published by pointermove as --dsh-width-handle-pointer-y), fading to + transparent over 40px each side (~104px total). A solid CORE, not a single + solid stop: a lone peak washes out over the fade. The scrollbar hover tint, + not a border token: this is a draggable bar and the border alphas (4-16%) + disappear against the base fill. Before the first move the fallback centers + the glow at 50% height. */ +.widthHandle::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 3px; + border-radius: 3px; + background: linear-gradient( + to bottom, + transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 52px), + var(--dsw-alias-scrollbar-hover-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 12px), + var(--dsw-alias-scrollbar-hover-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 12px), + transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 52px) + ); + opacity: 0; + pointer-events: none; +} + +.widthHandle[data-side='left']::after { + right: 16px; +} + +.widthHandle[data-side='right']::after { + left: 16px; +} + +.widthHandle:hover::after, +.widthHandle[data-dragging]::after { + opacity: 1; +} + +/* Views that elect a composer overlay (trajectory etc.) own their full-bleed + scrollers; the strips would sit over their content and steal clicks. */ +.root:has([data-conversation-composer-overlay]) .widthHandle { + display: none; +} + /* Composer context stack (Figma 9:937): standalone dock cards share one rhythm above the input card. */ .composerStack { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index a3a5942da7..e1adc6cad9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -13,6 +13,125 @@ import css from './ConversationRoot.module.css' /** Full props composed from the slot contract. */ export type ConversationRootProps = ConversationSlotProps +/** localStorage key for the dragged transcript width preference (px). */ +const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' +/** Floor for a dragged content width; matches the layout center-column minimum. */ +const CONTENT_MIN = 640 +/** Column budget the content must leave free: 88px per side keeps the width + * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a + * larger dragged width would push its own handles off the column and leave no + * way to drag back. */ +const CONTENT_EDGE_BUDGET = 176 + +/** Reads the persisted width preference; durable-storage boundary, so a + * missing or corrupt value resolves to "no preference". + * @returns the stored width in px, or null when unset or invalid. */ +function readWidthPreference(): number | null { + const raw = localStorage.getItem(WIDTH_PREF_KEY) + if (raw === null) return null + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : null +} + +/** Resolves the content width the CSS axis would show for a column width. + * @param columnWidth - the conversation column's rendered width in px. + * @param preference - the dragged preference, or null for the adaptive clamp. + * @returns the resolved content width in px (mirrors the CSS clamp). */ +function resolveContentWidth(columnWidth: number, preference: number | null): number { + const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) + if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) + return Math.max(680, Math.min(columnWidth * 0.64, 920)) +} + +/** One transcript width handle: pointer capture + rAF-throttled symmetric + * resize (both sides write the one centered width, so outward travel widens + * by 2× the pointer distance), double-click resets to the adaptive width. + * pointermove publishes the pointer's Y as a CSS variable so the glow + * indicator rides it. Mirrors ui-layout AppFrame's DragHandle capture model. */ +function WidthHandle(props: { + side: 'left' | 'right' + title: string + onStart: () => number + onDrag: (width: number) => void + onCommit: (width: number) => void + onEnd: () => void + onReset: () => void +}) { + const [dragging, setDragging] = useState(false) + const base = useRef(0) + const origin = useRef(0) + const latest = useRef(0) + const frame = useRef(null) + const callbacks = useRef(props) + callbacks.current = props + + const outwardWidth = () => { + const dx = latest.current - origin.current + const outward = callbacks.current.side === 'right' ? dx : -dx + return base.current + outward * 2 + } + const cancelFrame = () => { + if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } + } + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + origin.current = e.clientX + latest.current = e.clientX + base.current = callbacks.current.onStart() + setDragging(true) + }, []) + const onPointerMove = useCallback((e: React.PointerEvent) => { + const box = e.currentTarget.getBoundingClientRect() + e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`) + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return + latest.current = e.clientX + frame.current ??= requestAnimationFrame(() => { + frame.current = null + callbacks.current.onDrag(outwardWidth()) + }) + }, []) + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return + e.currentTarget.releasePointerCapture(e.pointerId) + cancelFrame() + latest.current = e.clientX + // Only a gesture with actual travel commits: a press-and-release on a + // window-clamped width must not overwrite the wider stored preference + // with the clamped display value. + if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) + setDragging(false) + callbacks.current.onEnd() + }, []) + // Releasing the button outside the window delivers pointercancel (or drops + // the capture silently) instead of pointerup; without this the glow's + // data-dragging state sticks on. The gesture is abandoned uncommitted — + // onEnd republishes the stored preference. releasePointerCapture inside + // onPointerUp also fires lostpointercapture, so this runs (idempotently) + // after every normal drag end too; keep both paths. + const onPointerCancel = useCallback(() => { + cancelFrame() + setDragging(false) + callbacks.current.onEnd() + }, []) + + return ( +
{ callbacks.current.onReset() }} + /> + ) +} + export function ConversationRoot({ sessionId, useSession, useSessions, useSessionPendingInteraction, useWorkspaces, useConversation, useInput, useComposerBlock, @@ -54,6 +173,68 @@ export function ConversationRoot({ seatObserver.current.observe(seat) }, []) + // Publishes the column's live width as --dsh-conversation-column-width so + // the shared width axis can adapt (see the .root CSS), and re-clamps a + // dragged preference against the shrunken column WITHOUT rewriting the + // stored preference — widening the window restores it (the AppFrame + // sidebar-drag rule). Same callback-ref pattern as the seat observer. + const rootEl = useRef(null) + const rootObserver = useRef(null) + const publishWidths = useCallback((root: HTMLDivElement): void => { + const column = root.offsetWidth + root.style.setProperty('--dsh-conversation-column-width', `${column}px`) + const preference = readWidthPreference() + if (preference === null) { + root.style.removeProperty('--dsh-chat-user-width') + } else { + root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) + } + }, []) + const rootResizeRef = useCallback((root: HTMLDivElement | null): void => { + rootObserver.current?.disconnect() + rootObserver.current = null + rootEl.current = root + if (root === null) return + rootObserver.current = new ResizeObserver(() => { publishWidths(root) }) + rootObserver.current.observe(root) + publishWidths(root) + }, [publishWidths]) + + // Drag plumbing for the two width handles: onStart snapshots the resolved + // width (grabbing a clamped column must not jump back to the raw stored + // preference), onDrag publishes only the live clamped style, onCommit + // persists the width of a gesture that actually travelled, and onEnd + // republishes from storage — an uncommitted press leaves the stored + // preference untouched. + const onHandleStart = useCallback((): number => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return 680 + return resolveContentWidth(root.offsetWidth, readWidthPreference()) + }, []) + const onHandleDrag = useCallback((width: number): void => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return + const clamped = resolveContentWidth(root.offsetWidth, width) + root.style.setProperty('--dsh-chat-user-width', `${clamped}px`) + }, []) + const onHandleCommit = useCallback((width: number): void => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return + localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`) + }, []) + const onHandleEnd = useCallback((): void => { + const root = rootEl.current + if (root !== null) publishWidths(root) + }, [publishWidths]) + const onHandleReset = useCallback((): void => { + localStorage.removeItem(WIDTH_PREF_KEY) + const root = rootEl.current + if (root !== null) publishWidths(root) + }, [publishWidths]) + const sessionWorkspace = sessionId === undefined ? undefined : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) @@ -196,12 +377,26 @@ export function ConversationRoot({ ) return ( -
+
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
{sessionId === undefined ? null : renderSlot('conversation.session', {})} {composerSeat}
+ {/* Width handles only while a transcript is on screen; the hero has no + content column to size. */} + {phase === 'active' && (['left', 'right'] as const).map(side => ( + + ))}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index d9911597f7..0a0d9206aa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -47,8 +47,12 @@ border-radius: 22px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv2); - font-size: 16px; - line-height: 24px; + /* The draft text follows the Settings font-size preference (the textarea, + mirror, and backdrop all inherit from here, so the three layers stay in + step); the line height keeps the default 10px leading via the shared px + delta. */ + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); /* Elevated surface in dark, same as the menus: the draft scrollport inside scrolls once the composer hits its height cap, so the thumb takes the l2 pair. Declared on the card because the elevation belongs to the surface, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 620b0beedd..1b7a82fe44 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -38,16 +38,32 @@ function fakeWiring() { return { wiring: shell, sink, shell } } -/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +/** jsdom has no ResizeObserver; the root publishes its width and the composer + * seat its height through one. Observed targets are recorded so a case can + * fire the callback against a chosen element. */ +const resizeObservers: { callback: ResizeObserverCallback; targets: Element[] }[] = [] class ResizeObserverStub { - observe(): void {} + targets: Element[] = [] + constructor(callback: ResizeObserverCallback) { + resizeObservers.push({ callback, targets: this.targets }) + } + + observe(target: Element): void { this.targets.push(target) } unobserve(): void {} - disconnect(): void {} + disconnect(): void { this.targets.length = 0 } +} + +/** Fires every recorded observer whose target list includes the element. */ +function fireResize(el: Element): void { + for (const entry of resizeObservers) { + if (entry.targets.includes(el)) entry.callback([], undefined as never) + } } afterEach(() => { cleanup() vi.unstubAllGlobals() + resizeObservers.length = 0 }) beforeEach(() => { localStorage.clear() @@ -560,4 +576,72 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)') expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull() }) + + it('publishes the column width as a px variable for the shared width axis', () => { + const b = mount(sessionSnapshotOf()) + const root = b.view.container.querySelector('[data-phase]') as HTMLElement + // jsdom offsetWidth is 0 until faked: the observer publishes whatever the + // layout reports, and the CSS clamp() floors the axis at 680px either way. + Object.defineProperty(root, 'offsetWidth', { value: 1200, configurable: true }) + act(() => { fireResize(root) }) + expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') + // No dragged preference: the user-width override stays absent so the + // adaptive clamp term applies. + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('') + }) + + it('drag → persist → window clamp → double-click reset round-trip on a width handle', () => { + const b = mount(sessionSnapshotOf()) + const root = b.view.container.querySelector('[data-phase]') as HTMLElement + Object.defineProperty(root, 'offsetWidth', { value: 1600, configurable: true }) + act(() => { fireResize(root) }) + const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement + expect(handle).not.toBeNull() + // jsdom lacks pointer capture: emulate per-element so hasPointerCapture + // gates pass; the finally block restores the original descriptors so the + // stubs cannot leak into later tests. + const names = ['setPointerCapture', 'releasePointerCapture', 'hasPointerCapture'] as const + const originals = names.map(name => + [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) + const captured = new Set() + Element.prototype.setPointerCapture = function () { captured.add(this) } + Element.prototype.releasePointerCapture = function () { captured.delete(this) } + Element.prototype.hasPointerCapture = function () { return captured.has(this) } + try { + // Base resolves from the adaptive clamp: min(1600*0.64, 920) = 920. + // Dragging the right handle outward by 25px widens by 2×25 = 50 → 970, + // inside both bounds (max = 1600 − 176 = 1424 keeps the handles on-column). + fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 825, clientY: 300 }) + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px') + expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') + // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the + // preference stays. + Object.defineProperty(root, 'offsetWidth', { value: 900, configurable: true }) + act(() => { fireResize(root) }) + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') + // A press without travel (a real double-click delivers two such + // press/release rounds before dblclick) must not commit the clamped + // display value over the stored preference. + fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 800, clientY: 300 }) + expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + // Double-click resets to the adaptive width: preference and override gone. + fireEvent.doubleClick(handle) + expect(localStorage.getItem('dsh.conversation.contentWidth')).toBeNull() + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('') + } finally { + for (const [name, descriptor] of originals) { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) + else Object.defineProperty(Element.prototype, name, descriptor) + } + } + }) + + it('hero phase renders no width handles (no transcript to size)', () => { + const b = mount(sessionSnapshotOf({ blank: true })) + expect(b.view.container.querySelector('[data-width-handle]')).toBeNull() + }) }) diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css index 7bdb83889e..f403730eff 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -10,7 +10,10 @@ flex-direction: column; align-items: flex-end; min-width: 0; - max-width: min(525px, 82%); + /* Mirrors the user bubble in ui-conversation MessageItem.module.css: the + figma 525px cap as a share of the width axis (525/748 ≈ 0.702), with the + same 748px fallback outside the conversation column. */ + max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%); } .bubble { @@ -21,5 +24,10 @@ background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); font: var(--dsw-font-markdown-code); + /* The command echo is a user message, not dense code-in-prose: keep the + code family from the token but read at the body size, riding the + Settings font-size axis like the user bubble (14/22 at the default). */ + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(22px + var(--dsh-content-font-delta, 0px)); white-space: pre-wrap; } diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 6c99443565..d7f0b8554c 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -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-layout/README.md -README.md: e0e7b33170ee61ca7a0d8ca5a087b9c40100fcee -README.zh.md: 7ea650bf7613a8a0e0ca4e303364cf8b8de926f7 +README.md: 85ab49166315b2f37f2022a2f5b77635ae07b0d2 +README.zh.md: aeaea4c455af25569f1ff44f8c8644b4f80e75b3 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index e0e7b33170..85ab491663 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -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 resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and 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, the theme's alias tokens as inline variables on body, and one owned `` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes. +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 resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and 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, the theme's alias tokens as inline variables on body, the content font size as `--dsh-content-font-size` on body, and one owned `` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes. AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. It also projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the rest of the root entry. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 7ea650bf76..aeaea4c455 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 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 上的内联变量,同时拥有一个 ``,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景成为唯一的颜色依据;呈现器在 dispose(资源释放)时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。 +外壳插件:三栏 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 上的内联变量,把正文字号写为 body 上的 `--dsh-content-font-size`,同时拥有一个 ``,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景成为唯一的颜色依据;呈现器在 dispose(资源释放)时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。它还会把选中 Session 的标题投影到 build 配置的产品标题或已本地化的 `common.brand.localBuild` 回退之上,因此 locale revision 会与 root entry 的其余内容一起更新文档元数据。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用约定默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。 diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 87e3592798..7660359f23 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -2,16 +2,20 @@ * 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, the active - * theme's alias-token overrides as inline CSS variables on body, and one - * presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure - * DOM writes, no React involvement; the presenter only ever retracts what it - * wrote itself, so foreign attributes, metadata, and inline styles survive. + * theme's alias-token overrides as inline CSS variables on body, the content + * font-size axis (`--dsh-content-font-size`), and one presenter-owned + * `meta[name="theme-color"]` for surrounding browser UI. Pure DOM writes, no + * React involvement; the presenter only ever retracts what it wrote itself, + * so foreign attributes, metadata, and inline styles survive. */ 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' +/** Body variable carrying the user's content font size in px. */ +export const CONTENT_FONT_SIZE_VARIABLE = '--dsh-content-font-size' + /** 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). */ @@ -28,10 +32,10 @@ export class ThemePresenter { /** * 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`. Browser theme-color metadata follows the computed - * body background after those writes, so the rendered palette remains the - * color authority. + * resolved upstream), publish the content font-size axis, then replace the + * previously applied token variables with `active.tokens`. Browser + * theme-color metadata follows the computed body background after those + * writes, so the rendered palette remains the color authority. * @param snapshot - resolved theme snapshot from ctx.theme. */ apply(snapshot: ThemeSnapshot): void { @@ -40,6 +44,7 @@ export class ThemePresenter { const body = document.body if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') else body.removeAttribute(DARK_ATTRIBUTE) + body.style.setProperty(CONTENT_FONT_SIZE_VARIABLE, `${snapshot.fontSize}px`) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] for (const [name, value] of Object.entries(snapshot.active.tokens)) { @@ -50,11 +55,12 @@ export class ThemePresenter { if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta) } - /** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */ + /** Retract root color-scheme, the palette attribute, token variables, the font-size axis, and the owned metadata node. */ dispose(): void { document.documentElement.style.removeProperty('color-scheme') const body = document.body body.removeAttribute(DARK_ATTRIBUTE) + body.style.removeProperty(CONTENT_FONT_SIZE_VARIABLE) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] this.themeColorMeta.remove() diff --git a/packages/client/ui-layout/tests/theme-presenter.client.spec.ts b/packages/client/ui-layout/tests/theme-presenter.client.spec.ts index 79f6b974d0..08bf94f446 100644 --- a/packages/client/ui-layout/tests/theme-presenter.client.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.client.spec.ts @@ -7,10 +7,10 @@ import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layou const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)' const DARK_THEME_COLOR = 'rgb(21, 21, 23)' -function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { +function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}, fontSize = 14): ThemeSnapshot { // The presenter must key off colorScheme, not the id — keep them distinct. const active = { id: `${colorScheme}-test`, colorScheme, tokens } - return { preference: colorScheme, active, themes: [active], revision: 1 } + return { preference: colorScheme, fontSize, active, themes: [active], revision: 1 } } function clearThemePresentation(): void { @@ -72,7 +72,15 @@ describe('ThemePresenter', () => { expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('') }) - it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => { + it('publishes the content font size and follows changes', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('light')) + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('14px') + presenter.apply(snapshot('light', {}, 17)) + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('17px') + }) + + it('dispose removes color-scheme, the attribute, the font-size axis, 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' })) @@ -81,6 +89,7 @@ describe('ThemePresenter', () => { 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('--dsh-content-font-size')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') expect(meta?.isConnected).toBe(false) }) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 4f78a49935..f1beebe5f8 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -4,7 +4,9 @@ .block { --dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner); --dsl-code-block-border-radius: 12px; - --dsl-code-block-banner-font: var(--dsw-font-xs-13); + /* Local 11/18 instead of the shared --dsw-font-xs-13: the banner scales + with the markdown 0.875 ladder without shrinking every other xs-13 use. */ + --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family); --dsl-code-block-content-font: var(--dsw-font-markdown-code-block); position: relative; @@ -42,7 +44,7 @@ .infostring { color: var(--dsw-alias-label-primary); font-family: var(--ds-font-family-code); - font-size: 12px; + font-size: 11px; line-height: 18px; min-width: 0; overflow: hidden; diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index f8d45cd4f6..dafe49616c 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -96,7 +96,7 @@ } .markdown li::marker { - line-height: 28px; + line-height: 24px; color: var(--dsw-alias-label-secondary); } @@ -252,7 +252,7 @@ } .tableScroll table code { - font-size: 13px; + font-size: 11px; } .markdown > *:first-child, diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index cdc4c96fa1..f42b12b2ec 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -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-theme/README.md -README.md: 0c9bdf3ee3d99aee1e05453e8f455c1309deb284 -README.zh.md: 033bf7867ac19c664e6303377cae545876ade6f1 +README.md: ec89fde974e3ecbb5de93c4f09f7bbd9b750dc06 +README.zh.md: 5330756a2ec1fa668d74c2a421dd1e496eba677b diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 0c9bdf3ee3..ec89fde974 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), 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). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. The Client keeps Host settings persistence disabled on non-loopback pages, so their selections remain process-local even though Connection authentication applies to every API method. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`) and the conversation content font size (`fontSize`, integer px 12–17, default 14), 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]`, `--dsh-content-font-size` on body, and inline alias tokens). A loopback browser provides the service immediately with the defaults, then loads the `ui-theme` namespace in the background and writes each accepted selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. The Client keeps Host settings persistence disabled on non-loopback pages, so their selections remain process-local even though Connection authentication applies to every API method. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `` tag. Each index response embeds the registered Host settings for `ui-theme` (preference and font size), or the schema defaults when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme`, `body[data-ds-dark-theme]`, and `--dsh-content-font-size` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates. -When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates. +The plugin registers two rows into the settings General section: the Appearance preference cubes and the font-size stepper (12–17 px, centered value with hover-revealed up/down arrows, a px unit label after the pill). The font size drives `--dsh-content-font-size`; `gradient-shadow-text.css` derives `--dsh-content-font-delta` from it and shifts the markdown heading/base ladder by the same px increment, while table, small, and code variants stay fixed. Consumers outside the token ladder (the user bubble, the composer draft) read the same pair directly. `src/styles/` holds five sheets imported in order by ui-theme's dynamic client entry: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme instead of leaving theme CSS in the static web shell. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 033bf7867a..5330756a2e 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeRuntime。该服务拥有实时主题偏好(`light`/`dark`/`system`)与会话正文字号(`fontSize`,整数 px,12–17,默认 14),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`、body 上的 `--dsh-content-font-size`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以默认值立即提供该服务,随后在后台加载 `ui-theme` namespace,并将每次通过的选择经 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。Client 在非 loopback 页面禁用 Host settings 持久化,因此这些页面的选择仍只保留在进程内,尽管 Connection 会认证每个 API 方法。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 当主机组合包含 HTTP 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 `ui-theme` Host 设置(主题偏好与字号),没有 settings provider 时则嵌入 schema 默认值;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme`、`body[data-ds-dark-theme]` 与 `--dsh-content-font-size`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 -当主机组合包含 HTTP 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 +插件向设置 General 区块注册两行:外观偏好方块与字号步进器(12–17 px,数值居中、hover 时显示上下箭头,药丸后带 px 单位标签)。字号驱动 `--dsh-content-font-size`;`gradient-shadow-text.css` 由它派生 `--dsh-content-font-delta`,让 markdown 标题/正文阶梯按同一像素增量整体平移,而表格、small 与 code 变体保持固定。token 阶梯之外的消费方(用户气泡、composer 草稿)直接读取同一对变量。 `src/styles/` 下有五张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR 会随 ui-theme 一同移除这些样式,而不会把主题 CSS 留在静态 Web 外壳中。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts index e6430cc6b9..1bc4131c02 100644 --- a/packages/client/ui-theme/src/boot-theme.ts +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -1,15 +1,15 @@ /** * Theme bootstrap row for the browser's pre-plugin interval. Each index - * render embeds the current durable built-in preference; the browser resolves - * only `system`, then writes the same DOM fields ui-layout's ThemePresenter - * owns after the client plugin tree activates. + * render embeds the current durable built-in preference and content font size; + * the browser resolves only `system`, then writes the same DOM fields + * ui-layout's ThemePresenter owns after the client plugin tree activates. */ import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' -import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' +import { DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' -/** Build the inline script body for one schema-validated built-in preference. */ -function bootThemeScript(preference: ThemePreference): string { +/** Build the inline script body for one schema-validated durable theme section. */ +function bootThemeScript(preference: ThemePreference, fontSize: number): string { return `(() => { const preference = ${JSON.stringify(preference)} const systemDark = preference === 'system' @@ -18,6 +18,7 @@ function bootThemeScript(preference: ThemePreference): string { const dark = preference === 'dark' || systemDark document.documentElement.style.colorScheme = dark ? 'dark' : 'light' document.body.toggleAttribute('data-ds-dark-theme', dark) + document.body.style.setProperty('--dsh-content-font-size', ${JSON.stringify(`${fontSize}px`)}) })()` } @@ -25,10 +26,12 @@ function bootThemeScript(preference: ThemePreference): string { * The theme bootstrap as an injection row: an inline script immediately after * the opening body tag, before the shell mount and module script. * @param preference - Current Host-backed built-in preference. + * @param fontSize - Current Host-backed content font size in px. * @returns the body script row. */ export function bootThemeInjection( preference: ThemePreference = DEFAULT_PREFERENCE, + fontSize: number = DEFAULT_FONT_SIZE, ): IndexInjection { - return { kind: 'script', placement: 'body', text: bootThemeScript(preference) } + return { kind: 'script', placement: 'body', text: bootThemeScript(preference, fontSize) } } diff --git a/packages/client/ui-theme/src/client/FontSizeRow.module.css b/packages/client/ui-theme/src/client/FontSizeRow.module.css new file mode 100644 index 0000000000..8460ab0c6a --- /dev/null +++ b/packages/client/ui-theme/src/client/FontSizeRow.module.css @@ -0,0 +1,106 @@ +/* Font-size row: title + stepper pill, same cell rhythm as the Language and + * Enter-behavior rows (gap 8, pad 16/0, hairline separator; the section + * column strips the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Stepper pill + unit label. The pill reuses the selector-pill geometry + (h36 r18, module fill) holding the px value and an always-visible arrow + column; the unit sits outside the pill. */ +.control { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.stepper { + position: relative; /* the arrow column anchors to the pill's right edge */ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 36px; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); +} + +.value { + min-width: 18px; + text-align: center; + font-size: 14px; + line-height: 22px; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} + +.unit { + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +/* Arrow column: anchored right so the value stays pill-centered whether or + not the arrows are showing; revealed on hover/focus-within. Hidden with + opacity, not visibility: the buttons stay in tab order and in the + accessibility tree, so keyboard focus itself triggers the reveal. */ +.arrows { + position: absolute; + right: 8px; + display: flex; + flex-direction: column; + gap: 2px; + opacity: 0; +} + +.stepper:hover .arrows, +.stepper:focus-within .arrows { + opacity: 1; +} + +/* Arrow buttons: small chips carrying a translucent wash of the layer-1 + surface (white in light, the elevated layer in dark) over the module fill; + hover raises the full layer-1 chip. */ +.arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 12px; + padding: 0; + border: none; + border-radius: 3px; + background: color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.arrow:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.arrow:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} diff --git a/packages/client/ui-theme/src/client/FontSizeRow.tsx b/packages/client/ui-theme/src/client/FontSizeRow.tsx new file mode 100644 index 0000000000..efc78b23e1 --- /dev/null +++ b/packages/client/ui-theme/src/client/FontSizeRow.tsx @@ -0,0 +1,69 @@ +/** + * Font-size preference row registered into the General section item slot: + * title + stepper pill (centered value; hover reveals the up/down arrow + * column anchored to the pill's right edge) + a px unit label after the + * pill. Registered by this package — the theme feature owns the content + * font-size setting the same way it owns the appearance preference. The + * displayed value follows the persisted setting, never the click echo. + */ +import { + IconChevronDownOutline14, IconChevronUpOutline14, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { FONT_SIZE_MAX, FONT_SIZE_MIN } from '../theme-settings.ts' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { createFontSizeRowStore } from './settings-store.ts' +import css from './FontSizeRow.module.css' + +/** Injected business face: the preference write (t rides the standard locale seat). */ +export interface FontSizeRowInjected { + /** Change the content font size (integer px within FONT_SIZE_MIN..FONT_SIZE_MAX). */ + setFontSize: (px: number) => void +} + +/** Full component props: runtime share + store share + locale seat + injected face. */ +export type FontSizeRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.theme'> & FontSizeRowInjected + +/** + * Render the font-size row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function FontSizeRow({ t, setFontSize, useStore }: FontSizeRowComponentProps) { + const fontSize = useStore(s => s.fontSize) + return ( +
+
+
{t('fontSize.title')}
+
+
+
+ {fontSize} + + + + +
+ {t('fontSize.unit')} +
+
+ ) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index c6522e7ffe..64bf442b47 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -18,16 +18,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' -import { createAppearanceRowStore } from './settings-store.ts' +import type { FontSizeRowInjected } from './FontSizeRow.tsx' +import { FontSizeRow } from './FontSizeRow.tsx' +import { createAppearanceRowStore, createFontSizeRowStore } from './settings-store.ts' import { installThemeStyles } from './styles.ts' import { en, zh, type ThemeKey } from './locales.ts' import { - DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, FONT_SIZE_FIELD, FONT_SIZE_MAX, FONT_SIZE_MIN, + isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' -export type { AppearanceRowState } from './settings-store.ts' +export type { FontSizeRowComponentProps, FontSizeRowInjected } from './FontSizeRow.tsx' +export type { AppearanceRowState, FontSizeRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' export type { ThemePreference, ThemeSettings } from '../theme-settings.ts' @@ -76,6 +80,8 @@ export interface ThemeDefinition { export interface ThemeSnapshot { /** The persisted preference (may be `system`). */ preference: ThemePreference + /** Conversation content font size in px (integer within FONT_SIZE_MIN..FONT_SIZE_MAX). */ + fontSize: number /** * The resolved active theme (`system` resolved via prefers-color-scheme) * with override layers folded into its tokens (seq order, later layers win @@ -154,6 +160,7 @@ export class ThemeRuntime { private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference + private fontSize: number = bootstrapFontSize() private revision = 0 private snapshot: ThemeSnapshot private readonly media: MediaQueryList | undefined @@ -231,11 +238,29 @@ export class ThemeRuntime { this.publish() } + /** + * Change the conversation content font size — the only font-size write + * entry. Accepted values are written through the settings scope and emit + * `theme/change`. + * @param px - integer px within FONT_SIZE_MIN..FONT_SIZE_MAX; out-of-range or fractional values throw. + */ + setFontSize(px: number): void { + if (!Number.isInteger(px) || px < FONT_SIZE_MIN || px > FONT_SIZE_MAX) { + throw new Error(`font size ${px} is outside ${FONT_SIZE_MIN}..${FONT_SIZE_MAX}`) + } + if (this.fontSize === px) return + this.fontSize = px + void this.host.set(FONT_SIZE_FIELD, px) + this.publish() + } + /** Adopt the scope's accepted durable preference without writing it back. */ private adopt(): void { const section = this.host.getSnapshot().value - if (section === undefined || this.preference === section.preference) return + if (section === undefined) return + if (this.preference === section.preference && this.fontSize === section.fontSize) return this.preference = section.preference + this.fontSize = section.fontSize this.publish() } @@ -302,6 +327,7 @@ export class ThemeRuntime { if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) return Object.freeze({ preference: this.preference, + fontSize: this.fontSize, active: this.composeActive(active), themes: Object.freeze([...this.themes]), revision: this.revision, @@ -332,13 +358,29 @@ export class ThemeRuntime { } } +/** + * Read the font size the Host boot script wrote on `body` before any plugin + * ran, so the initial snapshot matches first paint and ui-layout's presenter + * does not flash the schema default while the settings read is in flight. + * Non-browser runs and mounts without the boot script fall back to the + * schema default; the durable settings adoption still lands afterwards. + */ +function bootstrapFontSize(): number { + /* v8 ignore next -- needs a documentless run (node e2e booting the client tree), not constructible under jsdom */ + if (typeof document === 'undefined') return DEFAULT_FONT_SIZE + const raw = document.body.style.getPropertyValue('--dsh-content-font-size') + const parsed = Number.parseInt(raw, 10) + return Number.isInteger(parsed) && parsed >= FONT_SIZE_MIN && parsed <= FONT_SIZE_MAX + ? parsed + : DEFAULT_FONT_SIZE +} + /** * Runtime shape check for one override layer (model-authored callers pass * untyped JS through the dynamic-package façade, so the static type cannot * enforce the pair shape there). Returns a defensive per-token copy so later * caller mutation cannot reach the stored layer. - */ -function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides { + */function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides { const validated: ThemeTokenOverrides = {} for (const [name, value] of Object.entries(tokens)) { if (typeof value === 'string') { @@ -393,8 +435,11 @@ export function apply(ctx: ClientContext): void { const store = createAppearanceRowStore() let bound: BoundActions | undefined + const fontSizeStore = createFontSizeRowStore() + let fontSizeBound: BoundActions | undefined const sync = (snapshot: ThemeSnapshot): void => { bound?.sync(snapshot.preference, snapshot.revision) + fontSizeBound?.sync(snapshot.fontSize, snapshot.revision) } ctx.on('theme/change', sync) const injected = (actions: BoundActions): AppearanceRowInjected => { @@ -414,4 +459,20 @@ export function apply(ctx: ClientContext): void { locale: SETTINGS_NS, inject: injected, }, AppearanceRow)) + + const fontSizeInjected = (actions: BoundActions): FontSizeRowInjected => { + fontSizeBound = actions + sync(theme.getTheme()) + return { + setFontSize: (px) => { theme.setFontSize(px) }, + } + } + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'font-size', + order: 11, + store: fontSizeStore, + locale: SETTINGS_NS, + inject: fontSizeInjected, + }, FontSizeRow)) } diff --git a/packages/client/ui-theme/src/client/locales.ts b/packages/client/ui-theme/src/client/locales.ts index 6df56ceb96..b52fff550b 100644 --- a/packages/client/ui-theme/src/client/locales.ts +++ b/packages/client/ui-theme/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `settings.theme` namespace dictionaries (the Appearance row's copy). */ +/** `settings.theme` namespace dictionaries (the Appearance and font-size rows' copy). */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { @@ -6,6 +6,10 @@ export const zh = { 'appearance.light': '浅色', 'appearance.dark': '深色', 'appearance.system': '跟随系统', + 'fontSize.title': '字号大小', + 'fontSize.unit': 'px', + 'fontSize.increase': '增大字号', + 'fontSize.decrease': '减小字号', } satisfies Record /** The settings.theme namespace key union. */ @@ -17,4 +21,8 @@ export const en = { 'appearance.light': 'Light', 'appearance.dark': 'Dark', 'appearance.system': 'System', + 'fontSize.title': 'Font size', + 'fontSize.unit': 'px', + 'fontSize.increase': 'Increase font size', + 'fontSize.decrease': 'Decrease font size', } satisfies Record diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index b616013ac4..d511343cde 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -1,10 +1,10 @@ /** - * Appearance row slot store: a mirror of the theme service snapshot. The - * plugin's apply-world change listener is the only writer; the row component - * reads via props.useStore. + * Appearance and font-size row slot stores: mirrors of the theme service + * snapshot. The plugin's apply-world change listener is the only writer; the + * row components read via props.useStore. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' -import type { ThemePreference } from '../theme-settings.ts' +import { DEFAULT_FONT_SIZE, type ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ export interface AppearanceRowState { @@ -35,3 +35,33 @@ export function createAppearanceRowStore(): EngineStoreHandle void +} + +/** + * Declares the font-size row state and write surface. + * @returns the store handle. + */ +export function createFontSizeRowStore(): EngineStoreHandle { + return defineStore({ + init: (): FontSizeRowState => ({ fontSize: DEFAULT_FONT_SIZE, revision: -1 }), + actions: { + sync: (d, fontSize: number, revision: number) => { + if (revision <= d.revision) return + d.fontSize = fontSize + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index cc119d1825..71a94abc77 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -5,24 +5,26 @@ import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { bootThemeInjection } from './boot-theme.ts' import { - DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, + DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, + DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, FONT_SIZE_FIELD, FONT_SIZE_MAX, FONT_SIZE_MIN, + THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE) -/** Read the registered preference or use the schema default without a settings provider. */ -function readPreference(ctx: Context): ThemePreference { +/** Read the registered theme section or the schema defaults without a settings provider. */ +function readSection(ctx: Context): { preference: ThemePreference; fontSize: number } { + const fallback = { preference: DEFAULT_PREFERENCE, fontSize: DEFAULT_FONT_SIZE } const settings = ctx.get('settings') - if (settings === undefined) return DEFAULT_PREFERENCE + if (settings === undefined) return fallback const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined - if (section === undefined) return DEFAULT_PREFERENCE - return section.preference + if (section === undefined) return fallback + return section } /** @@ -36,6 +38,7 @@ export function apply(ctx: Context): void { settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) }) ctx.on('webserver/index-inject', (table) => { - table.push(bootThemeInjection(readPreference(ctx))) + const section = readSection(ctx) + table.push(bootThemeInjection(section.preference, section.fontSize)) }) } diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index e062b4ba93..c286c6d2e2 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -18,125 +18,133 @@ body[data-ds-dark-theme] { /* ---------- 字体 ---------- */ /* 由 @deepseek-figma-plugin/custom-variable-name 插件导出 */ +/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素; + 重新从 Figma 导出时需要保留这一缩放。 */ +/* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter + 写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量 + (--dsh-content-font-delta)随动,保持层级;表格、small、code 属于密集 + 次级文本,保持固定。 */ body { - --dsw-font-markdown-h1: 700 24px/34px var(--dsw-font-family); + --dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px); + + --dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h1-font-family: var(--dsw-font-family); --dsw-font-markdown-h1-font-weight: 700; - --dsw-font-markdown-h1-line-height: 34px; - --dsw-font-markdown-h1-font-size: 24px; + --dsw-font-markdown-h1-line-height: calc(30px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h1-font-size: calc(21px + var(--dsh-content-font-delta)); --dsw-font-markdown-h1-font-style: normal; - --dsw-font-markdown-h2: 700 22px/32px var(--dsw-font-family); + --dsw-font-markdown-h2: 700 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h2-font-family: var(--dsw-font-family); --dsw-font-markdown-h2-font-weight: 700; - --dsw-font-markdown-h2-line-height: 32px; - --dsw-font-markdown-h2-font-size: 22px; + --dsw-font-markdown-h2-line-height: calc(28px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h2-font-size: calc(19px + var(--dsh-content-font-delta)); --dsw-font-markdown-h2-font-style: normal; - --dsw-font-markdown-h3: 700 20px/30px var(--dsw-font-family); + --dsw-font-markdown-h3: 700 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h3-font-family: var(--dsw-font-family); --dsw-font-markdown-h3-font-weight: 700; - --dsw-font-markdown-h3-line-height: 30px; - --dsw-font-markdown-h3-font-size: 20px; + --dsw-font-markdown-h3-line-height: calc(26px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h3-font-size: calc(18px + var(--dsh-content-font-delta)); --dsw-font-markdown-h3-font-style: normal; - --dsw-font-markdown-h4: 600 16px/28px var(--dsw-font-family); + --dsw-font-markdown-h4: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h4-font-family: var(--dsw-font-family); --dsw-font-markdown-h4-font-weight: 600; - --dsw-font-markdown-h4-line-height: 28px; - --dsw-font-markdown-h4-font-size: 16px; + --dsw-font-markdown-h4-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h4-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-h4-font-style: normal; - --dsw-font-markdown-base: 16px/28px var(--dsw-font-family); + --dsw-font-markdown-base: var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-base-font-family: var(--dsw-font-family); --dsw-font-markdown-base-font-weight: 400; - --dsw-font-markdown-base-line-height: 28px; - --dsw-font-markdown-base-font-size: 16px; + --dsw-font-markdown-base-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-font-style: normal; - --dsw-font-markdown-base-strong: 600 16px/28px var(--dsw-font-family); + --dsw-font-markdown-base-strong: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-base-strong-font-family: var(--dsw-font-family); --dsw-font-markdown-base-strong-font-weight: 600; - --dsw-font-markdown-base-strong-line-height: 28px; - --dsw-font-markdown-base-strong-font-size: 16px; + --dsw-font-markdown-base-strong-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-strong-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-strong-font-style: normal; - --dsw-font-markdown-base-italic: italic 16px/28px var(--dsw-font-family); + --dsw-font-markdown-base-italic: italic var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-base-italic-font-family: var(--dsw-font-family); --dsw-font-markdown-base-italic-font-weight: 400; - --dsw-font-markdown-base-italic-line-height: 28px; - --dsw-font-markdown-base-italic-font-size: 16px; + --dsw-font-markdown-base-italic-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-italic-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-italic-font-style: italic; - --dsw-font-markdown-base-strong-italic: italic 600 16px/28px var(--dsw-font-family); + --dsw-font-markdown-base-strong-italic: italic 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-base-strong-italic-font-family: var(--dsw-font-family); --dsw-font-markdown-base-strong-italic-font-weight: 600; - --dsw-font-markdown-base-strong-italic-line-height: 28px; - --dsw-font-markdown-base-strong-italic-font-size: 16px; + --dsw-font-markdown-base-strong-italic-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-strong-italic-font-style: italic; - --dsw-font-markdown-table: 15px/25px var(--dsw-font-family); + --dsw-font-markdown-table: 13px/22px var(--dsw-font-family); --dsw-font-markdown-table-font-family: var(--dsw-font-family); --dsw-font-markdown-table-font-weight: 400; - --dsw-font-markdown-table-line-height: 25px; - --dsw-font-markdown-table-font-size: 15px; + --dsw-font-markdown-table-line-height: 22px; + --dsw-font-markdown-table-font-size: 13px; --dsw-font-markdown-table-font-style: normal; - --dsw-font-markdown-table-head: 500 15px/25px var(--dsw-font-family); + --dsw-font-markdown-table-head: 500 13px/22px var(--dsw-font-family); --dsw-font-markdown-table-head-font-family: var(--dsw-font-family); --dsw-font-markdown-table-head-font-weight: 500; - --dsw-font-markdown-table-head-line-height: 25px; - --dsw-font-markdown-table-head-font-size: 15px; + --dsw-font-markdown-table-head-line-height: 22px; + --dsw-font-markdown-table-head-font-size: 13px; --dsw-font-markdown-table-head-font-style: normal; - --dsw-font-markdown-small: 14px/24px var(--dsw-font-family); + --dsw-font-markdown-small: 12px/20px var(--dsw-font-family); --dsw-font-markdown-small-font-family: var(--dsw-font-family); --dsw-font-markdown-small-font-weight: 400; - --dsw-font-markdown-small-line-height: 24px; - --dsw-font-markdown-small-font-size: 14px; + --dsw-font-markdown-small-line-height: 20px; + --dsw-font-markdown-small-font-size: 12px; --dsw-font-markdown-small-font-style: normal; - --dsw-font-markdown-small-strong: 600 14px/24px var(--dsw-font-family); + --dsw-font-markdown-small-strong: 600 12px/20px var(--dsw-font-family); --dsw-font-markdown-small-strong-font-family: var(--dsw-font-family); --dsw-font-markdown-small-strong-font-weight: 600; - --dsw-font-markdown-small-strong-line-height: 24px; - --dsw-font-markdown-small-strong-font-size: 14px; + --dsw-font-markdown-small-strong-line-height: 20px; + --dsw-font-markdown-small-strong-font-size: 12px; --dsw-font-markdown-small-strong-font-style: normal; - --dsw-font-markdown-small-italic: italic 14px/24px var(--dsw-font-family); + --dsw-font-markdown-small-italic: italic 12px/20px var(--dsw-font-family); --dsw-font-markdown-small-italic-font-family: var(--dsw-font-family); --dsw-font-markdown-small-italic-font-weight: 400; - --dsw-font-markdown-small-italic-line-height: 24px; - --dsw-font-markdown-small-italic-font-size: 14px; + --dsw-font-markdown-small-italic-line-height: 20px; + --dsw-font-markdown-small-italic-font-size: 12px; --dsw-font-markdown-small-italic-font-style: italic; - --dsw-font-markdown-small-strong-italic: italic 600 14px/24px var(--dsw-font-family); + --dsw-font-markdown-small-strong-italic: italic 600 12px/20px var(--dsw-font-family); --dsw-font-markdown-small-strong-italic-font-family: var(--dsw-font-family); --dsw-font-markdown-small-strong-italic-font-weight: 600; - --dsw-font-markdown-small-strong-italic-line-height: 24px; - --dsw-font-markdown-small-strong-italic-font-size: 14px; + --dsw-font-markdown-small-strong-italic-line-height: 20px; + --dsw-font-markdown-small-strong-italic-font-size: 12px; --dsw-font-markdown-small-strong-italic-font-style: italic; - --dsw-font-markdown-code: 14px/22px var(--ds-font-family-code); + --dsw-font-markdown-code: 12px/19px var(--ds-font-family-code); --dsw-font-markdown-code-font-family: var(--ds-font-family-code); --dsw-font-markdown-code-font-weight: 400; - --dsw-font-markdown-code-line-height: 22px; - --dsw-font-markdown-code-font-size: 14px; + --dsw-font-markdown-code-line-height: 19px; + --dsw-font-markdown-code-font-size: 12px; --dsw-font-markdown-code-font-style: normal; - --dsw-font-markdown-code-block: 13px/22px var(--ds-font-family-code); + --dsw-font-markdown-code-block: 11px/19px var(--ds-font-family-code); --dsw-font-markdown-code-block-font-family: var(--ds-font-family-code); --dsw-font-markdown-code-block-font-weight: 400; - --dsw-font-markdown-code-block-line-height: 22px; - --dsw-font-markdown-code-block-font-size: 13px; + --dsw-font-markdown-code-block-line-height: 19px; + --dsw-font-markdown-code-block-font-size: 11px; --dsw-font-markdown-code-block-font-style: normal; /* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */ - --dsw-font-markdown-code-block-small: 12px/18px var(--ds-font-family-code); + --dsw-font-markdown-code-block-small: 11px/16px var(--ds-font-family-code); --dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code); --dsw-font-markdown-code-block-small-font-weight: 400; - --dsw-font-markdown-code-block-small-line-height: 18px; - --dsw-font-markdown-code-block-small-font-size: 12px; + --dsw-font-markdown-code-block-small-line-height: 16px; + --dsw-font-markdown-code-block-small-font-size: 11px; --dsw-font-markdown-code-block-small-font-style: normal; --dsw-font-xl-24: 600 24px/32px var(--dsw-font-family); diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index c2c268e21f..11f8789d6c 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -11,21 +11,36 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' /** Field carrying the selected built-in theme preference. */ export const THEME_PREFERENCE_FIELD = 'preference' +/** Field carrying the conversation content font size. */ +export const FONT_SIZE_FIELD = 'fontSize' + /** Theme preference persisted by the product Appearance row. */ export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' +/** Smallest accepted content font size (px). */ +export const FONT_SIZE_MIN = 12 + +/** Largest accepted content font size (px). */ +export const FONT_SIZE_MAX = 17 + +/** Content font size when the user-settings document has no override (px). */ +export const DEFAULT_FONT_SIZE = 14 + /** Durable theme section shared by the Host schema and the browser scope. */ export interface ThemeSettings { /** Selected built-in preference. */ preference: ThemePreference + /** Conversation content font size in px (integer within {@link FONT_SIZE_MIN}..{@link FONT_SIZE_MAX}). */ + fontSize: number } /** Durable theme schema; also the wire envelope the browser scope validates against. */ export const ThemeSettingsSchema: z = z.object({ [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), + [FONT_SIZE_FIELD]: z.number().step(1).min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(DEFAULT_FONT_SIZE), }) /** diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 65b66c2a39..121444af93 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -8,10 +8,11 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' -import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, FontSizeRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' -import type { createAppearanceRowStore } from '../src/client/settings-store.ts' +import { FontSizeRow } from '../src/client/FontSizeRow.tsx' +import type { createAppearanceRowStore, createFontSizeRowStore } from '../src/client/settings-store.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -31,11 +32,11 @@ async function bench(isLoopback = true) { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - let preference = 'system' + const section: Record = { preference: 'system', fontSize: 14 } const namespace = () => ({ ns: THEME_SETTINGS_NAMESPACE, schema: ThemeSettingsSchema.toJSON(), - value: { preference }, + value: { ...section }, applies: 'live' as const, secrets: [], revision: 0, @@ -47,8 +48,9 @@ async function bench(isLoopback = true) { value: { writable: true, hasDocument: true, namespaces: [namespace()] }, }, })) - const mutate = vi.fn((request: { ops: { value: string }[] }) => { - preference = request.ops[0]!.value + const mutate = vi.fn((request: { ops: { path: string[]; value: unknown }[] }) => { + const op = request.ops[0]! + section[op.path[0]!] = op.value return Promise.resolve({ rpcId: 'theme-mutate' as never, result: { ok: true as const, value: namespace() }, @@ -59,7 +61,7 @@ async function bench(isLoopback = true) { await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, - setHostPreference: (next: string) => { preference = next }, + setHostSection: (next: Record) => { Object.assign(section, next) }, } } @@ -81,20 +83,33 @@ function faceOf(slots: SlotRegistry) { return { entry, instance, face } } +/** The same choreography for the font-size row entry. */ +function fontSizeFaceOf(slots: SlotRegistry) { + const entry = slots.entries(SLOT).find(e => e.component === FontSizeRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => FontSizeRowInjected)(instance.actions) + return { entry, instance, face } +} + describe('ui-theme apply', () => { it('declares the slot and locale services', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) - it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { + it('provides the service, registers localized copy, and registers both rows (declaration before or after apply)', async () => { const before = await bench() declareItems(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观') + expect(before.locale.bind(SETTINGS_NS)('fontSize.title')).toBe('字号大小') before.locale.setLocale('en') expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance') const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)! expect(entry.options).toMatchObject({ id: 'appearance', order: 10 }) + const fontEntry = before.slots.entries(SLOT).find(e => e.component === FontSizeRow)! + expect(fontEntry.options).toMatchObject({ id: 'font-size', order: 11 }) + expect(fontEntry.locale).toBe(SETTINGS_NS) const after = await bench() const fiber = after.ctx.plugin({ inject: [...inject], apply }) @@ -103,6 +118,7 @@ describe('ui-theme apply', () => { declareItems(after.slots) await Promise.resolve() expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + expect(after.slots.entries(SLOT).some(e => e.component === FontSizeRow)).toBe(true) }) it('projects service snapshots into the row store and routes face writes back', async () => { @@ -125,25 +141,44 @@ describe('ui-theme apply', () => { await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) }) + it('projects font-size snapshots into its row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeRuntime + // An event ahead of any inject hits the unbound-actions arm. + theme.setFontSize(16) + + const { instance, face } = fontSizeFaceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().fontSize).toBe(16) + + face.setFontSize(12) + expect(theme.getTheme().fontSize).toBe(12) + expect(instance.getSnapshot().fontSize).toBe(12) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { const b = await bench() // The shared mirror read once at bench time; a Host-side change reaches it // through the document invalidation, exactly as production announces one. - b.setHostPreference('dark') + b.setHostSection({ preference: 'dark', fontSize: 17 }) b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + expect(theme.getTheme().fontSize).toBe(17) // The mirror refreshes on every document commit (ns-agnostic); the scope's // derived value only moves when its own namespace changed. b.events.emit('settings/document-updated', ['unrelated', 0]) await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) }) expect(theme.getTheme().preference).toBe('dark') - b.setHostPreference('light') + b.setHostSection({ preference: 'light' }) b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) - b.setHostPreference('dark') + b.setHostSection({ preference: 'dark' }) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) @@ -159,7 +194,7 @@ describe('ui-theme apply', () => { it('activates before a slow settings refresh and converges when it settles', async () => { const b = await bench() - b.setHostPreference('dark') + b.setHostSection({ preference: 'dark' }) const describe = b.describe.getMockImplementation()! const pending = deferred>>() b.describe.mockImplementationOnce(() => pending.promise) @@ -177,7 +212,7 @@ describe('ui-theme apply', () => { it('ignores an invalid preference crossing the settings wire', async () => { const b = await bench() - b.setHostPreference('sepia') + b.setHostSection({ preference: 'sepia' }) b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime @@ -189,24 +224,25 @@ describe('ui-theme apply', () => { const b = await bench() const host = declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries(SLOT)).toHaveLength(1) + expect(b.slots.entries(SLOT)).toHaveLength(2) - // Collapse: the declarer dies, the cascade removes our entry while the - // apply closure still holds its (now stale) disposer. + // Collapse: the declarer dies, the cascade removes our entries while the + // apply closure still holds its (now stale) disposers. host() expect(b.slots.entries(SLOT)).toHaveLength(0) declareItems(b.slots) await Promise.resolve() expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + expect(b.slots.entries(SLOT).some(e => e.component === FontSizeRow)).toBe(true) }) - it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => { + it('teardown removes the rows and the dictionaries; teardown without a declaration is quiet', async () => { const b = await bench() declareItems(b.slots) const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(b.slots.entries(SLOT)).toHaveLength(1) + expect(b.slots.entries(SLOT)).toHaveLength(2) await fiber.dispose() expect(b.slots.entries(SLOT)).toHaveLength(0) // Dictionary disposal: translation falls back to the bare key. diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index ab737059c1..ef13929961 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -11,8 +11,8 @@ function mockSystemDark(matches: boolean): void { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) } -function executeBootstrap(preference?: ThemePreference): void { - const row = bootThemeInjection(preference) +function executeBootstrap(preference?: ThemePreference, fontSize?: number): void { + const row = bootThemeInjection(preference, fontSize) if (row.kind !== 'script') throw new Error('theme bootstrap row is not a script') runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia }) } @@ -22,6 +22,7 @@ afterEach(() => { vi.unstubAllGlobals() document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) + document.body.style.removeProperty('--dsh-content-font-size') }) describe('theme bootstrap row', () => { @@ -58,4 +59,12 @@ describe('theme bootstrap row', () => { expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) }) + + it('writes the durable content font size and defaults it to 14px', () => { + mockSystemDark(false) + executeBootstrap('light', 17) + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('17px') + executeBootstrap('light') + expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('14px') + }) }) diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx new file mode 100644 index 0000000000..864c843ffc --- /dev/null +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +/** FontSizeRow behavior: value display, arrow clicks drive setFontSize, + * bound-value arrows disable, display follows the store mirror. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { FontSizeRow } from '../src/client/FontSizeRow.tsx' +import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx' +import { createFontSizeRowStore } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const COPY: Record = { + 'fontSize.title': 'Font size', + 'fontSize.increase': 'Increase font size', + 'fontSize.decrease': 'Decrease font size', +} + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + }) + return bindSnapshotSelector(store) +} + +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: FontSizeRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + +function mount(fontSize = 14) { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createFontSizeRowStore().create() + store.actions.sync(fontSize, 0) + const setFontSize = vi.fn() + const props: FontSizeRowComponentProps = { + useSessions: emptySessions(), + useSessionPendingInteraction, + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => COPY[key] ?? key, + setFontSize, + } + render() + return { store, setFontSize } +} + +const arrow = (name: string): HTMLButtonElement => + screen.getByRole('button', { name }) as HTMLButtonElement + +describe('FontSizeRow', () => { + it('renders the title and the current size with both arrows enabled mid-range', () => { + mount(14) + expect(screen.getByText('Font size')).toBeDefined() + expect(screen.getByText('14')).toBeDefined() + expect(arrow('Increase font size').disabled).toBe(false) + expect(arrow('Decrease font size').disabled).toBe(false) + }) + + it('arrow clicks step by 1; display follows the store mirror, not the click echo', () => { + const b = mount(14) + fireEvent.click(arrow('Increase font size')) + expect(b.setFontSize).toHaveBeenCalledWith(15) + // No store write yet: the display is unchanged. + expect(screen.getByText('14')).toBeDefined() + act(() => { b.store.actions.sync(15, 1) }) + expect(screen.getByText('15')).toBeDefined() + fireEvent.click(arrow('Decrease font size')) + expect(b.setFontSize).toHaveBeenCalledWith(14) + }) + + it('disables the outward arrow at each bound', () => { + mount(17) + expect(arrow('Increase font size').disabled).toBe(true) + expect(arrow('Decrease font size').disabled).toBe(false) + cleanup() + mount(12) + expect(arrow('Increase font size').disabled).toBe(false) + expect(arrow('Decrease font size').disabled).toBe(true) + }) +}) diff --git a/packages/client/ui-theme/tests/host.client.spec.ts b/packages/client/ui-theme/tests/host.client.spec.ts index 692158cba0..3ba181ea99 100644 --- a/packages/client/ui-theme/tests/host.client.spec.ts +++ b/packages/client/ui-theme/tests/host.client.spec.ts @@ -34,10 +34,12 @@ describe('ui-theme host', () => { const fiber = ctx.plugin({ apply }) await fiber.await() const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE) - expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE }) - await ctx.settings.update(ns, { preference: 'dark' }) - expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' }) + expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14 }) + await ctx.settings.update(ns, { preference: 'dark', fontSize: 16 }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'dark', fontSize: 16 }) await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow() + await expect(ctx.settings.update(ns, { fontSize: 11 })).rejects.toThrow() + await expect(ctx.settings.update(ns, { fontSize: 18 })).rejects.toThrow() await fiber.dispose() expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) }) @@ -51,8 +53,10 @@ describe('ui-theme host', () => { expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ kind: 'script', placement: 'body' }) expect(scriptText(rows[0])).toContain('const preference = "system"') - await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' }) + expect(scriptText(rows[0])).toContain('"14px"') + await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark', fontSize: 17 }) expect(scriptText(collect(ctx)[0])).toContain('const preference = "dark"') + expect(scriptText(collect(ctx)[0])).toContain('"17px"') await fiber.dispose() expect(collect(ctx)).toEqual([]) }) diff --git a/packages/client/ui-theme/tests/settings-store.client.spec.ts b/packages/client/ui-theme/tests/settings-store.client.spec.ts index 540d0f5b3b..1bdcebd322 100644 --- a/packages/client/ui-theme/tests/settings-store.client.spec.ts +++ b/packages/client/ui-theme/tests/settings-store.client.spec.ts @@ -1,6 +1,6 @@ -/** Appearance row store: snapshot-mirror action and the revision guard. */ +/** Appearance and font-size row stores: snapshot-mirror actions and the revision guards. */ import { describe, expect, it } from 'vitest' -import { createAppearanceRowStore } from '../src/client/settings-store.ts' +import { createAppearanceRowStore, createFontSizeRowStore } from '../src/client/settings-store.ts' describe('createAppearanceRowStore', () => { it('init shape: system preference with revision at -1', () => { @@ -26,3 +26,20 @@ describe('createAppearanceRowStore', () => { expect(store.getSnapshot().revision).toBe(3) }) }) + +describe('createFontSizeRowStore', () => { + it('init shape: default size with revision at -1', () => { + const store = createFontSizeRowStore().create() + expect(store.getSnapshot()).toEqual({ fontSize: 14, revision: -1 }) + }) + + it('sync mirrors the size; the revision guard drops stale and duplicate writes', () => { + const store = createFontSizeRowStore().create() + store.actions.sync(16, 3) + expect(store.getSnapshot()).toEqual({ fontSize: 16, revision: 3 }) + store.actions.sync(12, 2) + store.actions.sync(12, 3) + expect(store.getSnapshot().fontSize).toBe(16) + expect(store.getSnapshot().revision).toBe(3) + }) +}) diff --git a/packages/client/ui-theme/tests/theme.client.spec.ts b/packages/client/ui-theme/tests/theme.client.spec.ts index ae8b0488d0..d2a7d2ebe9 100644 --- a/packages/client/ui-theme/tests/theme.client.spec.ts +++ b/packages/client/ui-theme/tests/theme.client.spec.ts @@ -26,12 +26,54 @@ describe('ThemeRuntime', () => { const { theme } = make() const snapshot = theme.getTheme() expect(snapshot.preference).toBe('system') + expect(snapshot.fontSize).toBe(14) // jsdom matchMedia is absent; system resolves to light. expect(snapshot.active.id).toBe('light') expect(snapshot.active.colorScheme).toBe('light') expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) + it('seeds the initial font size from the boot-script body variable, ignoring junk', () => { + // The Host boot script writes the durable size on body before any plugin + // runs; the first snapshot must match it so activation never flashes 14. + document.body.style.setProperty('--dsh-content-font-size', '16px') + try { + expect(make().theme.getTheme().fontSize).toBe(16) + document.body.style.setProperty('--dsh-content-font-size', '99px') + expect(make().theme.getTheme().fontSize).toBe(14) + } finally { + document.body.style.removeProperty('--dsh-content-font-size') + } + }) + + it('setFontSize switches, writes through the scope, and republishes; same value is a no-op', () => { + const { theme, events, host } = make() + theme.setFontSize(17) + expect(theme.getTheme().fontSize).toBe(17) + expect(host.set).toHaveBeenCalledWith('fontSize', 17) + expect(events).toHaveLength(1) + theme.setFontSize(17) + expect(events).toHaveLength(1) + expect(host.set).toHaveBeenCalledOnce() + }) + + it('rejects out-of-range and fractional font sizes', () => { + const { theme, events, host } = make() + for (const px of [11, 18, 14.5, Number.NaN]) { + expect(() => { theme.setFontSize(px) }).toThrow('outside 12..17') + } + expect(events).toHaveLength(0) + expect(host.set).not.toHaveBeenCalled() + }) + + it('adopts a published Host font size without writing it back', () => { + const { theme, events, host } = make() + host.publish({ status: 'ready', value: { preference: 'system', fontSize: 12 }, revision: 1, writable: true }) + expect(theme.getTheme().fontSize).toBe(12) + expect(events).toHaveLength(1) + expect(host.set).not.toHaveBeenCalled() + }) + it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => { const { theme, events, host } = make() theme.setTheme('dark') @@ -50,17 +92,17 @@ describe('ThemeRuntime', () => { it('adopts a published Host section without writing it back', () => { const { theme, events, host } = make() - host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { preference: 'dark', fontSize: 14 }, revision: 1, writable: true }) expect(theme.getTheme().preference).toBe('dark') expect(events).toHaveLength(1) expect(host.set).not.toHaveBeenCalled() - host.publish({ value: { preference: 'dark' }, revision: 2 }) + host.publish({ value: { preference: 'dark', fontSize: 14 }, revision: 2 }) expect(events).toHaveLength(1) }) it('adopts a section already standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { preference: 'dark', fontSize: 14 }, revision: 1, writable: true }) const { theme } = make(host) expect(theme.getTheme().preference).toBe('dark') }) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1635660caf..eb5b402c93 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -245,6 +245,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Switch the theme preference — the only user preference write entry. Built-in preferences are written through the settings scope and every accepted value emits `theme/change`.', parameters: [{ name: 'id', description: 'a registered theme id or `system`; unknown ids throw.' }], }, + { + signature: 'setFontSize(px: number): void', + description: 'Change the conversation content font size — the only font-size write entry. Accepted values are written through the settings scope and emit `theme/change`.', + parameters: [{ name: 'px', description: 'integer px within FONT_SIZE_MIN..FONT_SIZE_MAX; out-of-range or fractional values throw.' }], + }, { signature: 'register(definition: ThemeDefinition): () => void', description: 'Register a theme. Duplicate id throws (single occupant per id; the built-in pair counts; `system` is a preference, not a registrable id).', @@ -767,7 +772,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ThemeSnapshot', - declaration: 'export interface ThemeSnapshot {\n preference: ThemePreference;\n active: ThemeDefinition;\n themes: readonly ThemeDefinition[];\n revision: number;\n}', + declaration: 'export interface ThemeSnapshot {\n preference: ThemePreference;\n fontSize: number;\n active: ThemeDefinition;\n themes: readonly ThemeDefinition[];\n revision: number;\n}', }, { name: 'ThemeTokenModes', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 189991fd0d..fd4aece269 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1523,6 +1523,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-theme AppearanceRow id \'appearance\'', + 'client-ui-theme FontSizeRow id \'font-size\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/scripts/gen-cordis-inspect-catalog.ts b/scripts/gen-cordis-inspect-catalog.ts index 24ce8d70d2..b683fd1a85 100644 --- a/scripts/gen-cordis-inspect-catalog.ts +++ b/scripts/gen-cordis-inspect-catalog.ts @@ -14,7 +14,7 @@ const CLIENT_SERVICES: Readonly> = { locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'register', 'bind'], sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'], slots: ['register', 'inject'], - theme: ['getTheme', 'setTheme', 'register', 'overrideTokens'], + theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'], uiWorkspace: [ 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory', 'createDirectory', 'openPath',