diff --git a/packages/app/e2e/models/model-picker-visual.spec.ts b/packages/app/e2e/models/model-picker-visual.spec.ts new file mode 100644 index 000000000..087c94e9d --- /dev/null +++ b/packages/app/e2e/models/model-picker-visual.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from "../fixtures" +import { openPalette } from "../actions" +import { promptSelector } from "../selectors" +import type { Page } from "@playwright/test" + +function rgb(value: string) { + const [r, g, b] = value.match(/\d+/g)?.slice(0, 3).map(Number) ?? [] + if (r === undefined || g === undefined || b === undefined) throw new Error(`Invalid color: ${value}`) + return [r, g, b] as const +} + +function luminance([r, g, b]: readonly [number, number, number]) { + const channel = (value: number) => { + const normalized = value / 255 + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +function contrast(a: readonly [number, number, number], b: readonly [number, number, number]) { + const lighter = Math.max(luminance(a), luminance(b)) + const darker = Math.min(luminance(a), luminance(b)) + return (lighter + 0.05) / (darker + 0.05) +} + +function cssColor(page: Page, variable: string) { + return page.evaluate((name) => { + const node = document.createElement("div") + node.style.color = `var(${name})` + document.body.appendChild(node) + const color = getComputedStyle(node).color + node.remove() + return color + }, variable) +} + +test("model picker hover and tooltip stay visible", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + + const command = page.locator('[data-slash-id="model.choose"]') + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const picker = page.getByRole("dialog") + await expect(picker).toBeVisible() + const hoverSurface = await cssColor(page, "--surface-sunken") + + const row = picker.locator('[data-component="list-item"]').first() + await expect(row).toBeVisible() + + const pickerBackground = await picker.evaluate((node) => getComputedStyle(node).backgroundColor) + await row.hover() + await expect(row).toHaveAttribute("data-active", "true") + + const rowBackground = await row.evaluate((node) => getComputedStyle(node).backgroundColor) + expect(rowBackground).toBe(hoverSurface) + expect(contrast(rgb(rowBackground), rgb(pickerBackground))).toBeGreaterThan(1.01) + + const tooltip = page.locator('[data-component="tooltip"]') + await expect(tooltip).toBeVisible() + + const tooltipBackground = await tooltip.evaluate((node) => { + const style = getComputedStyle(node) + return style.backgroundColor + }) + const textColors = await tooltip.locator("div").evaluateAll((nodes) => { + return nodes + .filter((node) => node.textContent?.trim()) + .map((node) => getComputedStyle(node).color) + }) + expect(textColors.length).toBeGreaterThan(0) + for (const textColor of textColors) { + expect(contrast(rgb(tooltipBackground), rgb(textColor))).toBeGreaterThanOrEqual(4.5) + } +}) + +test("prompt workspace and variant menus keep visible hover states", async ({ page, gotoSession }) => { + await gotoSession() + const hoverSurface = await cssColor(page, "--surface-sunken") + + const workspace = page.locator('[data-action="prompt-workspace"]') + await expect(workspace).toBeVisible() + await workspace.click() + + const workspaceMenu = page.getByRole("menu").filter({ hasText: /Workspace|工作区|项目/i }) + await expect(workspaceMenu).toBeVisible() + const workspaceItem = workspaceMenu.locator('[role="menuitemradio"], [role="menuitem"]').first() + await expect(workspaceItem).toBeVisible() + + const workspaceBackground = await workspaceMenu.evaluate((node) => getComputedStyle(node.parentElement ?? node).backgroundColor) + await workspaceItem.hover() + const workspaceItemBackground = await workspaceItem.evaluate((node) => getComputedStyle(node).backgroundColor) + expect(workspaceItemBackground).toBe(hoverSurface) + expect(contrast(rgb(workspaceItemBackground), rgb(workspaceBackground))).toBeGreaterThan(1.01) + + await page.keyboard.press("Escape") + await expect(workspaceMenu).toHaveCount(0) + + const variant = page.locator('[data-action="prompt-model-variant"]') + await expect(variant).toBeVisible() + await variant.click() + + const variantMenu = page.getByRole("menu").filter({ hasText: /Reasoning effort|思考强度/i }) + await expect(variantMenu).toBeVisible() + const variantItem = variantMenu.locator('[role="menuitemradio"]').first() + await expect(variantItem).toBeVisible() + + const variantBackground = await variantMenu.evaluate((node) => getComputedStyle(node.parentElement ?? node).backgroundColor) + await variantItem.hover() + const variantItemBackground = await variantItem.evaluate((node) => getComputedStyle(node).backgroundColor) + expect(variantItemBackground).toBe(hoverSurface) + expect(contrast(rgb(variantItemBackground), rgb(variantBackground))).toBeGreaterThan(1.01) +}) + +test("command palette search results keep visible hover states", async ({ page, gotoSession }) => { + await gotoSession() + const hoverSurface = await cssColor(page, "--surface-sunken") + + const palette = await openPalette(page) + await palette.getByRole("textbox").fill("open") + + const row = palette.locator('[data-component="list-item"]').first() + await expect(row).toBeVisible() + + const paletteBackground = await palette.evaluate((node) => getComputedStyle(node).backgroundColor) + await row.hover() + await expect(row).toHaveAttribute("data-active", "true") + + const rowBackground = await row.evaluate((node) => getComputedStyle(node).backgroundColor) + expect(rowBackground).toBe(hoverSurface) + expect(contrast(rgb(rowBackground), rgb(paletteBackground))).toBeGreaterThan(1.01) +}) diff --git a/packages/app/src/components/model-picker-hotfix.test.ts b/packages/app/src/components/model-picker-hotfix.test.ts new file mode 100644 index 000000000..af156fa00 --- /dev/null +++ b/packages/app/src/components/model-picker-hotfix.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" + +const root = new URL("../../../../", import.meta.url) +const read = async (path: string) => Bun.file(new URL(path, root)).text() + +describe("model picker visual regression guard", () => { + test("model tooltip content inherits the tooltip text color", async () => { + const source = await read("packages/app/src/components/model-tooltip.tsx") + + expect(source).not.toContain("text-fg-on-brand") + }) + + test("model list active row uses a visible hover surface", async () => { + const source = await read("packages/ui/src/components/list.css") + + expect(source).toContain('&[data-active="true"]') + expect(source).toMatch(/&\[data-active="true"\]\s*\{[\s\S]*?background:\s*var\(--surface-sunken\)/) + expect(source).not.toMatch(/&\[data-active="true"\]\s*\{[\s\S]*?background:\s*var\(--surface-raised\)/) + }) + + test("prompt workspace and variant menu rows use a visible hover surface", async () => { + const workspace = await read("packages/app/src/components/prompt-input/workspace-chip.tsx") + const promptInput = await read("packages/app/src/components/prompt-input.tsx") + const select = await read("packages/ui/src/components/select.css") + + expect(workspace).not.toContain("hover:bg-surface-raised") + expect(workspace).not.toContain("focus-visible:bg-surface-raised") + expect(promptInput).not.toContain("hover:bg-surface-raised focus-visible:bg-surface-raised") + expect(select).not.toMatch(/&\[data-highlighted\]\s*\{[\s\S]*?background:\s*var\(--surface-raised\)/) + expect(select).not.toMatch(/&:hover\s*\{[\s\S]*?background:\s*var\(--surface-raised\)/) + }) +}) diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx index 3b3eb8573..1d2a5493a 100644 --- a/packages/app/src/components/model-tooltip.tsx +++ b/packages/app/src/components/model-tooltip.tsx @@ -79,13 +79,11 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
{title()}
{(value) => ( -
- {language.t("model.tooltip.allows", { inputs: value() })} -
+
{language.t("model.tooltip.allows", { inputs: value() })}
)}
-
{reasoning()}
-
{context()}
+
{reasoning()}
+
{context()}
) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3e1c3ad59..2b5913b52 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1263,7 +1263,7 @@ export const PromptInput: Component = (props) => { type="button" role="menuitemradio" aria-checked={active()} - class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-13-regular text-fg-strong outline-none hover:bg-surface-raised focus-visible:bg-surface-raised" + class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-13-regular text-fg-strong outline-none hover:bg-surface-sunken focus-visible:bg-surface-sunken" onClick={() => { if (!actionReady()) return local.model.variant.set(variant === "default" ? undefined : variant) diff --git a/packages/app/src/components/prompt-input/workspace-chip.tsx b/packages/app/src/components/prompt-input/workspace-chip.tsx index 357d5eed5..aa1400172 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.tsx +++ b/packages/app/src/components/prompt-input/workspace-chip.tsx @@ -79,7 +79,7 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {} type="button" role="menuitemradio" aria-checked={active()} - class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-13-regular outline-none hover:bg-surface-raised focus-visible:bg-surface-raised" + class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-13-regular outline-none hover:bg-surface-sunken focus-visible:bg-surface-sunken" onClick={() => { navigate(`/${base64Encode(workspace.path)}/session`) setOpen(false) @@ -102,7 +102,7 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {} type="button" role="menuitem" data-action="workspace-chip-add" - class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-13-regular text-fg-base outline-none hover:bg-surface-raised focus-visible:bg-surface-raised" + class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-13-regular text-fg-base outline-none hover:bg-surface-sunken focus-visible:bg-surface-sunken" onClick={() => { setOpen(false) layoutPage.openProject() diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index a04f35ecf..f86091d77 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -288,7 +288,7 @@ &[data-active="true"] { border-radius: var(--radius-md); - background: var(--surface-raised); + background: var(--surface-sunken); [data-slot="list-item-active-icon"] { display: inline-flex; } @@ -298,7 +298,7 @@ } } &:active { - background: var(--surface-base); + background: var(--surface-base-active); } &:focus-visible { outline: none; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index cc5fc0ce5..dc6eff04c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -337,6 +337,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {(item, i) => { const node = (