Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions packages/app/e2e/models/model-picker-visual.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
32 changes: 32 additions & 0 deletions packages/app/src/components/model-picker-hotfix.test.ts
Original file line number Diff line number Diff line change
@@ -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\)/)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

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\)/)
})
})
8 changes: 3 additions & 5 deletions packages/app/src/components/model-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,11 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
<div class="text-13-medium">{title()}</div>
<Show when={inputs()}>
{(value) => (
<div class="text-13-regular text-fg-on-brand">
{language.t("model.tooltip.allows", { inputs: value() })}
</div>
<div class="text-13-regular">{language.t("model.tooltip.allows", { inputs: value() })}</div>
)}
</Show>
<div class="text-13-regular text-fg-on-brand">{reasoning()}</div>
<div class="text-13-regular text-fg-on-brand">{context()}</div>
<div class="text-13-regular">{reasoning()}</div>
<div class="text-13-regular">{context()}</div>
</div>
)
}
2 changes: 1 addition & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ export const PromptInput: Component<PromptInputProps> = (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)
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/prompt-input/workspace-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/list.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -298,7 +298,7 @@
}
}
&:active {
background: var(--surface-base);
background: var(--surface-base-active);
}
&:focus-visible {
outline: none;
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
{(item, i) => {
const node = (
<button
data-component="list-item"
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/select.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
user-select: none;

&[data-highlighted] {
background: var(--surface-raised);
background: var(--surface-sunken);
}
&[data-disabled] {
background-color: var(--surface-raised);
Expand All @@ -165,7 +165,7 @@
outline: none;
}
&:hover {
background: var(--surface-raised);
background: var(--surface-sunken);
}
}
}
Expand Down
Loading