mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
fix(web): stabilize composer command editing and scrolling
This commit is contained in:
+2
-2
@@ -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-09-08-composer-menu-sections-and-localized-rows.md
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.md: c8fc2226a15828de14f623fc582473e72e3ec1f1
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 5905c872ab583294aabf15a1b69f2dfe1312e4ba
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.md: ab381d2e3ab62cc272a3984468163a657b717da1
|
||||
2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 8fe15119ac96ac7d1aac6206a7c423ef35d72fd2
|
||||
|
||||
+6
@@ -20,8 +20,14 @@ The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare
|
||||
|
||||
`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour.
|
||||
|
||||
The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation.
|
||||
|
||||
**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window.
|
||||
|
||||
**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim.
|
||||
|
||||
**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete.
|
||||
|
||||
+6
@@ -20,8 +20,14 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列
|
||||
|
||||
`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。
|
||||
|
||||
菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。
|
||||
|
||||
**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。
|
||||
|
||||
**在宿主侧本地化说明。** 宿主没有语言设置,目录又被所有客户端共享;客户端已经拥有其他所有界面的产品文案(客户端界面文案归语言所有的决策),相等性判断还能让作用域覆盖或第三方描述符原样保留。
|
||||
|
||||
**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。
|
||||
|
||||
@@ -133,6 +133,68 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record').each([
|
||||
{ locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' },
|
||||
{ locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' },
|
||||
{ locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' },
|
||||
{ locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' },
|
||||
])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => {
|
||||
const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale })
|
||||
const inputTripwire = watchConsole(inputPage)
|
||||
onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`))
|
||||
try {
|
||||
await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const input = inputPage.locator('[data-composer-input]').first()
|
||||
await writeComposerDraft(inputPage, input, '/')
|
||||
await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click()
|
||||
await expect.poll(() => input.textContent()).toBe(`${token} `)
|
||||
await input.press('End')
|
||||
await inputPage.keyboard.insertText('这是任务')
|
||||
await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
|
||||
for (let i = 0; i < 5; i++) await input.press('Backspace')
|
||||
const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent()
|
||||
await expect.poll(() => input.textContent()).toBe(token)
|
||||
await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
|
||||
await expect.poll(tokenText).toBe(token)
|
||||
await input.press('Space')
|
||||
await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
|
||||
await expect.poll(() => input.textContent()).toBe(`${token} `)
|
||||
await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token)
|
||||
const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content)
|
||||
await expect.poll(shownHint).toBe(JSON.stringify(hint))
|
||||
const cdp = await inputPage.context().newCDPSession(inputPage)
|
||||
await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
|
||||
await expect.poll(shownHint).toBe('none')
|
||||
await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 })
|
||||
await expect.poll(shownHint).toBe('none')
|
||||
await cdp.send('Input.insertText', { text: '这' })
|
||||
await expect.poll(() => input.textContent()).toBe(`${token} 这`)
|
||||
await expect.poll(shownHint).toBe('none')
|
||||
await input.press('Backspace')
|
||||
await expect.poll(shownHint).toBe(JSON.stringify(hint))
|
||||
await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
|
||||
await expect.poll(shownHint).toBe('none')
|
||||
await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
|
||||
await expect.poll(shownHint).toBe(JSON.stringify(hint))
|
||||
await input.press('Backspace')
|
||||
await input.press('Backspace')
|
||||
await expect.poll(() => input.getAttribute('data-phase')).toBe('plain')
|
||||
await writeComposerDraft(inputPage, input, '')
|
||||
const placeholder = inputPage.locator('[data-composer-placeholder]').first()
|
||||
await expect.poll(() => placeholder.isVisible()).toBe(true)
|
||||
await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
|
||||
await expect.poll(() => placeholder.isVisible()).toBe(false)
|
||||
await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
|
||||
await expect.poll(() => placeholder.isVisible()).toBe(true)
|
||||
await cdp.detach()
|
||||
expect(inputTripwire.pageErrors).toEqual([])
|
||||
expect(inputTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await inputPage.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
|
||||
const activeScaffold = await launchWebScaffold()
|
||||
const activePage = await newEnglishPage(browser)
|
||||
|
||||
@@ -83,7 +83,7 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
})
|
||||
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
|
||||
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
|
||||
.map(rule => rule.style.getPropertyValue('background'))
|
||||
.map(rule => rule.style.getPropertyValue('background-color'))
|
||||
const style = getComputedStyle(list)
|
||||
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
|
||||
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 4793373df94e2b7c617c1f44dd74d2b32bd86d6f
|
||||
README.zh.md: 463b9bff010fee7d29cad46828dc1caff8b22fff
|
||||
README.md: 6a953a0f099d5205da683d81a1390a05b5159209
|
||||
README.zh.md: 612a7ca72787a4199207ad5fd0ac6846024e1d6e
|
||||
|
||||
@@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell and standard props
|
||||
|
||||
Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
|
||||
|
||||
Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
|
||||
|
||||
The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
|
||||
@@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell 与标准 props
|
||||
|
||||
已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
|
||||
|
||||
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
|
||||
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface SubmitOutcome {
|
||||
export interface CommandClaim {
|
||||
/** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */
|
||||
readonly name: string
|
||||
/** The text the draft carries for the claim (`/name ` in English, a localized token elsewhere). */
|
||||
/** Inserted command text with its argument separator; the bare complete name also retains the claim. */
|
||||
readonly token: string
|
||||
readonly hint?: string
|
||||
readonly attachments?: boolean
|
||||
|
||||
@@ -34,8 +34,9 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () =
|
||||
if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
|
||||
return
|
||||
}
|
||||
const token = activeToken()
|
||||
const text = node.getTextContent()
|
||||
const active = activeToken()
|
||||
const token = text === active?.trimEnd() ? text : active
|
||||
if (token === null || !text.startsWith(token)) {
|
||||
if (node.getStyle() === TOKEN_STYLE) node.setStyle('')
|
||||
return
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* keydown AFTER compositionend, so a root-element composition watch holds the
|
||||
* guard for 10ms more (the old textarea's proven window); keyCode
|
||||
* 229 is the legacy signal engines emit without isComposing.
|
||||
* The root's composition attribute suppresses placeholders until both the
|
||||
* native composition and the editor's final text reconciliation finish.
|
||||
*/
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import {
|
||||
@@ -57,12 +59,20 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
||||
// root element and re-arms on root swaps.
|
||||
let composing = false
|
||||
let composingUntil = 0
|
||||
let rootElement: HTMLElement | null = null
|
||||
const syncComposition = (): void => {
|
||||
rootElement?.toggleAttribute('data-composer-composing', composing || editor.isComposing())
|
||||
}
|
||||
const onCompositionStart = (): void => {
|
||||
composing = true
|
||||
syncComposition()
|
||||
}
|
||||
const onCompositionEnd = (): void => {
|
||||
composing = false
|
||||
composingUntil = Date.now() + 10
|
||||
// The native event can precede the committed draft, including an empty
|
||||
// cancellation. The callback also runs when no document text changed.
|
||||
editor.update(() => {}, { onUpdate: syncComposition })
|
||||
}
|
||||
const recentlyComposing = (): boolean => composing || Date.now() < composingUntil
|
||||
|
||||
@@ -79,9 +89,15 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
||||
editor.registerRootListener((root, prevRoot) => {
|
||||
prevRoot?.removeEventListener('compositionstart', onCompositionStart)
|
||||
prevRoot?.removeEventListener('compositionend', onCompositionEnd)
|
||||
prevRoot?.removeAttribute('data-composer-composing')
|
||||
composing = false
|
||||
composingUntil = 0
|
||||
rootElement = root
|
||||
root?.addEventListener('compositionstart', onCompositionStart)
|
||||
root?.addEventListener('compositionend', onCompositionEnd)
|
||||
syncComposition()
|
||||
}),
|
||||
editor.registerUpdateListener(syncComposition),
|
||||
editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
|
||||
// Tab acts only when the trigger menu has a highlighted completion;
|
||||
|
||||
@@ -96,7 +96,7 @@ export function registerTextRefDecoration(
|
||||
const getMatch = (text: string): { start: number; end: number } | null => {
|
||||
const claim = activeToken()
|
||||
for (const range of scanTextRefs(text, lexiconOf())) {
|
||||
if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim) continue
|
||||
if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim.trimEnd()) continue
|
||||
return { start: range.start, end: range.end }
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -27,6 +27,11 @@ function argsAfter(draft: string, token: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** A claimed name may stand alone; arguments require the token's separator. */
|
||||
function retainsClaim(draft: string, token: string): boolean {
|
||||
return draft.startsWith(token) || draft === token.trimEnd()
|
||||
}
|
||||
|
||||
/** The submit-plane slice of the published InputState. */
|
||||
export interface SubmitSnapshot {
|
||||
readonly phase: InputState['phase']
|
||||
@@ -83,9 +88,9 @@ export class SubmitMachine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */
|
||||
/** The complete command name retains its claim with or without the argument separator. */
|
||||
private onDraftChanged(draft: string): readonly InputEffect[] {
|
||||
if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) {
|
||||
if (this.phase === 'claimed' && this.claim !== undefined && !retainsClaim(draft, this.claim.token)) {
|
||||
this.phase = 'plain'
|
||||
this.claim = undefined
|
||||
}
|
||||
@@ -200,7 +205,7 @@ export class SubmitMachine {
|
||||
}
|
||||
const text = ev.message ?? ev.outcome?.text
|
||||
if (ev.draft === flight.attempt.draftSnapshot
|
||||
&& this.claim !== undefined && ev.draft.startsWith(this.claim.token)) {
|
||||
&& this.claim !== undefined && retainsClaim(ev.draft, this.claim.token)) {
|
||||
this.phase = 'claimed'
|
||||
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
|
||||
}
|
||||
|
||||
@@ -195,6 +195,14 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.input[data-composer-composing] p:last-child::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.input[data-composer-composing] + .placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
|
||||
Insets mirror .input's pads (the other right 4px sits on .scroll). One
|
||||
ellipsized line: a wrapped second line would clip against the docked
|
||||
|
||||
@@ -1266,6 +1266,37 @@ describe('decorations', () => {
|
||||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
|
||||
})
|
||||
|
||||
it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => {
|
||||
const { shell, textarea, view } = bench()
|
||||
expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull()
|
||||
fireEvent.compositionStart(textarea)
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||||
act(() => { shell.setDraft('z') })
|
||||
act(() => { shell.setDraft('') })
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||||
fireEvent.compositionEnd(textarea, { data: '' })
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
|
||||
|
||||
act(() => {
|
||||
shell.setDraft('/目标 ')
|
||||
shell.beginCommand(
|
||||
{ name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
||||
{ start: 0, end: 4, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
})
|
||||
fireEvent.compositionStart(textarea)
|
||||
act(() => { shell.setDraft('/目标 z') })
|
||||
act(() => { shell.setDraft('/目标 ') })
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(true)
|
||||
fireEvent.compositionEnd(textarea, { data: '这' })
|
||||
act(() => { shell.setDraft('/目标 这') })
|
||||
await act(async () => {})
|
||||
expect(textarea.hasAttribute('data-composer-composing')).toBe(false)
|
||||
expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
|
||||
})
|
||||
|
||||
it('an inserted reference renders a real chip capsule with its icon and label', () => {
|
||||
const { view, shell } = bench()
|
||||
const reference = {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { $replaceDetectSpanWithText } from '../src/client/input/editor/span-map.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
@@ -306,6 +307,34 @@ describe('scenario: images ride an accepting command through the real pipeline',
|
||||
})
|
||||
|
||||
describe('scenario H: backspace breaks the token', () => {
|
||||
it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed when its arguments and separator are deleted', async (name) => {
|
||||
const { source } = commandSource([{ name, description: name, input: { hint: '目标内容' } }],
|
||||
() => Promise.resolve({ kind: 'success' }))
|
||||
const b = await scopedBench((triggers) => { triggers.registerSource(source) })
|
||||
b.type(`/${name}`)
|
||||
fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 })
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
b.type(`/${name} 这是目标`)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => {
|
||||
b.shell.editor.update(() => {
|
||||
const end = b.shell.snapshot.draft.length
|
||||
$replaceDetectSpanWithText({ start: end - 1, end }, '')
|
||||
}, { discrete: true })
|
||||
})
|
||||
}
|
||||
expect(b.shell.snapshot.draft).toBe(`/${name}`)
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name}`)
|
||||
b.type(`/${name} `)
|
||||
await act(async () => {})
|
||||
expect(b.shell.snapshot.phase).toBe('claimed')
|
||||
expect(b.shell.snapshot.draft).toBe(`/${name} `)
|
||||
expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name} `)
|
||||
b.type(`/${name}x`)
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('claim releases automatically; the enter after that goes through adjudication again', async () => {
|
||||
const b = await bench()
|
||||
b.type('/goal')
|
||||
|
||||
@@ -4,13 +4,46 @@
|
||||
* contenteditable reach the registered composer commands (the jsdom lane's
|
||||
* gesture entry, below the full component bench).
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { createEditor } from 'lexical'
|
||||
import { registerPlainText } from '@lexical/plain-text'
|
||||
import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts'
|
||||
|
||||
describe('keymap keydown routing', () => {
|
||||
it('clears composition presentation on root swaps and unregisters pending callbacks', async () => {
|
||||
const editor = createEditor({ namespace: 'composition-root', onError: (e) => { throw e } })
|
||||
const first = document.createElement('div')
|
||||
const second = document.createElement('div')
|
||||
document.body.append(first, second)
|
||||
onTestFinished(() => {
|
||||
editor.setRootElement(null)
|
||||
first.remove()
|
||||
second.remove()
|
||||
})
|
||||
editor.setRootElement(first)
|
||||
const unregister = registerComposerKeymap(editor, {
|
||||
arbitrate: () => 'pass', space: () => false, dismissPopup: () => {},
|
||||
canSubmit: () => false, submit: () => {}, intakeFiles: () => {}, pasteText: () => {},
|
||||
})
|
||||
onTestFinished(unregister)
|
||||
fireEvent.compositionStart(first)
|
||||
expect(first.hasAttribute('data-composer-composing')).toBe(true)
|
||||
editor.setRootElement(second)
|
||||
expect(first.hasAttribute('data-composer-composing')).toBe(false)
|
||||
expect(second.hasAttribute('data-composer-composing')).toBe(false)
|
||||
fireEvent.compositionStart(first)
|
||||
expect(first.hasAttribute('data-composer-composing')).toBe(false)
|
||||
fireEvent.compositionStart(second)
|
||||
expect(second.hasAttribute('data-composer-composing')).toBe(true)
|
||||
fireEvent.compositionEnd(second, { data: '' })
|
||||
unregister()
|
||||
await Promise.resolve()
|
||||
expect(second.hasAttribute('data-composer-composing')).toBe(false)
|
||||
fireEvent.compositionStart(second)
|
||||
expect(second.hasAttribute('data-composer-composing')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes Enter to the keymap submit handler', () => {
|
||||
const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } })
|
||||
const root = document.createElement('div')
|
||||
|
||||
@@ -429,9 +429,9 @@ describe('claim precedence over text-ref entities', () => {
|
||||
const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)'
|
||||
const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]])
|
||||
|
||||
it('keeps a claimed lexicon-listed token plain and warn-styled until release', () => {
|
||||
it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and warn-styled until release', (token) => {
|
||||
const editor = makeEditor()
|
||||
let claim: string | null = '/plan'
|
||||
let claim: string | null = token
|
||||
registerClaimDecoration(editor, () => claim)
|
||||
registerTextRefDecoration(editor, () => LEXICON, () => claim)
|
||||
editor.update(() => {
|
||||
@@ -449,7 +449,7 @@ describe('claim precedence over text-ref entities', () => {
|
||||
}
|
||||
})
|
||||
// Claimed: the entity transform yields the seat, the claim transform styles it.
|
||||
expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' })
|
||||
expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
|
||||
// Released (the shell's refresh nudges the seat dirty): the entity captures it.
|
||||
claim = null
|
||||
editor.update(() => {
|
||||
@@ -458,11 +458,11 @@ describe('claim precedence over text-ref entities', () => {
|
||||
}, { discrete: true })
|
||||
expect(leaf()).toEqual({ type: 'composer-text-ref', style: '', text: '/plan' })
|
||||
// Re-claimed: the entity reverts to plain text and the warn style returns.
|
||||
claim = '/plan'
|
||||
claim = token
|
||||
editor.update(() => {
|
||||
const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild()
|
||||
if ($isTextNode(first)) first.markDirty()
|
||||
}, { discrete: true })
|
||||
expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' })
|
||||
expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('submit-machine: claimed lifecycle', () => {
|
||||
expect(m.state.phase).toBe('submitting')
|
||||
})
|
||||
|
||||
it('breaking startsWith(token) auto-releases back to plain', () => {
|
||||
it('editing the command name releases back to plain', () => {
|
||||
const m = new SubmitMachine()
|
||||
m.dispatch({ type: 'claim', claim: claimOf('goal') })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
|
||||
@@ -202,6 +202,33 @@ describe('submit-machine: claimed lifecycle', () => {
|
||||
expect(m.state.claim).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '],
|
||||
['feedback', '/feedback '], ['feedback', '/反馈 '],
|
||||
])('retains %s as %s without its separator and submits an empty argument', (name, token) => {
|
||||
const m = new SubmitMachine()
|
||||
m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } })
|
||||
for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) {
|
||||
m.dispatch({ type: 'draft-changed', draft })
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
expect(m.state.claim?.name).toBe(name)
|
||||
}
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() })
|
||||
const begin = effectAt(fx, 0, 'begin-submit')
|
||||
expect(begin.args).toBe('')
|
||||
m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() })
|
||||
expect(m.state.phase).toBe('claimed')
|
||||
expect(m.state.claim?.name).toBe(name)
|
||||
})
|
||||
|
||||
it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => {
|
||||
const m = new SubmitMachine()
|
||||
m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
|
||||
m.dispatch({ type: 'draft-changed', draft })
|
||||
expect(m.state.phase).toBe('plain')
|
||||
expect(m.state.claim).toBeUndefined()
|
||||
})
|
||||
|
||||
it('explicit release returns to plain when nothing is in flight', () => {
|
||||
const m = new SubmitMachine()
|
||||
m.dispatch({ type: 'claim', claim: claimOf('goal') })
|
||||
|
||||
@@ -6,14 +6,20 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
/* Height cap: the 320px design maximum, clamped at runtime to the space
|
||||
* above the composer (inline max-height set in MenuView.tsx). */
|
||||
max-height: 320px;
|
||||
box-sizing: border-box;
|
||||
/* Fits the built-in rows without scrolling and still clamps at runtime to
|
||||
* the space above the composer (inline max-height set in MenuView.tsx). */
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
/* A 10px draggable rail carries a quiet 4px thumb, inset from the card's
|
||||
rounded ends. ui-theme's global scrollbar sheet renders these values. */
|
||||
--dsh-scrollbar-width: 10px;
|
||||
--dsh-scrollbar-thumb-border: 3px;
|
||||
--dsh-scrollbar-track-margin: 12px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -24,6 +30,19 @@
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
}
|
||||
|
||||
/* The hint covers content, not the scrollbar rail, and disappears once the
|
||||
viewport reaches the final row. */
|
||||
.menu[data-overflow-below]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-menu));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb
|
||||
* header pinned above the scrolling list.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -23,8 +23,8 @@ import type { MenuKey } from './locales.ts'
|
||||
/** Full menu props: injected face + the locale seat. */
|
||||
export type MenuViewProps = MenuViewInjected & PropsLocale<'slash.menu'>
|
||||
|
||||
/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */
|
||||
const MAX_HEIGHT = 320
|
||||
/** Height cap that fits the two headings and eight built-in command rows. */
|
||||
const MAX_HEIGHT = 400
|
||||
|
||||
/** DOM id of one option row (the aria-activedescendant target). */
|
||||
function optionId(source: string, index: number): string {
|
||||
@@ -46,10 +46,20 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
|
||||
() => headers.getSnapshot(),
|
||||
)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const [hasOverflowBelow, setHasOverflowBelow] = useState(false)
|
||||
// The list is bottom-anchored above the composer; clamp the design cap to
|
||||
// the space above it, re-measured on every store update (the anchor moves
|
||||
// when the composer grows).
|
||||
const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state)
|
||||
const updateOverflowHint = useCallback(() => {
|
||||
const viewport = viewportRef.current
|
||||
setHasOverflowBelow(viewport !== null
|
||||
&& viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1)
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
updateOverflowHint()
|
||||
}, [state, maxHeight, updateOverflowHint])
|
||||
const highlight = state.open ? state.highlight : null
|
||||
// Focus stays in the textarea (combobox pattern), so the browser never
|
||||
// scrolls the active option into view on keyboard moves — do it here.
|
||||
@@ -76,7 +86,13 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
|
||||
return (
|
||||
// The listbox role sits on the scrolling viewport, not this shell: a
|
||||
// breadcrumb header is not an option, and a listbox may not carry one.
|
||||
<div ref={listRef} className={css.menu} style={{ maxHeight }} data-trigger-menu="">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={css.menu}
|
||||
style={{ maxHeight }}
|
||||
data-trigger-menu=""
|
||||
data-overflow-below={hasOverflowBelow || undefined}
|
||||
>
|
||||
{state.groups.map((group) => {
|
||||
const trail = crumbs.get(group.source)
|
||||
return trail === undefined ? null : (
|
||||
@@ -103,10 +119,12 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
|
||||
)
|
||||
})}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={css.viewport}
|
||||
role="listbox"
|
||||
aria-label={t('suggestions.aria')}
|
||||
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
|
||||
onScroll={updateOverflowHint}
|
||||
>
|
||||
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
|
||||
? null
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('MenuView', () => {
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(menuShell().style.maxHeight).toBe('320px')
|
||||
expect(menuShell().style.maxHeight).toBe('400px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
@@ -251,12 +251,27 @@ describe('MenuView', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(menuShell().style.maxHeight).toBe('320px')
|
||||
expect(menuShell().style.maxHeight).toBe('400px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(menuShell().style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('shows the bottom overflow hint until the list reaches its final row', () => {
|
||||
mount(openState())
|
||||
const listbox = screen.getByRole('listbox')
|
||||
Object.defineProperties(listbox, {
|
||||
clientHeight: { configurable: true, value: 320 },
|
||||
scrollHeight: { configurable: true, value: 392 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true },
|
||||
})
|
||||
fireEvent.scroll(listbox)
|
||||
expect(menuShell().hasAttribute('data-overflow-below')).toBe(true)
|
||||
listbox.scrollTop = 72
|
||||
fireEvent.scroll(listbox)
|
||||
expect(menuShell().hasAttribute('data-overflow-below')).toBe(false)
|
||||
})
|
||||
|
||||
it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
fireEvent.pointerDown(document.body)
|
||||
|
||||
@@ -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: 7c5ba6541601bbe506a0707e3ebfd75a1f30ade9
|
||||
README.zh.md: 6c82a1be39fff0ff827d515e870775aa9571ad2c
|
||||
README.md: 291492b9b41df6570722fb2dbd14565c660ec350
|
||||
README.zh.md: 2883dd50ef6c70b2229b98c7434ca5317c5debe9
|
||||
|
||||
@@ -59,7 +59,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
### Scrollbar rebinding
|
||||
|
||||
`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align beside a space-consuming bar. The two rendering paths are mutually exclusive by construction: Firefox takes the standard properties inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path.
|
||||
`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). WebKit-based browsers also read `--dsh-scrollbar-width`, `--dsh-scrollbar-thumb-border`, and `--dsh-scrollbar-track-margin`; a scroll surface may rebind them to keep a wide draggable rail around a narrower visible thumb or to inset the track from rounded ends. The two rendering paths are mutually exclusive by construction: Firefox takes the standard thin scrollbar inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so geometry and hover customization apply only through the pseudo-element path.
|
||||
|
||||
### Preference persistence
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ kind: "package-reference"
|
||||
|
||||
### 滚动条重新绑定
|
||||
|
||||
`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准属性,WebKit 系引擎走伪元素,因此 hover token 只经由伪元素这条路径渲染。
|
||||
`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。WebKit 系浏览器还会读取 `--dsh-scrollbar-width`、`--dsh-scrollbar-thumb-border` 与 `--dsh-scrollbar-track-margin`;滚动表面可重新绑定它们,在较窄的可见滑块外保留较宽的拖动区域,或让轨道避开圆角两端。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准细滚动条,WebKit 系引擎走伪元素,因此几何与 hover 定制只经由伪元素路径生效。
|
||||
|
||||
### 偏好持久化
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
*
|
||||
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
|
||||
* the l1 pair here is the base-surface default, and an elevated surface
|
||||
* (menu, popover, dialog) rebinds to the l2 pair on its own container. Both
|
||||
* rendering paths below read the indirection, so one rebind reaches whichever
|
||||
* path the engine took. */
|
||||
* (menu, popover, dialog) rebinds to the l2 pair on its own container. A
|
||||
* scroll container may also rebind the three geometry variables below. */
|
||||
|
||||
body {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
|
||||
--dsh-scrollbar-thumb-border: 0px;
|
||||
--dsh-scrollbar-track-margin: 0px;
|
||||
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
|
||||
below. A surface that must align itself beside a space-consuming bar
|
||||
(ConversationRoot's overlay composer seat) reads this instead of
|
||||
@@ -64,23 +65,27 @@ body {
|
||||
selector matching already does. Not inherited either, hence the unscoped
|
||||
selectors. */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: var(--dsh-scrollbar-width);
|
||||
height: var(--dsh-scrollbar-width);
|
||||
}
|
||||
|
||||
/* Track stays transparent so the thumb reads against whatever surface scrolls
|
||||
under it; only the thumb carries a token colour. */
|
||||
::-webkit-scrollbar-track {
|
||||
margin-block: var(--dsh-scrollbar-track-margin);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
border: var(--dsh-scrollbar-thumb-border) solid transparent;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsh-scrollbar-thumb);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dsh-scrollbar-thumb-hover);
|
||||
background-color: var(--dsh-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Both scrollbars meeting in a corner: no separate token, so the corner
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
||||
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
||||
* scrollbar.css binds the base-surface pair through the rebindable
|
||||
* indirection, the width variable mirrors the ::-webkit-scrollbar rule for
|
||||
* consumers that align beside the bar, and elevated surfaces rebind that
|
||||
* indirection in complete pairs. The expected token set is scanned out of
|
||||
* indirection, the WebKit geometry reads the shared width, thumb-border, and
|
||||
* track-margin variables, and elevated surfaces rebind the colour indirection
|
||||
* in complete pairs. The expected token set is scanned out of
|
||||
* design-platform.css, so adding, renaming, or dropping a scrollbar token
|
||||
* moves these assertions with it.
|
||||
*/
|
||||
@@ -25,6 +25,11 @@ const DARK_ATTRIBUTE = '[data-ds-dark-theme]'
|
||||
const TOKEN_PREFIX = '--dsw-alias-scrollbar-'
|
||||
/** Prefix of the rebindable indirection scrollbar.css owns. */
|
||||
const INDIRECTION_PREFIX = '--dsh-scrollbar-'
|
||||
/** The elevation-aware colour variables surfaces rebind as one pair. */
|
||||
const COLOUR_INDIRECTIONS = new Set([
|
||||
`${INDIRECTION_PREFIX}thumb`,
|
||||
`${INDIRECTION_PREFIX}thumb-hover`,
|
||||
])
|
||||
/** The one non-token rebind value: a surface that draws no thumb at all. */
|
||||
const HIDDEN_THUMB = 'transparent'
|
||||
/** The elevation rebind, spelled per property: value-wholeness, not token shape. */
|
||||
@@ -68,6 +73,14 @@ const platformRules = parseRules(platformCss)
|
||||
const scrollbarRules = parseRules(scrollbarCss)
|
||||
const sorted = (names: Iterable<string>): string[] => [...names].sort()
|
||||
|
||||
/** Last value one selector declares for a property. */
|
||||
function declaration(rules: CssRule[], property: string, selectorPart: string): string | undefined {
|
||||
return rules
|
||||
.filter(rule => rule.selectors.includes(selectorPart))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([name]) => name === property)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollbar tokens defined by the rules whose selectors carry (or do not
|
||||
* carry) the dark palette attribute.
|
||||
@@ -175,7 +188,7 @@ for (const file of packageStylesheets()) {
|
||||
let rebindsElevation = false
|
||||
const ruleSurfaces: string[] = []
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) {
|
||||
if (COLOUR_INDIRECTIONS.has(property) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) {
|
||||
rebinds = true
|
||||
if (value !== HIDDEN_THUMB) rebindsElevation = true
|
||||
}
|
||||
@@ -248,48 +261,39 @@ describe('scrollbar.css base-surface binding', () => {
|
||||
it('routes the standard property and the WebKit thumb through the same indirection', () => {
|
||||
// A rebind on an elevated container has to move the Firefox and the WebKit
|
||||
// rendering together, which only holds while both read the same variable.
|
||||
const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules
|
||||
.filter(rule => rule.selectors.includes(selectorPart))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([name]) => name === property)?.[1]
|
||||
const thumbColor = declaration('scrollbar-color', 'body')
|
||||
const thumbColor = declaration(scrollbarRules, 'scrollbar-color', 'body')
|
||||
expect(thumbColor).toBeDefined()
|
||||
const indirection = varReferences(thumbColor!)[0]
|
||||
expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`)
|
||||
expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection])
|
||||
expect(varReferences(declaration(scrollbarRules, 'background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css width variable', () => {
|
||||
describe('scrollbar.css geometry variables', () => {
|
||||
const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width`
|
||||
const THUMB_BORDER_VARIABLE = `${INDIRECTION_PREFIX}thumb-border`
|
||||
const TRACK_MARGIN_VARIABLE = `${INDIRECTION_PREFIX}track-margin`
|
||||
const GEOMETRY_VARIABLES = [WIDTH_VARIABLE, THUMB_BORDER_VARIABLE, TRACK_MARGIN_VARIABLE]
|
||||
|
||||
it('defines the width variable on body as a static length', () => {
|
||||
// The overlay seat compensation reads a fixed number, not a second
|
||||
// indirection: the mirror check below compares the WebKit rule against
|
||||
// this value, so a var()-to-var() chain would compare one indirection to
|
||||
// another instead of pinning the number.
|
||||
const value = scrollbarRules
|
||||
it('defines each geometry variable on body as a static length', () => {
|
||||
const definitions = new Map(scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('body'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||
expect(value, WIDTH_VARIABLE).toBeDefined()
|
||||
expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/)
|
||||
.filter(([property]) => GEOMETRY_VARIABLES.includes(property)))
|
||||
for (const property of GEOMETRY_VARIABLES) {
|
||||
expect(definitions.get(property), property).toMatch(/^\d+(?:\.\d+)?px$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => {
|
||||
// The compensation stays aligned with the WebKit bar only while both read
|
||||
// the same number. A change to one side without the other puts the overlay
|
||||
// seat a band off from Chat on WebKit engines.
|
||||
const variableValue = scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('body'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||
it('routes WebKit scrollbar geometry through those variables', () => {
|
||||
const webkitWidth = scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('::-webkit-scrollbar'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === 'width')?.[1]
|
||||
expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined()
|
||||
expect(webkitWidth).toBe(variableValue)
|
||||
expect(varReferences(webkitWidth!)).toEqual([WIDTH_VARIABLE])
|
||||
expect(varReferences(declaration(scrollbarRules, 'border', '::-webkit-scrollbar-thumb')!)).toEqual([THUMB_BORDER_VARIABLE])
|
||||
expect(varReferences(declaration(scrollbarRules, 'margin-block', '::-webkit-scrollbar-track')!)).toEqual([TRACK_MARGIN_VARIABLE])
|
||||
})
|
||||
|
||||
it('every reader of the width variable outside ui-theme references a defined variable', () => {
|
||||
@@ -422,10 +426,8 @@ describe('elevated surface rebinds', () => {
|
||||
// A surface rebinding only the resting colour keeps the l1 hover colour,
|
||||
// so the elevation is wrong only while the pointer is over the thumb.
|
||||
for (const { file, rule } of rebindRules) {
|
||||
const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX))
|
||||
expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([
|
||||
`${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`,
|
||||
].sort())
|
||||
const properties = rule.declarations.map(([property]) => property).filter(property => COLOUR_INDIRECTIONS.has(property))
|
||||
expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual(sorted(COLOUR_INDIRECTIONS))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -459,7 +461,7 @@ describe('elevated surface rebinds', () => {
|
||||
// white)` and a crossed pair (the hover token bound to the resting
|
||||
// property); neither is what the contract says.
|
||||
for (const { file, rule } of rebindRules) {
|
||||
const rebinds = rule.declarations.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
|
||||
const rebinds = rule.declarations.filter(([property]) => COLOUR_INDIRECTIONS.has(property))
|
||||
const where = `${file} ${rule.selectors.join(', ')}`
|
||||
if (rebinds.every(([, value]) => value === HIDDEN_THUMB)) continue
|
||||
expect(rebinds.some(([, value]) => value === HIDDEN_THUMB), `${where}: mixes ${HIDDEN_THUMB} with an elevation`).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user