fix(ui): hide sidebar resize pill

This commit is contained in:
_Kerman
2026-07-30 17:43:45 +08:00
parent 287289ddbc
commit c000bf3be8
9 changed files with 130 additions and 16 deletions

View File

@@ -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/simplification/2026-07-30-sidebar-resize-without-visible-pill.md
2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e
2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c

View File

@@ -0,0 +1,25 @@
# Agent Note: Sidebar resize without a visible pill
Status: implemented
English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md)
## Problem
The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful.
## Decision
AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill.
The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live.
## Alternatives considered
**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction.
**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary.
## Consequences
The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill.

View File

@@ -0,0 +1,25 @@
# Agent Note: 侧边栏缩放不显示胶囊
Status: implemented
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
## 问题
AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
## 决策
AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
## 曾考虑的替代方案
**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
## 后果
侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。

View File

@@ -2,14 +2,18 @@
// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -23,11 +27,41 @@ async function detailsTrack(page: Page): Promise<number> {
})
}
/** First AppFrame grid track in CSS pixels. */
async function sidebarTrack(page: Page): Promise<number> {
return await appFrame(page).evaluate((element) => {
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
return Number.parseFloat(tracks[0] ?? 'NaN')
})
}
/** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
/** Render the two boundary affordances without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise<string> {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({
side: element.getAttribute('data-side'),
cursor: getComputedStyle(element).cursor,
pillGenerated: getComputedStyle(element, '::after').content !== 'none',
})))
return [
'# AppFrame drag handles',
'',
...handles.flatMap(handle => [
`## ${handle.side}`,
'',
'- hit strip present: true',
`- cursor: ${handle.cursor}`,
`- pill generated: ${String(handle.pillGenerated)}`,
'',
]),
].join('\n').trimEnd()
}
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -63,6 +97,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
const sidebarBefore = await sidebarTrack(page)
const sidebarHandle = page.locator('[data-side="sidebar"]')
const sidebarBox = await sidebarHandle.boundingBox()
expect(sidebarBox).not.toBeNull()
const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
await page.mouse.move(dragStartX, sidebarBox!.y + 200)
await page.mouse.down()
await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
await page.mouse.up()
await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
@@ -91,5 +137,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
}, 90_000)
})

View File

@@ -0,0 +1,13 @@
# AppFrame drag handles
## sidebar
- hit strip present: true
- cursor: col-resize
- pill generated: false
## details
- hit strip present: true
- cursor: col-resize
- pill generated: true

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 packages/client/ui-layout/README.md
README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a
README.md: 250828764ecb2c620410fc59d84076f4117d7fc4
README.zh.md: 17c45794ff7ce7ef90e7a233abcef82321a87407

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
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, plus the theme's alias tokens as inline variables on body).
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states 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 opens at the 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.

View File

@@ -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 上的内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话以默认宽度打开返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。

View File

@@ -49,9 +49,8 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. Details
adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
.handle {
position: absolute;
top: 0;
@@ -76,7 +75,7 @@
}
}
.handle::after {
.handle[data-side='details']::after {
content: '';
position: absolute;
top: 50%;
@@ -88,23 +87,22 @@
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
/* Hover affordance: the details pill hides until the pointer is over its
column, the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}