diff --git a/packages/app/e2e/regression/mobile-session.spec.ts b/packages/app/e2e/regression/mobile-session.spec.ts index 3a207cca732c..7af867b036ae 100644 --- a/packages/app/e2e/regression/mobile-session.spec.ts +++ b/packages/app/e2e/regression/mobile-session.spec.ts @@ -328,7 +328,7 @@ test.describe("touch", () => { await projects.click() const picker = page.getByRole("dialog", { name: "Projects", exact: true }) await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible() - await picker.getByRole("button", { name: fixture.project.name, exact: true }).click() + await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click() await expect(picker).toBeHidden() await expect(projects).toContainText(fixture.project.name) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index b07486d4f753..6e7c3d4df5cb 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -372,7 +372,6 @@ test("icon-only inactive tabs retain their avatar and attention badge instead of const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`) await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden() await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible() - await expect(tabB.locator('[data-slot="project-avatar-generated"]')).toBeVisible() await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible() await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px") await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px") diff --git a/packages/app/test-browser/project-avatar-hover.test.ts b/packages/app/test-browser/project-avatar-hover.test.ts index f964ff068bbb..089c2f77c4a5 100644 --- a/packages/app/test-browser/project-avatar-hover.test.ts +++ b/packages/app/test-browser/project-avatar-hover.test.ts @@ -25,6 +25,13 @@ Bun.plugin({ const { ProjectAvatar, ProjectIconStoreProvider, generateProjectIcon } = await import("@opencode/ui/project-avatar") +const { motionTuning } = await import("@opencode/ui/motion-tuning") + +// Generated avatars are opt-in under Knobs → Experiments. +motionTuning.list().forEach((knob) => { + if (knob.id === "projectAvatar.style") motionTuning.set(knob, "generated") +}) + const getContext = HTMLCanvasElement.prototype.getContext const requestFrame = window.requestAnimationFrame diff --git a/packages/ui/src/data-display/project-avatar/project-avatar.tsx b/packages/ui/src/data-display/project-avatar/project-avatar.tsx index 4163143ad575..97861b1979db 100644 --- a/packages/ui/src/data-display/project-avatar/project-avatar.tsx +++ b/packages/ui/src/data-display/project-avatar/project-avatar.tsx @@ -4,11 +4,13 @@ import { createEffect, createMemo, onCleanup, + createSignal, onMount, splitProps, Show, useContext, } from "solid-js" +import { motionTuning } from "../../motion-tuning" import { attachGeneratedAvatar } from "./animation" import { generateProjectIcon, @@ -19,6 +21,23 @@ import "./project-avatar.css" export { generateProjectIcon, GENERATED_PROJECT_ICON_STYLE, type GeneratedProjectIcon } from "./generated" +const avatarChoice = motionTuning.choice("projectAvatar.style", { + label: "Project avatars", + group: "Experiments", + value: "letters", + labels: { letters: "Letters", generated: "Generated" }, +}) + +const [avatarStyle, setAvatarStyle] = createSignal(avatarChoice()) + +// A live switch crossfades the avatars through a view transition; nothing else on the page changes. +motionTuning.subscribe(() => { + if (avatarChoice() === avatarStyle()) return + + if (typeof document === "undefined" || !document.startViewTransition) return setAvatarStyle(avatarChoice()) + document.startViewTransition(() => setAvatarStyle(avatarChoice())) +}) + const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) @@ -142,7 +161,8 @@ export function ProjectAvatar(props: ProjectAvatarProps) { "style", ]) - const generated = () => (split.src || split.variant === "outline" ? undefined : split.generated) + const generated = () => + split.src || split.variant === "outline" || avatarStyle() === "letters" ? undefined : split.generated return (