Jelajahi Sumber

fix(ui): hide sidebar resize pill

_Kerman 1 bulan lalu
induk
melakukan
c000bf3be8

+ 6 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.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/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

+ 25 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md

@@ -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.

+ 25 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md

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

+ 48 - 1
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -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)
 })

+ 13 - 0
apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md

@@ -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

+ 2 - 2
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: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
-README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a
+README.md: 250828764ecb2c620410fc59d84076f4117d7fc4
+README.zh.md: 17c45794ff7ce7ef90e7a233abcef82321a87407

+ 1 - 1
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 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.
 

+ 1 - 1
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 上的内联变量)。
+外壳插件:三栏 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 表层获取操作。
 

+ 9 - 11
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -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);
 }