feat(app): switch project avatars between letters and generated from Knobs (#57930)

This commit is contained in:
Kit Langton
2026-10-21 15:13:03 -07:00
committed by GitHub
Unverified
parent 565a2e3a33
commit 0fccb330e1
4 changed files with 29 additions and 3 deletions
@@ -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)
-1
View File
@@ -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")
@@ -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
@@ -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 (
<div