polish(ui): trim the width handle and describe the font-size scope

Drop the width handle's double-click reset and tooltip — the handle is now
drag-only and a stored preference is only replaced by another drag — and
add a tertiary description line under the Settings font-size title stating
the size only affects conversation content. Update both Agent Notes and
the settings dialog goldens.
This commit is contained in:
yx.zhang 2026-08-25 18:00:56 +08:00
parent 9ecd18e986
commit 5720917ea7
15 changed files with 38 additions and 39 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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
2026-08-18-conversation-adaptive-content-width.md: 9b0e7afbded296b60b7c283030a537321b36684e
2026-08-18-conversation-adaptive-content-width.zh.md: f1f424cf9d4ac087518e9f79a365d0052069b999

View file

@ -16,7 +16,7 @@ The conversation column's shared width axis (`--dsh-chat-content-width`) was the
**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 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. The handle carries no reset affordance and no tooltip; a stored preference is only ever replaced by another drag.
**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.
@ -26,7 +26,7 @@ The conversation column's shared width axis (`--dsh-chat-content-width`) was the
**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 settings-backed "wide mode" toggle.** Adds a persistent settings surface for what drag 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.

View file

@ -16,7 +16,7 @@ Status: implemented
**拖拽手柄是正文两侧 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` 说明拖拽与双击用法。
**偏好持久化在 `localStorage`(`dsh.conversation.contentWidth`),钳制不改写。** 列收窄时显示宽度重新钳制到 `[640px, 列宽 − 176px]`(每侧预留 88px 保证手柄永远放得下),但存储的偏好保留——拉宽窗口自动恢复,与 AppFrame 侧栏拖拽同规则。手柄不带重置操作也不带 tooltip;已存储的偏好只会被下一次拖拽替换。
**用户气泡上限跟随宽度轴。** `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 缺省值覆盖会话列之外的挂载。
@ -26,7 +26,7 @@ Status: implemented
**宽内容出血(代码块、工具卡片突破散文列)。** 阅读工效最佳但涉及 MarkdownText 和所有工具卡片布局;作为可能的二期推迟。
**settings 支持的"宽屏模式"开关。** 为拖拽 + 双击已覆盖的能力增加持久设置面;不需要。
**settings 支持的"宽屏模式"开关。** 为拖拽已覆盖的能力增加持久设置面;不需要。
**输入卡旁 12px 手柄条。** 首版实现,实践中不可用:宽屏上千余像素的边距里只有一条细缝,且 sticky 输入卡遮挡它。改为锚定在光带位置的 40px 条。

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md
2026-08-18-settings-font-size-control.md: b8141047a8a024069094a6c3ea07e592914f2ec1
2026-08-18-settings-font-size-control.zh.md: b7ad80540d394de490748dbc03938d8c1721d4d6
2026-08-18-settings-font-size-control.md: 34ee7f28f58a0a5a5dc8cd39bc4cecf5e081359f
2026-08-18-settings-font-size-control.zh.md: 2c2ea714d50ca1f934d78ede6175935338488331

View file

@ -16,7 +16,7 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde
**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 — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder read `var(--dsh-content-font-size, 14px)` (or `calc(<own default> + var(--dsh-content-font-delta, 0px))` for smaller steps) and `calc(<default line-height> + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (reference summaries and their inline glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), and the flow chrome around them — the shared DisclosureRow header (tool calls, think, commands; row height, title, and leading box all move) with its expanded bodies' `22px + delta` indent keeping content aligned under the shifted title start, ToolRow/bash-row summaries and file links, think text (12px keeping its 2px step under the body), compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel (run/phase headers and expanded member rows), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. 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.
**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. A tertiary description line under the title states the scope — the size only affects conversation content, not the application chrome. 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

View file

@ -16,7 +16,7 @@
**一个 CSS 增量变量平移阶梯。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。表格、small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外的消费方直接读取 `var(--dsh-content-font-size, 14px)`(较小档位则用 `calc(<自身默认> + var(--dsh-content-font-delta, 0px))`)与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含引用摘要及其行内字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量),以及围绕它们的流内 chrome——共享的 DisclosureRow 头部(工具调用、think、命令;行高、标题与 leading 盒同步移动),其展开内容以 `22px + delta` 缩进跟随平移后的标题起点保持对齐、ToolRow/bash 行的 summary 与文件链接、think 正文(12px,保持比正文小 2px 的层级)、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板(run/phase 头部与展开的成员行)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。14px 回退让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。
**步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。
**步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。标题下方的三级说明行标明作用范围——字号仅影响会话内容,不影响应用外框。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。
## 已考虑的替代方案

View file

@ -39,7 +39,7 @@
- button "System" [pressed]:
- img
- text: System
- text: Font size 14
- text: Font size Only affects conversation content 14
- button "Increase font size":
- img
- button "Decrease font size":

View file

@ -39,7 +39,7 @@
- button "跟随系统" [pressed]:
- img
- text: 跟随系统
- text: 字号大小 14
- text: 字号大小 仅影响会话内容的字号 14
- button "增大字号":
- img
- button "减小字号":

View file

@ -19,7 +19,6 @@ export const zh = {
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'width.handle.title': '拖动调整正文宽度;双击恢复自适应',
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
@ -165,7 +164,6 @@ 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',

View file

@ -45,17 +45,15 @@ function resolveContentWidth(columnWidth: number, preference: number | null): nu
/** 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. */
* by 2× the pointer distance). 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)
@ -121,13 +119,11 @@ function WidthHandle(props: {
data-side={props.side}
data-width-handle={props.side}
data-dragging={dragging || undefined}
title={props.title}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onLostPointerCapture={onPointerCancel}
onDoubleClick={() => { callbacks.current.onReset() }}
/>
)
}
@ -229,11 +225,6 @@ export function ConversationRoot({
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
@ -389,12 +380,10 @@ export function ConversationRoot({
<WidthHandle
key={side}
side={side}
title={t('width.handle.title')}
onStart={onHandleStart}
onDrag={onHandleDrag}
onCommit={onHandleCommit}
onEnd={onHandleEnd}
onReset={onHandleReset}
/>
))}
</div>

View file

@ -590,7 +590,7 @@ describe('ConversationRoot resident composer', () => {
expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('')
})
it('drag → persist → window clamp → double-click reset round-trip on a width handle', () => {
it('drag → persist → window clamp 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 })
@ -622,16 +622,15 @@ describe('ConversationRoot resident composer', () => {
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.
// press/release rounds) 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.
// No reset affordance on the handle: double-click leaves the preference alone.
fireEvent.doubleClick(handle)
expect(localStorage.getItem('dsh.conversation.contentWidth')).toBeNull()
expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('')
expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970')
} finally {
for (const [name, descriptor] of originals) {
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)

View file

@ -26,6 +26,13 @@
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* 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. */

View file

@ -1,10 +1,11 @@
/**
* 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.
* title + body-text-only description + 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,
@ -37,6 +38,7 @@ export function FontSizeRow({ t, setFontSize, useStore }: FontSizeRowComponentPr
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('fontSize.title')}</div>
<div className={css.desc}>{t('fontSize.description')}</div>
</div>
<div className={css.control}>
<div className={css.stepper}>

View file

@ -7,6 +7,7 @@ export const zh = {
'appearance.dark': '深色',
'appearance.system': '跟随系统',
'fontSize.title': '字号大小',
'fontSize.description': '仅影响会话内容的字号',
'fontSize.unit': 'px',
'fontSize.increase': '增大字号',
'fontSize.decrease': '减小字号',
@ -22,6 +23,7 @@ export const en = {
'appearance.dark': 'Dark',
'appearance.system': 'System',
'fontSize.title': 'Font size',
'fontSize.description': 'Only affects conversation content',
'fontSize.unit': 'px',
'fontSize.increase': 'Increase font size',
'fontSize.decrease': 'Decrease font size',

View file

@ -15,6 +15,7 @@ afterEach(cleanup)
const COPY: Record<string, string> = {
'fontSize.title': 'Font size',
'fontSize.description': 'Only affects conversation content',
'fontSize.increase': 'Increase font size',
'fontSize.decrease': 'Decrease font size',
}
@ -61,6 +62,7 @@ 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('Only affects conversation content')).toBeDefined()
expect(screen.getByText('14')).toBeDefined()
expect(arrow('Increase font size').disabled).toBe(false)
expect(arrow('Decrease font size').disabled).toBe(false)