mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
## Summary Follow-up to #5644. Cmd +/- had become a text-only zoom: type scaled while rem-based padding, gaps, widths, avatars, and controls stayed frozen, which produced cramped layouts (see [#buzz-frontend thread](buzz://message?channel=a410ffde-c61f-416a-96e0-c296b5f5ecc9&id=1a758115cf07b00c097f6e988553908c045165325a57637519cfa7ed9c9accec)). Root cause: #5644 introduced a virtual typography rem so the **Font size** preference could change text without moving layout — a good decoupling — but it also routed **Cmd +/- zoom** through that same px-valued token and pinned the real root at 16px. One decision ("freeze layout") was applied to two dials that shouldn't share it. This PR gives each dial one owner and lets CSS compose them: | Control | Changes | How | |---|---|---| | **Cmd +/- zoom** | Everything — true zoom | Scales the real `<html>` font-size again (`useWebviewZoomShortcuts`) | | **Font size preference** | Text only | Sets `data-font-size`; `typography.css` maps it to a unitless `--buzz-type-scale`, mirroring how density already works | `--buzz-type-rem` becomes `calc(1rem * var(--buzz-type-scale))` — rem-relative, so it rides on zoom automatically. Resulting text px = `16 × zoom × scale × token-ratio`. The 13 / 14 / 15px conversation contract is unchanged at default zoom. Density and the type ramp from #5644 are untouched. The preference module no longer does px math or knows about zoom; the zoom hook no longer imports the preference module. Net deletion in production code. ## Validation - `pnpm test` — 5,308 desktop unit tests - `pnpm check:px-text`, `tsc --noEmit`, biome - Playwright: `top-chrome-zoom-clearance.spec.ts` (native-chrome clearance stays fixed under root zoom), `inbox-refactor-screenshots.spec.ts` (zoomed row padding now asserts `4.4px` instead of the frozen `4px`), and both `profile.spec.ts` zoom tests (composed zoom × preference, cross-window storage reset) - Before/after screenshots at 140% zoom in the comment below --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1880 lines
68 KiB
TypeScript
1880 lines
68 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
const SHOTS = "test-results/buzz-theme";
|
|
const THEME_STORAGE_KEY = "buzz-theme";
|
|
const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background";
|
|
const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity";
|
|
const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab";
|
|
const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity";
|
|
const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
|
|
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
|
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
|
|
/**
|
|
* Seed the active theme into localStorage BEFORE the mock bridge installs so
|
|
* ThemeProvider reads it on first mount (init scripts run in registration
|
|
* order; React reads state on mount, which the bridge triggers).
|
|
*/
|
|
async function seedTheme(page: Page, theme: string) {
|
|
await page.addInitScript(
|
|
({ key, value }) => {
|
|
window.localStorage.setItem(key, value);
|
|
},
|
|
{ key: THEME_STORAGE_KEY, value: theme },
|
|
);
|
|
}
|
|
|
|
async function seedIconChannelSection(page: Page) {
|
|
await page.addInitScript(
|
|
({ channelId, pubkey }) => {
|
|
window.localStorage.setItem(
|
|
`buzz-channel-sections.v1:${pubkey}`,
|
|
JSON.stringify({
|
|
version: 1,
|
|
sections: [
|
|
{
|
|
id: "alignment-section",
|
|
name: "Team channels",
|
|
icon: "📌",
|
|
order: 0,
|
|
},
|
|
],
|
|
assignments: { [channelId]: "alignment-section" },
|
|
}),
|
|
);
|
|
},
|
|
{ channelId: GENERAL_CHANNEL_ID, pubkey: MOCK_PUBKEY },
|
|
);
|
|
}
|
|
|
|
async function openChannel(page: Page) {
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
|
}
|
|
|
|
async function expectBuzzSidebarPalette(page: Page, mode: "light" | "dark") {
|
|
const mutedColor =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
|
|
const searchSurface =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
|
|
const rowHoverSurface =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.04)" : "rgba(255, 255, 255, 0.04)";
|
|
const activeSurface =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.07)" : "rgba(255, 255, 255, 0.16)";
|
|
const chromeColor =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.5)";
|
|
const search = page.getByTestId("open-search");
|
|
const pinnedHeader = page.getByTestId("sidebar-pinned-header");
|
|
const sidebarScroller = page.locator(".buzz-sidebar-scrollbar");
|
|
const scrollContent = page.getByTestId("sidebar-scroll-content");
|
|
const primaryMenu = page.getByTestId("sidebar-primary-menu");
|
|
const sectionLabel = page
|
|
.locator('[data-sidebar="group-label"]')
|
|
.filter({ hasText: "Channels" })
|
|
.first();
|
|
|
|
await expect(sectionLabel).toHaveCSS("color", mutedColor);
|
|
await expect(search).toHaveCSS("background-color", searchSurface);
|
|
await expect(search.locator("svg").first()).toHaveCSS("color", mutedColor);
|
|
await expect(search.locator("span").first()).toHaveCSS("color", mutedColor);
|
|
await expect(pinnedHeader).toHaveCSS("padding-bottom", "8px");
|
|
await expect(pinnedHeader).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
await expect(pinnedHeader).toHaveCSS("margin-left", "3px");
|
|
await expect(pinnedHeader).toHaveCSS("margin-right", "3px");
|
|
await expect(pinnedHeader).toHaveCSS("padding-right", "8px");
|
|
await expect(sidebarScroller).toHaveCSS("padding-left", "0px");
|
|
await expect(sidebarScroller).toHaveCSS("padding-right", "0px");
|
|
await expect(scrollContent).toHaveCSS("padding-left", "3px");
|
|
await expect(scrollContent).toHaveCSS("padding-right", "3px");
|
|
const pinnedSpacerColor = await pinnedHeader.evaluate(
|
|
(element) => getComputedStyle(element, "::before").backgroundColor,
|
|
);
|
|
expect(pinnedSpacerColor).toBe("rgba(0, 0, 0, 0)");
|
|
await expect(sidebarScroller.getByTestId("open-agents-view")).toBeVisible();
|
|
const searchBox = await search.boundingBox();
|
|
const pinnedHeaderBox = await pinnedHeader.boundingBox();
|
|
const primaryMenuBox = await primaryMenu.boundingBox();
|
|
const primaryRowBox = await page
|
|
.getByTestId("open-agents-view")
|
|
.boundingBox();
|
|
const activeRowBox = await page.getByTestId("channel-general").boundingBox();
|
|
const hoverRowBox = await page.getByTestId("channel-random").boundingBox();
|
|
const scrollContentBox = await scrollContent.evaluate((element) => {
|
|
const box = element.getBoundingClientRect();
|
|
return { left: box.left, right: box.right };
|
|
});
|
|
expect(searchBox).not.toBeNull();
|
|
expect(pinnedHeaderBox).not.toBeNull();
|
|
expect(primaryMenuBox).not.toBeNull();
|
|
expect(primaryRowBox).not.toBeNull();
|
|
expect(activeRowBox).not.toBeNull();
|
|
expect(hoverRowBox).not.toBeNull();
|
|
if (
|
|
!searchBox ||
|
|
!pinnedHeaderBox ||
|
|
!primaryMenuBox ||
|
|
!primaryRowBox ||
|
|
!activeRowBox ||
|
|
!hoverRowBox
|
|
) {
|
|
throw new Error("Sidebar search or primary navigation geometry is missing");
|
|
}
|
|
expect(primaryMenuBox.y - (searchBox.y + searchBox.height)).toBe(8);
|
|
expect(
|
|
pinnedHeaderBox.y +
|
|
pinnedHeaderBox.height -
|
|
(searchBox.y + searchBox.height),
|
|
).toBe(8);
|
|
expect(primaryMenuBox.y - (pinnedHeaderBox.y + pinnedHeaderBox.height)).toBe(
|
|
0,
|
|
);
|
|
for (const rowBox of [primaryRowBox, activeRowBox, hoverRowBox]) {
|
|
expect(Math.abs(rowBox.x - searchBox.x)).toBeLessThanOrEqual(0.5);
|
|
// Linux CI reserves a classic scrollbar gutter while macOS uses an
|
|
// overlay scrollbar. Compare each row to its usable scroll area so the
|
|
// alignment check remains platform-independent.
|
|
const rowLeftSpacing = rowBox.x - scrollContentBox.left;
|
|
const rowRightSpacing = scrollContentBox.right - (rowBox.x + rowBox.width);
|
|
expect(Math.abs(rowLeftSpacing - rowRightSpacing)).toBeLessThanOrEqual(0.5);
|
|
}
|
|
await expect(page.locator("[data-buzz-sidebar-secondary]").first()).toHaveCSS(
|
|
"color",
|
|
mutedColor,
|
|
);
|
|
await expect(page.locator('[data-sidebar="trigger"]')).toHaveCSS(
|
|
"color",
|
|
chromeColor,
|
|
);
|
|
await expect(page.getByTestId("global-back")).toHaveCSS("color", chromeColor);
|
|
await expect(page.getByTestId("global-forward")).toHaveCSS(
|
|
"color",
|
|
chromeColor,
|
|
);
|
|
await expect(page.getByTestId("channel-general")).not.toHaveCSS(
|
|
"color",
|
|
mutedColor,
|
|
);
|
|
await expect(page.getByTestId("channel-general")).toHaveCSS(
|
|
"font-weight",
|
|
"400",
|
|
);
|
|
await expect(
|
|
page.getByTestId("channel-general").locator("[data-sidebar-row-label]"),
|
|
).toHaveCSS("opacity", "1");
|
|
await page.mouse.move(600, 100);
|
|
await expect(page.getByTestId("channel-general")).toHaveCSS(
|
|
"background-color",
|
|
activeSurface,
|
|
);
|
|
const hoverChannel = page.getByTestId("channel-random");
|
|
await hoverChannel.hover();
|
|
await expect(hoverChannel).toHaveCSS("background-color", rowHoverSurface);
|
|
|
|
const firstDmItem = page
|
|
.getByTestId("dm-list")
|
|
.locator('[data-sidebar="menu-item"]')
|
|
.first();
|
|
const firstDmButton = firstDmItem.locator('[data-sidebar="menu-button"]');
|
|
const firstDmLabel = firstDmButton.locator("[data-sidebar-row-label]");
|
|
const closeDmButton = firstDmItem.getByRole("button", {
|
|
name: "Close direct message",
|
|
});
|
|
const hoverChannelLabel = hoverChannel.locator("[data-sidebar-row-label]");
|
|
const hoverChannelIcon = hoverChannel.locator("svg").first();
|
|
const agentsButton = page.getByTestId("open-agents-view");
|
|
const agentsLabel = agentsButton.locator('[data-sidebar="menu-label"]');
|
|
const agentsIcon = agentsButton.locator("svg").first();
|
|
const sidebarForeground = await page
|
|
.getByTestId("app-sidebar")
|
|
.evaluate((element) => getComputedStyle(element).color);
|
|
await expect(hoverChannel).toHaveCSS("color", sidebarForeground);
|
|
await expect(firstDmButton).toHaveCSS("color", sidebarForeground);
|
|
await expect(agentsButton).toHaveCSS("color", sidebarForeground);
|
|
await expect(hoverChannelLabel).toHaveCSS("opacity", "0.8");
|
|
await expect(hoverChannelIcon).toHaveCSS("opacity", "0.8");
|
|
await expect(firstDmButton).toHaveCSS("opacity", "1");
|
|
await expect(firstDmLabel).toHaveCSS("opacity", "0.8");
|
|
await expect(agentsLabel).toHaveCSS("opacity", "0.8");
|
|
await expect(agentsIcon).toHaveCSS("opacity", "0.8");
|
|
await firstDmItem.hover();
|
|
await expect(closeDmButton).toBeVisible();
|
|
await closeDmButton.hover();
|
|
await expect(firstDmButton).toHaveCSS("background-color", rowHoverSurface);
|
|
|
|
const scrollbarThumbColor = await sidebarScroller.evaluate(
|
|
(element) =>
|
|
getComputedStyle(element, "::-webkit-scrollbar-thumb").backgroundColor,
|
|
);
|
|
expect(scrollbarThumbColor).toBe(searchSurface);
|
|
}
|
|
|
|
async function expectIconlessSectionTitleAligned(
|
|
page: Page,
|
|
listTestId: "stream-list" | "dm-list",
|
|
) {
|
|
const titleBox = await page
|
|
.getByTestId(`${listTestId}-section-label`)
|
|
.locator("[data-sidebar-section-title]")
|
|
.boundingBox();
|
|
const firstRowIconX = await page
|
|
.getByTestId(listTestId)
|
|
.locator('[data-sidebar="menu-button"]')
|
|
.first()
|
|
.evaluate((element) => {
|
|
const box = element.getBoundingClientRect();
|
|
const paddingLeft = Number.parseFloat(
|
|
getComputedStyle(element).paddingLeft,
|
|
);
|
|
return box.x + paddingLeft;
|
|
});
|
|
|
|
expect(titleBox).not.toBeNull();
|
|
if (!titleBox) {
|
|
throw new Error(`Sidebar section ${listTestId} is missing label geometry`);
|
|
}
|
|
expect(Math.abs(titleBox.x - firstRowIconX)).toBeLessThanOrEqual(0.5);
|
|
}
|
|
|
|
async function expectBuzzContentShadow(page: Page, mode: "light" | "dark") {
|
|
const effects = await page.evaluate(() => {
|
|
const shell = document.querySelector(".buzz-huddle-shell");
|
|
const content = document.querySelector("[data-buzz-content-surface]");
|
|
const shadowViewport = document.querySelector(
|
|
"[data-buzz-shadow-viewport]",
|
|
);
|
|
return {
|
|
appStroke: shell ? getComputedStyle(shell, "::before").boxShadow : "",
|
|
contentShadow: content ? getComputedStyle(content).boxShadow : "",
|
|
shadowViewportOverflow: shadowViewport
|
|
? getComputedStyle(shadowViewport).overflow
|
|
: "",
|
|
};
|
|
});
|
|
|
|
expect(effects.appStroke).toBe("none");
|
|
if (mode === "light") {
|
|
expect(effects.contentShadow).toContain("4px");
|
|
expect(effects.contentShadow).toContain("rgba(0, 0, 0, 0.07)");
|
|
expect(effects.shadowViewportOverflow).toBe("visible");
|
|
} else {
|
|
expect(effects.contentShadow).not.toContain("4px");
|
|
expect(effects.contentShadow).not.toContain("rgba(255, 255, 255, 0.07)");
|
|
expect(effects.shadowViewportOverflow).toBe("hidden");
|
|
}
|
|
}
|
|
|
|
async function expectBuzzGradientPaint(
|
|
page: Page,
|
|
mode: "light" | "dark",
|
|
): Promise<string> {
|
|
const paint = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const appSurface = document.querySelector(".buzz-huddle-app-surface");
|
|
const lightLayer = document.querySelector('[data-buzz-gradient="light"]');
|
|
const darkLayer = document.querySelector('[data-buzz-gradient="dark"]');
|
|
const sidebarRoot = document.querySelector(
|
|
'[data-testid="app-sidebar"], [data-testid="settings-sidebar"]',
|
|
);
|
|
const sidebarSurface =
|
|
sidebarRoot?.querySelector('[data-sidebar="sidebar"]') ?? sidebarRoot;
|
|
const appStyles = appSurface ? getComputedStyle(appSurface) : null;
|
|
const lightStyles = lightLayer ? getComputedStyle(lightLayer) : null;
|
|
const darkStyles = darkLayer ? getComputedStyle(darkLayer) : null;
|
|
return {
|
|
isDark: root.classList.contains("dark"),
|
|
theme: root.getAttribute("data-buzz-theme"),
|
|
surfaceImage: appStyles?.backgroundImage ?? "",
|
|
lightImage: lightStyles?.backgroundImage ?? "",
|
|
lightOpacity: lightStyles?.opacity ?? "",
|
|
darkImage: darkStyles?.backgroundImage ?? "",
|
|
darkOpacity: darkStyles?.opacity ?? "",
|
|
sidebarImage: sidebarSurface
|
|
? getComputedStyle(sidebarSurface).backgroundImage
|
|
: "",
|
|
};
|
|
});
|
|
|
|
expect(paint.theme).toBe(mode === "light" ? "buzz" : "buzz-dark");
|
|
expect(paint.isDark).toBe(mode === "dark");
|
|
expect(paint.surfaceImage).toBe("none");
|
|
expect(paint.lightImage).not.toBe("");
|
|
expect(paint.lightImage).not.toBe("none");
|
|
expect(paint.darkImage).not.toBe("");
|
|
expect(paint.darkImage).not.toBe("none");
|
|
expect(paint.lightImage).not.toBe(paint.darkImage);
|
|
expect(paint.lightOpacity).toBe(mode === "light" ? "1" : "0");
|
|
expect(paint.darkOpacity).toBe(mode === "dark" ? "1" : "0");
|
|
expect(paint.sidebarImage).toBe("none");
|
|
return mode === "light" ? paint.lightImage : paint.darkImage;
|
|
}
|
|
|
|
async function expectBuzzSettingsPalette(page: Page, mode: "light" | "dark") {
|
|
const mutedColor =
|
|
mode === "light" ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.4)";
|
|
const sidebar = page.getByTestId("settings-sidebar");
|
|
const sectionLabel = sidebar
|
|
.locator('[data-sidebar="group-label"]')
|
|
.filter({ hasText: "Personal" });
|
|
|
|
await expect(sectionLabel).toHaveCSS("color", mutedColor);
|
|
await expect(page.getByTestId("settings-nav-profile")).not.toHaveCSS(
|
|
"color",
|
|
mutedColor,
|
|
);
|
|
|
|
await expectBuzzGradientPaint(page, mode);
|
|
|
|
const version = page.getByTestId("settings-version");
|
|
if ((await version.count()) > 0) {
|
|
await expect(version).toHaveCSS("color", mutedColor);
|
|
}
|
|
}
|
|
|
|
async function expectAppliedBuzzTheme(
|
|
page: Page,
|
|
themeName: "buzz" | "buzz-dark",
|
|
storedTheme: "buzz" | "buzz-dark" = themeName,
|
|
) {
|
|
const isDark = themeName === "buzz-dark";
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate((storageKey) => {
|
|
const root = document.documentElement;
|
|
const styles = getComputedStyle(root);
|
|
return {
|
|
storedTheme: window.localStorage.getItem(storageKey),
|
|
isDark: root.classList.contains("dark"),
|
|
buzzTheme: root.getAttribute("data-buzz-theme"),
|
|
gradientTop: styles.getPropertyValue("--buzz-gradient-top").trim(),
|
|
gradientBottom: styles
|
|
.getPropertyValue("--buzz-gradient-bottom")
|
|
.trim(),
|
|
};
|
|
}, THEME_STORAGE_KEY),
|
|
)
|
|
.toEqual({
|
|
storedTheme,
|
|
isDark,
|
|
buzzTheme: themeName,
|
|
gradientTop: isDark ? "#4a4616" : "#e6e6b6",
|
|
gradientBottom: isDark ? "#0a1423" : "#c4d0da",
|
|
});
|
|
}
|
|
|
|
async function emitNativeThemeChange(page: Page, theme: "light" | "dark") {
|
|
await page.evaluate(async (nextTheme) => {
|
|
const tauriWindow = window as typeof window & {
|
|
__TAURI_INTERNALS__?: {
|
|
invoke?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
};
|
|
};
|
|
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
|
|
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
|
|
await invoke("plugin:event|emit", {
|
|
event: "tauri://theme-changed",
|
|
payload: nextTheme,
|
|
});
|
|
}, theme);
|
|
}
|
|
|
|
test("buzz light sidebar gradient", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await openChannel(page);
|
|
await expectBuzzGradientPaint(page, "light");
|
|
await expectBuzzSidebarPalette(page, "light");
|
|
await expectBuzzContentShadow(page, "light");
|
|
await expectIconlessSectionTitleAligned(page, "stream-list");
|
|
await expectIconlessSectionTitleAligned(page, "dm-list");
|
|
await waitForAnimations(page);
|
|
await page
|
|
.getByTestId("app-sidebar")
|
|
.screenshot({ path: `${SHOTS}/01-buzz-light-sidebar.png` });
|
|
});
|
|
|
|
test("buzz dark sidebar gradient", async ({ page }) => {
|
|
await seedTheme(page, "buzz-dark");
|
|
await installMockBridge(page);
|
|
await openChannel(page);
|
|
await expectBuzzGradientPaint(page, "dark");
|
|
await expectBuzzSidebarPalette(page, "dark");
|
|
await expectBuzzContentShadow(page, "dark");
|
|
await expectIconlessSectionTitleAligned(page, "stream-list");
|
|
await expectIconlessSectionTitleAligned(page, "dm-list");
|
|
await expect(page.locator("[data-buzz-content-surface]")).toHaveCSS(
|
|
"background-color",
|
|
"rgb(26, 26, 26)",
|
|
);
|
|
await waitForAnimations(page);
|
|
await page
|
|
.getByTestId("app-sidebar")
|
|
.screenshot({ path: `${SHOTS}/02-buzz-dark-sidebar.png` });
|
|
});
|
|
|
|
test("custom section icon and name align with channel columns", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await seedIconChannelSection(page);
|
|
await installMockBridge(page);
|
|
await openChannel(page);
|
|
|
|
const sectionIconBox = await page
|
|
.getByTestId("section-icon-alignment-section")
|
|
.boundingBox();
|
|
const sectionTitleBox = await page
|
|
.getByTestId("section-title-alignment-section")
|
|
.boundingBox();
|
|
const channelButton = page.getByTestId("channel-general");
|
|
const channelIconBox = await channelButton
|
|
.locator("svg")
|
|
.first()
|
|
.boundingBox();
|
|
const channelTitleBox = await channelButton
|
|
.locator("[data-sidebar-row-label]")
|
|
.boundingBox();
|
|
|
|
expect(sectionIconBox).not.toBeNull();
|
|
expect(sectionTitleBox).not.toBeNull();
|
|
expect(channelIconBox).not.toBeNull();
|
|
expect(channelTitleBox).not.toBeNull();
|
|
if (
|
|
!sectionIconBox ||
|
|
!sectionTitleBox ||
|
|
!channelIconBox ||
|
|
!channelTitleBox
|
|
) {
|
|
throw new Error("Custom section alignment geometry is missing");
|
|
}
|
|
expect(Math.abs(sectionIconBox.x - channelIconBox.x)).toBeLessThanOrEqual(
|
|
0.5,
|
|
);
|
|
expect(Math.abs(sectionTitleBox.x - channelTitleBox.x)).toBeLessThanOrEqual(
|
|
0.5,
|
|
);
|
|
});
|
|
|
|
async function openAppearance(page: Page, mode: "system" | "light" | "dark") {
|
|
// Settings renders at the AppShell level; open it via the profile card
|
|
// button, then select the Appearance section.
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-settings").click();
|
|
await page.getByTestId("profile-popover-settings").click();
|
|
await page.getByTestId("settings-nav-appearance").click();
|
|
const panel = page.getByTestId("settings-theme");
|
|
await expect(panel).toBeVisible({ timeout: 10_000 });
|
|
await page.getByTestId(`appearance-mode-${mode}`).click();
|
|
await waitForAnimations(page);
|
|
return panel;
|
|
}
|
|
|
|
test("appearance groups theme and preferences into labeled rows", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "github-light");
|
|
await page.addInitScript((prominentActiveTabStorageKey) => {
|
|
window.localStorage.setItem(prominentActiveTabStorageKey, "true");
|
|
Object.defineProperty(navigator, "platform", {
|
|
configurable: true,
|
|
get: () => "MacIntel",
|
|
});
|
|
}, PROMINENT_ACTIVE_TAB_STORAGE_KEY);
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
const themeCard = page.getByTestId("appearance-theme-card");
|
|
const preferencesCard = page.getByTestId("appearance-preferences-card");
|
|
|
|
await expect(
|
|
themeCard.getByRole("heading", { name: "Theme", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
preferencesCard.getByRole("heading", {
|
|
name: "Preferences",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(
|
|
themeCard.getByRole("group", { name: "Color mode" }),
|
|
).toBeVisible();
|
|
await expect(themeCard.getByTestId("glass-background-toggle")).toBeVisible();
|
|
await expect(
|
|
themeCard.getByTestId("prominent-active-tab-toggle"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
preferencesCard.getByTestId("prominent-active-tab-toggle"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
preferencesCard.getByRole("group", { name: "Thread layout" }),
|
|
).toBeVisible();
|
|
const themeStyleTrigger = themeCard.getByTestId("theme-style-trigger");
|
|
const themeStyleOptions = themeCard.getByTestId("theme-style-options");
|
|
const selectedThemePreview = themeCard.getByTestId(
|
|
"theme-style-selected-preview",
|
|
);
|
|
await expect(themeStyleTrigger).toHaveAttribute(
|
|
"aria-label",
|
|
"Theme style, Github Light",
|
|
);
|
|
await expect(themeStyleTrigger).not.toContainText("Github Light");
|
|
await expect(themeStyleTrigger).toHaveAttribute("aria-expanded", "false");
|
|
await expect(themeStyleTrigger).toHaveCSS(
|
|
"background-color",
|
|
"rgba(0, 0, 0, 0)",
|
|
);
|
|
await expect(themeStyleTrigger).toHaveCSS("border-width", "0px");
|
|
await expect(selectedThemePreview).toBeVisible();
|
|
const selectedThemePreviewSvg = selectedThemePreview.locator("svg");
|
|
await expect(selectedThemePreviewSvg).toBeVisible();
|
|
const selectedThemePreviewBox = await selectedThemePreview.boundingBox();
|
|
const selectedThemePreviewSvgBox =
|
|
await selectedThemePreviewSvg.boundingBox();
|
|
expect(selectedThemePreviewBox?.width).toBe(168);
|
|
expect(selectedThemePreviewBox?.height).toBe(112);
|
|
expect(selectedThemePreviewSvgBox?.width).toBeGreaterThan(100);
|
|
expect(selectedThemePreviewSvgBox?.height).toBeGreaterThan(70);
|
|
await expect(themeStyleOptions).toHaveCount(0);
|
|
|
|
await themeStyleTrigger.click();
|
|
await expect(themeStyleTrigger).toHaveAttribute("aria-expanded", "true");
|
|
await expect(themeStyleOptions).toBeVisible();
|
|
await themeCard.getByTestId("theme-option-buzz").click();
|
|
await expect(themeStyleTrigger).toHaveAttribute("aria-expanded", "true");
|
|
await expect(themeStyleOptions).toBeVisible();
|
|
await expect(
|
|
themeCard.getByTestId("prominent-active-tab-toggle"),
|
|
).toBeChecked();
|
|
const glassBeforeProminent = await themeCard.evaluate((card) => {
|
|
const glass = card.querySelector('[data-testid="glass-background-toggle"]');
|
|
const prominent = card.querySelector(
|
|
'[data-testid="prominent-active-tab-row"]',
|
|
);
|
|
return Boolean(
|
|
glass &&
|
|
prominent &&
|
|
glass.compareDocumentPosition(prominent) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
);
|
|
});
|
|
expect(glassBeforeProminent).toBe(true);
|
|
|
|
const colorModeLabelBox = await themeCard
|
|
.getByTestId("appearance-color-mode-row")
|
|
.locator("p")
|
|
.first()
|
|
.boundingBox();
|
|
const colorModeControlBox = await themeCard
|
|
.getByRole("group", { name: "Color mode" })
|
|
.boundingBox();
|
|
const glassLabelBox = await themeCard
|
|
.getByText("Glass background", { exact: true })
|
|
.boundingBox();
|
|
const glassToggleBox = await themeCard
|
|
.getByTestId("glass-background-toggle")
|
|
.boundingBox();
|
|
const colorModeIndicator = themeCard.getByTestId(
|
|
"appearance-color-mode-indicator",
|
|
);
|
|
const lightModeButton = themeCard.getByTestId("appearance-mode-light");
|
|
const initialIndicatorBox = await colorModeIndicator.boundingBox();
|
|
const lightModeButtonBox = await lightModeButton.boundingBox();
|
|
const themeCardBox = await themeCard.boundingBox();
|
|
const preferencesCardBox = await preferencesCard.boundingBox();
|
|
|
|
expect(colorModeLabelBox).not.toBeNull();
|
|
expect(colorModeControlBox).not.toBeNull();
|
|
expect(glassLabelBox).not.toBeNull();
|
|
expect(glassToggleBox).not.toBeNull();
|
|
expect(initialIndicatorBox).not.toBeNull();
|
|
expect(lightModeButtonBox).not.toBeNull();
|
|
expect(themeCardBox).not.toBeNull();
|
|
expect(preferencesCardBox).not.toBeNull();
|
|
if (
|
|
!colorModeLabelBox ||
|
|
!colorModeControlBox ||
|
|
!glassLabelBox ||
|
|
!glassToggleBox ||
|
|
!initialIndicatorBox ||
|
|
!lightModeButtonBox ||
|
|
!themeCardBox ||
|
|
!preferencesCardBox
|
|
) {
|
|
throw new Error("Appearance card geometry is missing");
|
|
}
|
|
|
|
expect(colorModeLabelBox.x).toBeLessThan(colorModeControlBox.x);
|
|
expect(glassLabelBox.x).toBeLessThan(glassToggleBox.x);
|
|
expect(themeCardBox.y).toBeLessThan(preferencesCardBox.y);
|
|
expect(
|
|
Math.abs(
|
|
initialIndicatorBox.x +
|
|
initialIndicatorBox.width / 2 -
|
|
(lightModeButtonBox.x + lightModeButtonBox.width / 2),
|
|
),
|
|
).toBeLessThanOrEqual(0.5);
|
|
await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s");
|
|
|
|
await themeCard.getByTestId("appearance-mode-dark").click();
|
|
await waitForAnimations(page);
|
|
const movedIndicatorBox = await colorModeIndicator.boundingBox();
|
|
const darkModeButtonBox = await themeCard
|
|
.getByTestId("appearance-mode-dark")
|
|
.boundingBox();
|
|
expect(movedIndicatorBox).not.toBeNull();
|
|
expect(darkModeButtonBox).not.toBeNull();
|
|
if (!movedIndicatorBox || !darkModeButtonBox) {
|
|
throw new Error("Color mode indicator geometry is missing");
|
|
}
|
|
expect(
|
|
Math.abs(
|
|
movedIndicatorBox.x +
|
|
movedIndicatorBox.width / 2 -
|
|
(darkModeButtonBox.x + darkModeButtonBox.width / 2),
|
|
),
|
|
).toBeLessThanOrEqual(0.5);
|
|
});
|
|
|
|
test("app font size and conversation density apply independently", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
|
|
const root = page.locator("html");
|
|
const densityControl = page.getByTestId("conversation-density-control");
|
|
const compact = page.getByTestId("conversation-density-compact");
|
|
const comfortable = page.getByTestId("conversation-density-comfortable");
|
|
const spacious = page.getByTestId("conversation-density-spacious");
|
|
const densityIndicator = page.getByTestId(
|
|
"conversation-density-control-indicator",
|
|
);
|
|
const fontSizeControl = page.getByTestId("font-size-control");
|
|
const smaller = page.getByTestId("font-size-smaller");
|
|
const defaultSize = page.getByTestId("font-size-default");
|
|
const larger = page.getByTestId("font-size-larger");
|
|
const fontSizeIndicator = page.getByTestId("font-size-control-indicator");
|
|
const preview = page.getByTestId("conversation-preview");
|
|
const previewSurface = page.getByTestId("conversation-preview-surface");
|
|
const previewContent = page.getByTestId("conversation-preview-content");
|
|
const previewChip = preview.getByText("Preview");
|
|
const firstPreviewMessage = previewSurface.locator("article").first();
|
|
const previewMessage = preview.getByText(
|
|
"The revised conversation layout is ready to review.",
|
|
);
|
|
const previewTimestamp = preview.getByText("9:41");
|
|
const densityDescription = page
|
|
.getByTestId("conversation-density-row")
|
|
.locator("[data-settings-subcopy]");
|
|
const fontSizeDescription = page
|
|
.getByTestId("font-size-row")
|
|
.locator("[data-settings-subcopy]");
|
|
// The conversation tokens are rem-relative `calc(...)` expressions (the
|
|
// type tokens ride on `--buzz-type-rem`, which itself derives from the
|
|
// root rem so Cmd +/- zooms everything together). Reading the raw custom
|
|
// property strings off <html> would just return unresolved calc text, so
|
|
// resolve each token to px through a probe element instead: assign the
|
|
// token to the probe's font-size and read the computed value back.
|
|
// font-size is used (rather than width) because its computed value keeps
|
|
// fractional precision instead of snapping to layout units.
|
|
const readScale = () =>
|
|
root.evaluate((element) => {
|
|
const PROBE_ID = "buzz-e2e-conversation-scale-probe";
|
|
const tokens = {
|
|
authorLineHeight: "--conversation-author-line-height",
|
|
bodyGap: "--conversation-body-gap",
|
|
fontSize: "--conversation-message-font-size",
|
|
lineHeight: "--conversation-message-line-height",
|
|
paragraphGap: "--conversation-paragraph-gap",
|
|
rowPadding: "--conversation-row-padding-block",
|
|
timestampFontSize: "--conversation-timestamp-font-size",
|
|
timestampLineHeight: "--conversation-timestamp-line-height",
|
|
} as const;
|
|
let probe = element.ownerDocument.getElementById(PROBE_ID);
|
|
if (!probe) {
|
|
probe = element.ownerDocument.createElement("span");
|
|
probe.id = PROBE_ID;
|
|
probe.style.cssText =
|
|
"position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none";
|
|
element.appendChild(probe);
|
|
}
|
|
const resolvePx = (token: string) => {
|
|
probe.style.fontSize = `var(${token})`;
|
|
const px = Number.parseFloat(window.getComputedStyle(probe).fontSize);
|
|
return Math.round(px * 100) / 100;
|
|
};
|
|
return Object.fromEntries(
|
|
Object.entries(tokens).map(([key, token]) => [key, resolvePx(token)]),
|
|
) as Record<keyof typeof tokens, number>;
|
|
});
|
|
const readSettingsScale = () =>
|
|
page.getByTestId("conversation-density-row").evaluate((element) => {
|
|
const rowStyle = window.getComputedStyle(element);
|
|
const label = element.querySelector("p");
|
|
if (!label) throw new Error("Conversation density label is missing");
|
|
const labelStyle = window.getComputedStyle(label);
|
|
return {
|
|
fontSize: labelStyle.fontSize,
|
|
lineHeight: labelStyle.lineHeight,
|
|
minHeight: rowStyle.minHeight,
|
|
paddingBlock: rowStyle.paddingTop,
|
|
};
|
|
});
|
|
const readPreviewTimestampScale = () =>
|
|
previewTimestamp.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return [style.fontSize, style.lineHeight];
|
|
});
|
|
const readSettingsChromeScale = () =>
|
|
Promise.all([
|
|
page
|
|
.getByRole("heading", { name: "Appearance" })
|
|
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
|
page
|
|
.getByTestId("settings-nav-appearance")
|
|
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
|
page
|
|
.getByRole("heading", { name: "Preferences" })
|
|
.evaluate((element) => window.getComputedStyle(element).fontSize),
|
|
]);
|
|
|
|
await expect(
|
|
page.getByRole("group", { name: "Conversation density" }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("group", { name: "Font size" })).toBeVisible();
|
|
await expect(densityControl).toHaveAccessibleName("Conversation density");
|
|
await expect(fontSizeControl).toHaveAccessibleName("Font size");
|
|
await expect(comfortable).toHaveText("Comfy");
|
|
await expect(defaultSize).toHaveText("Default");
|
|
await expect(preview).toContainText("Preview");
|
|
await expect(preview).not.toContainText("Message #design");
|
|
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
|
|
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
|
|
await expect(densityDescription).toHaveText(
|
|
"Spacing in conversations and Markdown content across Buzz",
|
|
);
|
|
await expect(fontSizeDescription).toHaveText(
|
|
"Applies across conversations and interface text",
|
|
);
|
|
// Comfy + Default: 14px conversation text on a 16px root rem.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 16,
|
|
bodyGap: 2,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
paragraphGap: 8,
|
|
rowPadding: 4,
|
|
timestampFontSize: 12,
|
|
timestampLineHeight: 16,
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
Promise.all([
|
|
densityControl.evaluate(
|
|
(element) => element.getBoundingClientRect().width,
|
|
),
|
|
fontSizeControl.evaluate(
|
|
(element) => element.getBoundingClientRect().width,
|
|
),
|
|
page
|
|
.getByTestId("appearance-color-mode-control")
|
|
.evaluate((element) => element.getBoundingClientRect().width),
|
|
]),
|
|
)
|
|
.toEqual([288, 288, 240]);
|
|
await expect
|
|
.poll(() =>
|
|
previewMessage.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return [style.fontSize, style.lineHeight];
|
|
}),
|
|
)
|
|
.toEqual(["14px", "20px"]);
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "14px",
|
|
lineHeight: "20px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
|
|
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
|
|
await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s");
|
|
await expect(densityIndicator).toHaveCSS("transition-property", /transform/);
|
|
await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s");
|
|
await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/);
|
|
await expect
|
|
.poll(async () => {
|
|
const [previewBackground, labelBackground, controlBackground] =
|
|
await Promise.all([
|
|
previewSurface.evaluate(
|
|
(element) => window.getComputedStyle(element).backgroundColor,
|
|
),
|
|
previewChip.evaluate(
|
|
(element) => window.getComputedStyle(element).backgroundColor,
|
|
),
|
|
densityControl.evaluate(
|
|
(element) => window.getComputedStyle(element).backgroundColor,
|
|
),
|
|
]);
|
|
return {
|
|
labelIsAnnotation: labelBackground !== controlBackground,
|
|
previewBackground,
|
|
};
|
|
})
|
|
.toEqual({
|
|
labelIsAnnotation: true,
|
|
previewBackground: "rgba(0, 0, 0, 0)",
|
|
});
|
|
const previewSurfaceBox = await previewSurface.boundingBox();
|
|
const previewChipBox = await previewChip.boundingBox();
|
|
const firstPreviewMessageBox = await firstPreviewMessage.boundingBox();
|
|
expect(previewSurfaceBox).not.toBeNull();
|
|
expect(previewChipBox).not.toBeNull();
|
|
expect(firstPreviewMessageBox).not.toBeNull();
|
|
if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) {
|
|
throw new Error("Conversation preview geometry is missing");
|
|
}
|
|
const previewChipRightInset =
|
|
previewSurfaceBox.x +
|
|
previewSurfaceBox.width -
|
|
(previewChipBox.x + previewChipBox.width);
|
|
expect(previewChipRightInset).toBeGreaterThanOrEqual(13);
|
|
expect(previewChipRightInset).toBeLessThanOrEqual(15);
|
|
const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y;
|
|
expect(previewChipTopInset).toBeGreaterThanOrEqual(13);
|
|
expect(previewChipTopInset).toBeLessThanOrEqual(15);
|
|
await expect(previewContent).toHaveCSS("padding-top", "16px");
|
|
await expect(previewContent).toHaveCSS("padding-right", "16px");
|
|
await expect(previewContent).toHaveCSS("padding-bottom", "16px");
|
|
await expect(previewContent).toHaveCSS("padding-left", "16px");
|
|
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual(
|
|
15,
|
|
);
|
|
expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual(
|
|
17,
|
|
);
|
|
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual(
|
|
15,
|
|
);
|
|
expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual(
|
|
17,
|
|
);
|
|
await densityIndicator.evaluate((element) => {
|
|
element.addEventListener(
|
|
"transitionrun",
|
|
() => element.setAttribute("data-transition-ran", "true"),
|
|
{ once: true },
|
|
);
|
|
});
|
|
|
|
await compact.click();
|
|
await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true");
|
|
await expect(root).toHaveAttribute("data-conversation-density", "compact");
|
|
await expect(root).toHaveAttribute("data-font-size", "default");
|
|
await expect(compact).toHaveAttribute("aria-pressed", "true");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
CONVERSATION_DENSITY_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("compact");
|
|
// Compact only tightens spacing; type is untouched.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 16,
|
|
bodyGap: 0,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
paragraphGap: 6,
|
|
rowPadding: 4,
|
|
timestampFontSize: 12,
|
|
timestampLineHeight: 16,
|
|
});
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "14px",
|
|
lineHeight: "20px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]);
|
|
await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]);
|
|
|
|
await larger.click();
|
|
await expect(root).toHaveAttribute("data-conversation-density", "compact");
|
|
await expect(root).toHaveAttribute("data-font-size", "larger");
|
|
await expect(larger).toHaveAttribute("aria-pressed", "true");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
FONT_SIZE_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("larger");
|
|
// Larger scales only the type tokens (15/14); compact spacing is unchanged.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 17.14,
|
|
bodyGap: 0,
|
|
fontSize: 15,
|
|
lineHeight: 21.43,
|
|
paragraphGap: 6,
|
|
rowPadding: 4,
|
|
timestampFontSize: 12.86,
|
|
timestampLineHeight: 17.14,
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
previewMessage.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return [style.fontSize, style.lineHeight];
|
|
}),
|
|
)
|
|
.toEqual(["15px", "21.4286px"]);
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "15px",
|
|
lineHeight: "21.4286px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await expect
|
|
.poll(readPreviewTimestampScale)
|
|
.toEqual(["12.8571px", "17.1429px"]);
|
|
await expect
|
|
.poll(readSettingsChromeScale)
|
|
.toEqual(["25.7143px", "15px", "15px"]);
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("appearance-preferences-card").screenshot({
|
|
path: `${SHOTS}/15-conversation-compact-larger.png`,
|
|
});
|
|
|
|
await spacious.click();
|
|
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
|
await expect(root).toHaveAttribute("data-font-size", "larger");
|
|
await expect(spacious).toHaveAttribute("aria-pressed", "true");
|
|
// Spacious loosens spacing only; Larger type carries over.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 17.14,
|
|
bodyGap: 4,
|
|
fontSize: 15,
|
|
lineHeight: 21.43,
|
|
paragraphGap: 10,
|
|
rowPadding: 8,
|
|
timestampFontSize: 12.86,
|
|
timestampLineHeight: 17.14,
|
|
});
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "15px",
|
|
lineHeight: "21.4286px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await expect
|
|
.poll(readPreviewTimestampScale)
|
|
.toEqual(["12.8571px", "17.1429px"]);
|
|
await expect
|
|
.poll(readSettingsChromeScale)
|
|
.toEqual(["25.7143px", "15px", "15px"]);
|
|
|
|
await smaller.click();
|
|
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
|
await expect(root).toHaveAttribute("data-font-size", "smaller");
|
|
await expect(smaller).toHaveAttribute("aria-pressed", "true");
|
|
// Smaller scales only the type tokens (13/14); spacious spacing is unchanged.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 14.86,
|
|
bodyGap: 4,
|
|
fontSize: 13,
|
|
lineHeight: 18.57,
|
|
paragraphGap: 10,
|
|
rowPadding: 8,
|
|
timestampFontSize: 11.14,
|
|
timestampLineHeight: 14.86,
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
previewMessage.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return [style.fontSize, style.lineHeight];
|
|
}),
|
|
)
|
|
.toEqual(["13px", "18.5714px"]);
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "13px",
|
|
lineHeight: "18.5714px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await expect
|
|
.poll(readPreviewTimestampScale)
|
|
.toEqual(["11.1429px", "14.8571px"]);
|
|
await expect
|
|
.poll(readSettingsChromeScale)
|
|
.toEqual(["22.2857px", "13px", "13px"]);
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("appearance-preferences-card").screenshot({
|
|
path: `${SHOTS}/16-conversation-spacious-smaller.png`,
|
|
});
|
|
|
|
await comfortable.click();
|
|
await defaultSize.click();
|
|
await expect(root).toHaveAttribute(
|
|
"data-conversation-density",
|
|
"comfortable",
|
|
);
|
|
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
|
|
|
|
const controlBox = await densityControl.boundingBox();
|
|
const compactBox = await compact.boundingBox();
|
|
const spaciousBox = await spacious.boundingBox();
|
|
expect(controlBox).not.toBeNull();
|
|
expect(compactBox).not.toBeNull();
|
|
expect(spaciousBox).not.toBeNull();
|
|
if (!controlBox || !compactBox || !spaciousBox) {
|
|
throw new Error("Conversation density control geometry is missing");
|
|
}
|
|
await page.mouse.move(
|
|
compactBox.x + compactBox.width / 2,
|
|
controlBox.y + controlBox.height / 2,
|
|
);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
spaciousBox.x + spaciousBox.width / 2,
|
|
controlBox.y + controlBox.height / 2,
|
|
);
|
|
await expect(root).toHaveAttribute("data-conversation-density", "spacious");
|
|
await expect(root).toHaveAttribute("data-font-size", "default");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
CONVERSATION_DENSITY_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("comfortable");
|
|
// Back to Default type while the drag previews spacious spacing.
|
|
await expect.poll(readScale).toEqual({
|
|
authorLineHeight: 16,
|
|
bodyGap: 4,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
paragraphGap: 10,
|
|
rowPadding: 8,
|
|
timestampFontSize: 12,
|
|
timestampLineHeight: 16,
|
|
});
|
|
await expect.poll(readSettingsScale).toEqual({
|
|
fontSize: "14px",
|
|
lineHeight: "20px",
|
|
minHeight: "64px",
|
|
paddingBlock: "12px",
|
|
});
|
|
await page.mouse.up();
|
|
await expect(spacious).toHaveAttribute("aria-pressed", "true");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
CONVERSATION_DENSITY_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("spacious");
|
|
await comfortable.click();
|
|
|
|
const fontSizeControlBox = await fontSizeControl.boundingBox();
|
|
const smallerBox = await smaller.boundingBox();
|
|
const largerBox = await larger.boundingBox();
|
|
expect(fontSizeControlBox).not.toBeNull();
|
|
expect(smallerBox).not.toBeNull();
|
|
expect(largerBox).not.toBeNull();
|
|
if (!fontSizeControlBox || !smallerBox || !largerBox) {
|
|
throw new Error("Font size control geometry is missing");
|
|
}
|
|
await page.mouse.move(
|
|
smallerBox.x + smallerBox.width / 2,
|
|
fontSizeControlBox.y + fontSizeControlBox.height / 2,
|
|
);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
largerBox.x + largerBox.width / 2,
|
|
fontSizeControlBox.y + fontSizeControlBox.height / 2,
|
|
);
|
|
await expect(root).toHaveAttribute("data-font-size", "larger");
|
|
await expect(root).toHaveAttribute(
|
|
"data-conversation-density",
|
|
"comfortable",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
FONT_SIZE_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("default");
|
|
await expect
|
|
.poll(() =>
|
|
previewMessage.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return [style.fontSize, style.lineHeight];
|
|
}),
|
|
)
|
|
.toEqual(["15px", "21.4286px"]);
|
|
await expect
|
|
.poll(readSettingsChromeScale)
|
|
.toEqual(["25.7143px", "15px", "15px"]);
|
|
|
|
// Losing the window during a scrub cancels the temporary preview rather
|
|
// than leaving presentation and persisted selection out of sync.
|
|
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
|
|
await expect(root).toHaveAttribute("data-font-size", "default");
|
|
await expect(defaultSize).toHaveAttribute("aria-pressed", "true");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
FONT_SIZE_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("default");
|
|
|
|
// Cancellation resets the gesture completely; the next selection persists.
|
|
await larger.click();
|
|
await expect(larger).toHaveAttribute("aria-pressed", "true");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
FONT_SIZE_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("larger");
|
|
await defaultSize.click();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("appearance-preferences-card").screenshot({
|
|
path: `${SHOTS}/14-conversation-preferences.png`,
|
|
});
|
|
});
|
|
|
|
test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
const panel = await openAppearance(page, "system");
|
|
await panel.screenshot({ path: `${SHOTS}/03-picker-system.png` });
|
|
});
|
|
|
|
test("appearance picker — light tab (Buzz)", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
const panel = await openAppearance(page, "light");
|
|
await panel.screenshot({ path: `${SHOTS}/04-picker-light.png` });
|
|
});
|
|
|
|
test("appearance picker — dark tab (Buzz Dark)", async ({ page }) => {
|
|
await seedTheme(page, "buzz-dark");
|
|
await installMockBridge(page);
|
|
const panel = await openAppearance(page, "dark");
|
|
await panel.screenshot({ path: `${SHOTS}/05-picker-dark.png` });
|
|
});
|
|
|
|
test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-settings").click();
|
|
await page.getByTestId("profile-popover-settings").click();
|
|
const sidebar = page.getByTestId("settings-sidebar");
|
|
await expect(sidebar).toBeVisible({ timeout: 10_000 });
|
|
const profileRow = page.getByTestId("settings-nav-profile");
|
|
const profileLabel = profileRow.locator('[data-sidebar="menu-label"]');
|
|
await expect(profileRow).toHaveAttribute("data-active", "true");
|
|
await expect(profileRow).toHaveCSS("font-weight", "600");
|
|
const selectedLabelBox = await profileLabel.boundingBox();
|
|
// Appearance is the active section here; its nav row should carry the Buzz
|
|
// white active pill (data-active=true), matching the Left Nav treatment.
|
|
await page.getByTestId("settings-nav-appearance").click();
|
|
await expect(profileRow).toHaveCSS("font-weight", "400");
|
|
const unselectedLabelBox = await profileLabel.boundingBox();
|
|
expect(selectedLabelBox).not.toBeNull();
|
|
expect(unselectedLabelBox).not.toBeNull();
|
|
if (!selectedLabelBox || !unselectedLabelBox) {
|
|
throw new Error("Settings nav label geometry is missing");
|
|
}
|
|
expect(Math.abs(selectedLabelBox.width - unselectedLabelBox.width)).toBe(0);
|
|
await expectBuzzSettingsPalette(page, "light");
|
|
const activeRow = page.getByTestId("settings-nav-appearance");
|
|
await expect(activeRow).toHaveAttribute("data-active", "true");
|
|
await waitForAnimations(page);
|
|
await sidebar.screenshot({ path: `${SHOTS}/06-settings-nav-light.png` });
|
|
});
|
|
|
|
test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => {
|
|
await seedTheme(page, "buzz-dark");
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.getByTestId("open-settings").click();
|
|
await page.getByTestId("profile-popover-settings").click();
|
|
const sidebar = page.getByTestId("settings-sidebar");
|
|
await expect(sidebar).toBeVisible({ timeout: 10_000 });
|
|
await page.getByTestId("settings-nav-appearance").click();
|
|
await expectBuzzSettingsPalette(page, "dark");
|
|
await expect(page.getByTestId("settings-content-surface")).toHaveCSS(
|
|
"background-color",
|
|
"rgb(26, 26, 26)",
|
|
);
|
|
await waitForAnimations(page);
|
|
await sidebar.screenshot({ path: `${SHOTS}/07-settings-nav-dark.png` });
|
|
await page.getByTestId("settings-view").screenshot({
|
|
path: `${SHOTS}/09-settings-content-dark.png`,
|
|
});
|
|
});
|
|
|
|
test("prominent active tab is opt-in and switches selection surfaces", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
|
|
const root = page.locator("html");
|
|
const activeRow = page.getByTestId("settings-nav-appearance");
|
|
const toggle = page.getByTestId("prominent-active-tab-toggle");
|
|
await expect(toggle).not.toBeChecked();
|
|
await expect(root).not.toHaveAttribute("data-prominent-active-tab", "");
|
|
await expect(activeRow).toHaveCSS("background-color", "rgba(0, 0, 0, 0.07)");
|
|
const subtleTextStyle = await activeRow.evaluate((element) => {
|
|
const styles = getComputedStyle(element);
|
|
return { color: styles.color, fontWeight: styles.fontWeight };
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
PROMINENT_ACTIVE_TAB_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBeNull();
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toBeChecked();
|
|
await expect(root).toHaveAttribute("data-prominent-active-tab", "");
|
|
await expect(activeRow).toHaveCSS(
|
|
"background-color",
|
|
"rgba(255, 255, 255, 0.82)",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
PROMINENT_ACTIVE_TAB_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("true");
|
|
const prominentTextStyle = await activeRow.evaluate((element) => {
|
|
const styles = getComputedStyle(element);
|
|
return { color: styles.color, fontWeight: styles.fontWeight };
|
|
});
|
|
expect(prominentTextStyle).toEqual(subtleTextStyle);
|
|
|
|
await toggle.click();
|
|
await expect(root).not.toHaveAttribute("data-prominent-active-tab", "");
|
|
await expect(activeRow).toHaveCSS("background-color", "rgba(0, 0, 0, 0.07)");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
PROMINENT_ACTIVE_TAB_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("false");
|
|
});
|
|
|
|
test("prominent channel and direct-message rows share one flat active state", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await page.addInitScript(
|
|
({ key }) => window.localStorage.setItem(key, "true"),
|
|
{ key: PROMINENT_ACTIVE_TAB_STORAGE_KEY },
|
|
);
|
|
await installMockBridge(page);
|
|
await openChannel(page);
|
|
|
|
const channelRow = page.getByTestId("channel-general");
|
|
const directMessageRow = page.getByTestId("channel-alice-tyler");
|
|
|
|
await expect(channelRow).toHaveCSS(
|
|
"background-color",
|
|
"rgba(255, 255, 255, 0.82)",
|
|
);
|
|
await expect(channelRow).toHaveCSS("box-shadow", "none");
|
|
await channelRow.hover();
|
|
await expect(channelRow).toHaveCSS(
|
|
"background-color",
|
|
"rgba(255, 255, 255, 0.82)",
|
|
);
|
|
|
|
await directMessageRow.click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
|
await expect(directMessageRow).toHaveCSS(
|
|
"background-color",
|
|
"rgba(255, 255, 255, 0.82)",
|
|
);
|
|
await expect(directMessageRow).toHaveCSS("box-shadow", "none");
|
|
await directMessageRow.hover();
|
|
await expect(directMessageRow).toHaveCSS(
|
|
"background-color",
|
|
"rgba(255, 255, 255, 0.82)",
|
|
);
|
|
});
|
|
|
|
for (const { activeSurface, hoverSurface, mode, theme } of [
|
|
{
|
|
activeSurface: "rgba(0, 0, 0, 0.07)",
|
|
hoverSurface: "rgba(0, 0, 0, 0.04)",
|
|
mode: "light" as const,
|
|
theme: "buzz",
|
|
},
|
|
{
|
|
activeSurface: "rgba(255, 255, 255, 0.16)",
|
|
hoverSurface: "rgba(255, 255, 255, 0.04)",
|
|
mode: "dark" as const,
|
|
theme: "buzz-dark",
|
|
},
|
|
]) {
|
|
test(`non-prominent ${theme} selection matches production`, async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, theme);
|
|
await page.addInitScript(
|
|
({ key }) => window.localStorage.setItem(key, "false"),
|
|
{ key: PROMINENT_ACTIVE_TAB_STORAGE_KEY },
|
|
);
|
|
await installMockBridge(page);
|
|
await openChannel(page);
|
|
|
|
const root = page.locator("html");
|
|
const sidebar = page.getByTestId("app-sidebar");
|
|
const activeRow = page.getByTestId("channel-general");
|
|
await expect(root).toHaveClass(
|
|
new RegExp(`(^|\\s)${mode === "dark" ? "dark" : "light"}($|\\s)`),
|
|
);
|
|
await expect(root).not.toHaveAttribute("data-prominent-active-tab", "");
|
|
await expect(activeRow).toHaveCSS("background-color", activeSurface);
|
|
await expect(activeRow).toHaveCSS("box-shadow", "none");
|
|
await expect(activeRow).toHaveCSS("font-weight", "400");
|
|
await activeRow.hover();
|
|
await expect(activeRow).toHaveCSS("background-color", activeSurface);
|
|
const inactiveRow = page.getByTestId("channel-random");
|
|
await inactiveRow.hover();
|
|
await expect(inactiveRow).toHaveCSS("background-color", hoverSurface);
|
|
const expectedForeground = await sidebar.evaluate((element) => {
|
|
const probe = document.createElement("span");
|
|
probe.style.color = "hsl(var(--sidebar-active-foreground))";
|
|
element.append(probe);
|
|
const color = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return color;
|
|
});
|
|
await expect(activeRow).toHaveCSS("color", expectedForeground);
|
|
});
|
|
}
|
|
|
|
for (const { mode, theme } of [
|
|
{ mode: "light" as const, theme: "github-light" },
|
|
{ mode: "dark" as const, theme: "github-dark" },
|
|
]) {
|
|
test(`${theme} ignores the Buzz prominent preference`, async ({ page }) => {
|
|
await seedTheme(page, theme);
|
|
await page.addInitScript(
|
|
({ key }) => window.localStorage.setItem(key, "true"),
|
|
{ key: PROMINENT_ACTIVE_TAB_STORAGE_KEY },
|
|
);
|
|
await installMockBridge(page);
|
|
await openAppearance(page, mode);
|
|
|
|
const root = page.locator("html");
|
|
const activeRow = page.getByTestId("settings-nav-appearance");
|
|
await expect(page.getByTestId("prominent-active-tab-row")).toHaveCount(0);
|
|
await expect(root).not.toHaveAttribute("data-prominent-active-tab", "");
|
|
|
|
const productionStyle = await page.evaluate(() => {
|
|
const sidebar = document.querySelector<HTMLElement>(
|
|
'[data-testid="settings-sidebar"]',
|
|
);
|
|
const row = document.querySelector<HTMLElement>(
|
|
'[data-testid="settings-nav-appearance"]',
|
|
);
|
|
if (!sidebar || !row) return null;
|
|
const probe = document.createElement("span");
|
|
probe.style.backgroundColor = "hsl(var(--sidebar-active))";
|
|
probe.style.color = "hsl(var(--sidebar-active-foreground))";
|
|
sidebar.append(probe);
|
|
const probeStyles = getComputedStyle(probe);
|
|
const rowStyles = getComputedStyle(row);
|
|
const result = {
|
|
expectedBackground: probeStyles.backgroundColor,
|
|
expectedForeground: probeStyles.color,
|
|
background: rowStyles.backgroundColor,
|
|
color: rowStyles.color,
|
|
fontWeight: rowStyles.fontWeight,
|
|
};
|
|
probe.remove();
|
|
return result;
|
|
});
|
|
|
|
expect(productionStyle).not.toBeNull();
|
|
expect(productionStyle?.background).toBe(
|
|
productionStyle?.expectedBackground,
|
|
);
|
|
expect(productionStyle?.color).toBe(productionStyle?.expectedForeground);
|
|
await expect(activeRow).toHaveCSS(
|
|
"font-weight",
|
|
productionStyle?.fontWeight ?? "",
|
|
);
|
|
});
|
|
}
|
|
|
|
test("settings content uses the same inset surface as the main app", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
const searchBox = await page.getByTestId("open-search").boundingBox();
|
|
await page.getByTestId("open-settings").click();
|
|
await page.getByTestId("profile-popover-settings").click();
|
|
|
|
const settingsView = page.getByTestId("settings-view");
|
|
const contentSurface = page.getByTestId("settings-content-surface");
|
|
const settingsTopChrome = page.getByTestId("settings-top-chrome");
|
|
const settingsSidebarTopChrome = page.getByTestId(
|
|
"settings-sidebar-top-chrome",
|
|
);
|
|
const backToAppBox = await page
|
|
.getByTestId("settings-back-to-app")
|
|
.boundingBox();
|
|
await expect(contentSurface).toBeVisible({ timeout: 10_000 });
|
|
for (const dragRegion of [settingsTopChrome, settingsSidebarTopChrome]) {
|
|
await expect(dragRegion).toHaveAttribute(
|
|
"data-tauri-drag-region",
|
|
/^(?:|true)$/,
|
|
);
|
|
await expect(dragRegion).toHaveCSS("cursor", "default");
|
|
await expect(dragRegion).toHaveCSS("user-select", "none");
|
|
}
|
|
await expect(page.getByTestId("settings-content-scroll")).toHaveCSS(
|
|
"padding-top",
|
|
"24px",
|
|
);
|
|
|
|
const viewBox = await settingsView.boundingBox();
|
|
const surfaceBox = await contentSurface.boundingBox();
|
|
expect(searchBox).not.toBeNull();
|
|
expect(backToAppBox).not.toBeNull();
|
|
expect(viewBox).not.toBeNull();
|
|
expect(surfaceBox).not.toBeNull();
|
|
if (!searchBox || !backToAppBox || !viewBox || !surfaceBox) {
|
|
throw new Error("Settings layout is missing");
|
|
}
|
|
|
|
expect(Math.abs(backToAppBox.y - searchBox.y)).toBeLessThanOrEqual(0.5);
|
|
|
|
// Match the normal app shell: a fixed 40px top chrome strip, then a 1px
|
|
// top/left inset and 8px right/bottom inset around the rounded content card.
|
|
expect(surfaceBox.y - viewBox.y).toBe(41);
|
|
expect(surfaceBox.x - viewBox.x).toBe(1);
|
|
expect(viewBox.x + viewBox.width - (surfaceBox.x + surfaceBox.width)).toBe(8);
|
|
expect(viewBox.y + viewBox.height - (surfaceBox.y + surfaceBox.height)).toBe(
|
|
8,
|
|
);
|
|
|
|
const topChromeBox = await settingsTopChrome.boundingBox();
|
|
const settingsHeadingBox = await page
|
|
.getByRole("heading", { level: 1, name: "Profile" })
|
|
.boundingBox();
|
|
expect(topChromeBox).not.toBeNull();
|
|
expect(settingsHeadingBox).not.toBeNull();
|
|
if (!topChromeBox || !settingsHeadingBox) {
|
|
throw new Error("Settings drag region or title is missing");
|
|
}
|
|
await page.mouse.move(
|
|
topChromeBox.x + topChromeBox.width / 2,
|
|
topChromeBox.y + topChromeBox.height / 2,
|
|
);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
settingsHeadingBox.x + settingsHeadingBox.width / 2,
|
|
settingsHeadingBox.y + settingsHeadingBox.height / 2,
|
|
{ steps: 5 },
|
|
);
|
|
await page.mouse.up();
|
|
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("");
|
|
|
|
await waitForAnimations(page);
|
|
await settingsView.screenshot({
|
|
path: `${SHOTS}/08-settings-content-inset.png`,
|
|
});
|
|
});
|
|
|
|
test("appearance hides accent picker under Buzz", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
const panel = await openAppearance(page, "light");
|
|
// The accent picker is hidden while a Buzz theme is active. Its neutral
|
|
// swatch testid must not be present.
|
|
await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
|
|
await panel.screenshot({ path: `${SHOTS}/10-appearance-no-accent.png` });
|
|
});
|
|
|
|
test("glass background keeps the content panel solid", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await page.addInitScript(() => {
|
|
(window as typeof window & { isTauri?: boolean }).isTauri = true;
|
|
Object.defineProperty(navigator, "platform", {
|
|
configurable: true,
|
|
get: () => "MacIntel",
|
|
});
|
|
});
|
|
await installMockBridge(page);
|
|
const panel = await openAppearance(page, "light");
|
|
|
|
const toggle = page.getByTestId("glass-background-toggle");
|
|
const opacitySlider = page.getByTestId("glass-opacity-slider");
|
|
const root = page.locator("html");
|
|
await expect(toggle).toBeEnabled();
|
|
await expect(toggle).not.toBeChecked();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => window.localStorage.getItem(key),
|
|
GLASS_BACKGROUND_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBeNull();
|
|
await expect(opacitySlider).toHaveCount(0);
|
|
await expect(root).not.toHaveAttribute("data-glass-background", "");
|
|
await expect(page.locator(".buzz-theme-gradient-underlay")).not.toHaveCSS(
|
|
"background-image",
|
|
"none",
|
|
);
|
|
|
|
await toggle.click();
|
|
await expect(toggle).toBeChecked();
|
|
await expect(opacitySlider).toBeVisible();
|
|
await expect(opacitySlider).toHaveClass(/buzz-avatar-framing-slider/);
|
|
await expect(opacitySlider).toHaveAttribute("aria-valuenow", "65");
|
|
await expect(opacitySlider).toHaveCSS("height", "32px");
|
|
const matchingRadiusControls = [
|
|
page.getByTestId("appearance-color-mode-control"),
|
|
page.getByTestId("appearance-color-mode-indicator"),
|
|
page.getByTestId("font-size-control"),
|
|
page.getByTestId("font-size-control-indicator"),
|
|
page.getByTestId("conversation-density-control"),
|
|
page.getByTestId("conversation-density-control-indicator"),
|
|
page.getByTestId("theme-style-trigger"),
|
|
page.getByTestId("link-preview-style-control"),
|
|
page.getByTestId("link-preview-style-control-indicator"),
|
|
page.getByTestId("thread-layout-control"),
|
|
page.getByTestId("thread-layout-control-indicator"),
|
|
];
|
|
for (const control of matchingRadiusControls) {
|
|
await expect(control).toHaveCSS("border-radius", "8px");
|
|
}
|
|
await expect(page.getByTestId("glass-opacity-value")).toHaveCount(0);
|
|
await expect(
|
|
opacitySlider.locator(".buzz-avatar-framing-slider-handle"),
|
|
).toHaveCSS("opacity", "1");
|
|
await expect(root).toHaveAttribute("data-glass-background", "");
|
|
const buzzSettingOrder = await page
|
|
.getByTestId("appearance-theme-card")
|
|
.locator(
|
|
'[data-testid="appearance-color-mode-row"], [data-testid="theme-style-row"], [data-testid="glass-background-row"], [data-testid="glass-opacity-row"], [data-testid="prominent-active-tab-row"]',
|
|
)
|
|
.evaluateAll((rows) => rows.map((row) => row.getAttribute("data-testid")));
|
|
expect(buzzSettingOrder).toEqual([
|
|
"appearance-color-mode-row",
|
|
"theme-style-row",
|
|
"glass-background-row",
|
|
"glass-opacity-row",
|
|
"prominent-active-tab-row",
|
|
]);
|
|
await expect(page.locator(".buzz-theme-gradient-underlay")).toHaveCSS(
|
|
"background-image",
|
|
"none",
|
|
);
|
|
for (const canvas of [root, page.locator("body"), page.locator("#root")]) {
|
|
await expect(canvas).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
}
|
|
await expect(
|
|
page.getByTestId("settings-sidebar").locator('[data-sidebar="sidebar"]'),
|
|
).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
|
await expect(page.getByTestId("settings-content-surface")).not.toHaveCSS(
|
|
"background-color",
|
|
"rgba(0, 0, 0, 0)",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some(
|
|
(entry) =>
|
|
entry.command === "set_window_vibrancy" &&
|
|
(entry.payload as { enabled?: boolean } | undefined)?.enabled ===
|
|
true,
|
|
),
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
await opacitySlider.press("Home");
|
|
await expect(opacitySlider).toHaveAttribute("aria-valuenow", "30");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
document.documentElement.style.getPropertyValue(
|
|
"--glass-background-opacity",
|
|
),
|
|
),
|
|
)
|
|
.toBe("30%");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(storageKey) => localStorage.getItem(storageKey),
|
|
GLASS_OPACITY_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("30");
|
|
await waitForAnimations(page);
|
|
await panel.screenshot({ path: `${SHOTS}/11-appearance-glass.png` });
|
|
|
|
await toggle.click();
|
|
await expect(root).not.toHaveAttribute("data-glass-background", "");
|
|
await expect(opacitySlider).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(storageKey) => localStorage.getItem(storageKey),
|
|
GLASS_BACKGROUND_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("false");
|
|
});
|
|
|
|
test("glass background is unavailable on Linux", async ({ page }) => {
|
|
await seedTheme(page, "buzz");
|
|
await page.addInitScript((storageKey) => {
|
|
window.localStorage.setItem(storageKey, "true");
|
|
(window as typeof window & { isTauri?: boolean }).isTauri = true;
|
|
Object.defineProperty(navigator, "platform", {
|
|
configurable: true,
|
|
get: () => "Linux x86_64",
|
|
});
|
|
Object.defineProperty(navigator, "userAgent", {
|
|
configurable: true,
|
|
get: () => "Buzz Desktop Linux",
|
|
});
|
|
}, GLASS_BACKGROUND_STORAGE_KEY);
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
|
|
await expect(page.getByTestId("glass-background-row")).toHaveCount(0);
|
|
await expect(page.getByTestId("glass-background-toggle")).toHaveCount(0);
|
|
await expect(page.getByTestId("glass-opacity-slider")).toHaveCount(0);
|
|
await expect(page.locator("html")).not.toHaveAttribute(
|
|
"data-glass-background",
|
|
"",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some(
|
|
(entry) => entry.command === "set_window_vibrancy",
|
|
),
|
|
),
|
|
)
|
|
.toBe(false);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(storageKey) => window.localStorage.getItem(storageKey),
|
|
GLASS_BACKGROUND_STORAGE_KEY,
|
|
),
|
|
)
|
|
.toBe("true");
|
|
});
|
|
|
|
test("non-Buzz glass preserves the selected theme sidebar tint", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "rose-pine-dawn");
|
|
await page.addInitScript(() => {
|
|
(window as typeof window & { isTauri?: boolean }).isTauri = true;
|
|
Object.defineProperty(navigator, "platform", {
|
|
configurable: true,
|
|
get: () => "MacIntel",
|
|
});
|
|
});
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
|
|
const root = page.locator("html");
|
|
await expect(root).not.toHaveAttribute("data-buzz-sidebar", "");
|
|
await page.getByTestId("glass-background-toggle").click();
|
|
await expect(root).toHaveAttribute("data-glass-background", "");
|
|
|
|
const tint = await page
|
|
.locator(".buzz-theme-gradient-layer")
|
|
.evaluate((element) => {
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
const sidebar = rootStyles
|
|
.getPropertyValue("--sidebar-background")
|
|
.trim();
|
|
const staticFallback = rootStyles.getPropertyValue("--sidebar").trim();
|
|
const probe = document.createElement("div");
|
|
probe.style.backgroundColor = `hsl(${sidebar} / 65%)`;
|
|
document.body.appendChild(probe);
|
|
const expected = getComputedStyle(probe).backgroundColor;
|
|
probe.remove();
|
|
|
|
return {
|
|
actual: getComputedStyle(element).backgroundColor,
|
|
expected,
|
|
sidebar,
|
|
staticFallback,
|
|
};
|
|
});
|
|
|
|
expect(tint.sidebar).not.toBe(tint.staticFallback);
|
|
expect(tint.actual).toBe(tint.expected);
|
|
});
|
|
|
|
test("accent picker reveals/hides when toggling Buzz", async ({ page }) => {
|
|
// Start on a non-Buzz theme so the accent picker is present, then select the
|
|
// Buzz tile — the picker should animate out and unmount. Reselecting a
|
|
// non-Buzz tile brings it back. Asserts the presence toggle (the motion
|
|
// wrapper) works end to end.
|
|
await seedTheme(page, "github-light");
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, "platform", {
|
|
configurable: true,
|
|
get: () => "MacIntel",
|
|
});
|
|
});
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
|
|
const nonBuzzSettingOrder = await page
|
|
.getByTestId("appearance-theme-card")
|
|
.locator(
|
|
'[data-testid="appearance-color-mode-row"], [data-testid="theme-style-row"], [data-testid="accent-color-options"], [data-testid="glass-background-row"], [data-testid="prominent-active-tab-row"]',
|
|
)
|
|
.evaluateAll((rows) => rows.map((row) => row.getAttribute("data-testid")));
|
|
expect(nonBuzzSettingOrder).toEqual([
|
|
"appearance-color-mode-row",
|
|
"theme-style-row",
|
|
"accent-color-options",
|
|
"glass-background-row",
|
|
]);
|
|
|
|
// Switch to Buzz — picker should leave (allow the exit animation to settle).
|
|
await page.getByTestId("theme-style-trigger").click();
|
|
await page.getByTestId("theme-option-buzz").click();
|
|
await expect(page.getByTestId("theme-style-trigger")).toHaveAttribute(
|
|
"aria-expanded",
|
|
"true",
|
|
);
|
|
await expect(page.getByTestId("accent-color-neutral")).toHaveCount(0);
|
|
|
|
// Back to a non-Buzz theme — picker returns.
|
|
await page.getByTestId("theme-option-github-light").click();
|
|
await expect(page.getByTestId("accent-color-neutral")).toBeVisible();
|
|
await expect(page.getByTestId("theme-style-trigger")).toHaveAttribute(
|
|
"aria-expanded",
|
|
"true",
|
|
);
|
|
|
|
const swatchBoxes = await page
|
|
.getByTestId("accent-color-options")
|
|
.locator("button")
|
|
.evaluateAll((buttons) =>
|
|
buttons.map((button) => {
|
|
const box = button.getBoundingClientRect();
|
|
return { x: box.x, y: box.y };
|
|
}),
|
|
);
|
|
expect(swatchBoxes).toHaveLength(10);
|
|
expect(new Set(swatchBoxes.map((box) => Math.round(box.y))).size).toBe(1);
|
|
await expect(page.getByTestId("accent-color-options")).toHaveCSS(
|
|
"overflow-x",
|
|
"auto",
|
|
);
|
|
await expect(page.getByTestId("accent-color-blue")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(
|
|
page.getByTestId("accent-color-blue").getByTestId("accent-color-selection"),
|
|
).toBeVisible();
|
|
await waitForAnimations(page);
|
|
await page.getByTestId("settings-theme").screenshot({
|
|
path: `${SHOTS}/12-appearance-theme-and-accents.png`,
|
|
});
|
|
const accentOptions = page.getByTestId("accent-color-options");
|
|
await accentOptions.scrollIntoViewIfNeeded();
|
|
await waitForAnimations(page);
|
|
await accentOptions.screenshot({
|
|
path: `${SHOTS}/13-appearance-accent-row.png`,
|
|
});
|
|
});
|
|
|
|
test("Buzz light and dark modes apply live without a reload", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "light");
|
|
await expectAppliedBuzzTheme(page, "buzz");
|
|
const lightGradient = await expectBuzzGradientPaint(page, "light");
|
|
|
|
await page.getByTestId("appearance-mode-dark").click();
|
|
await expectAppliedBuzzTheme(page, "buzz-dark");
|
|
const darkGradient = await expectBuzzGradientPaint(page, "dark");
|
|
expect(darkGradient).not.toBe(lightGradient);
|
|
|
|
await page.getByTestId("appearance-mode-light").click();
|
|
await expectAppliedBuzzTheme(page, "buzz");
|
|
await expectBuzzGradientPaint(page, "light");
|
|
|
|
// Exercise the overlap that previously let a slower, stale theme load win.
|
|
await page.getByTestId("appearance-mode-dark").click();
|
|
await page.getByTestId("appearance-mode-light").click();
|
|
await expectAppliedBuzzTheme(page, "buzz");
|
|
});
|
|
|
|
test("Buzz follows native system theme changes without a reload", async ({
|
|
page,
|
|
}) => {
|
|
await seedTheme(page, "buzz");
|
|
await page.addInitScript(() => {
|
|
(window as typeof window & { isTauri?: boolean }).isTauri = true;
|
|
});
|
|
await installMockBridge(page);
|
|
await openAppearance(page, "system");
|
|
|
|
await emitNativeThemeChange(page, "dark");
|
|
await expectAppliedBuzzTheme(page, "buzz-dark", "buzz");
|
|
await expectBuzzGradientPaint(page, "dark");
|
|
|
|
await emitNativeThemeChange(page, "light");
|
|
await expectAppliedBuzzTheme(page, "buzz", "buzz");
|
|
await expectBuzzGradientPaint(page, "light");
|
|
});
|