fix(ui): leave announcement placement to #19511, keep the onboarding gate

The sidebar-docked placement in #19511 already keeps the card off the
account menu. Drop the measured offset here so the two changes do not
conflict; this PR keeps only the first-session gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-06 11:04:06 -06:00
1 parent 05ba1f8a28
commit 73edd8cd68
3 files changed
+7 -92

No files matched your search

+4 -39
View File
@@ -3,10 +3,10 @@ import { test, expect, request as pwRequest, type APIRequestContext, type Page }
/**
* E2E (N-13): the in-app announcement well ("Introducing Connectors" upsell)
*
* 1. stays hidden while a new company's onboarding first task is open, so it
* cannot cover the onboarding questions in the user's first session; and
* 2. where it does render, never covers the sidebar account menu trigger or
* its flyout at a normal desktop size.
* stays hidden while a new company's onboarding first task is open, so it
* cannot cover the onboarding questions or the account menu in the user's
* first session. Placement (never covering the account menu once shown) is
* covered by announcement-placement.spec.ts.
*
* The public feed is mocked so the spec does not depend on what the live feed
* is publishing; the card mirrors the real 2026-09-14-connectors entry.
@@ -21,11 +21,6 @@ const ONE_PIXEL_PNG = Buffer.from(
"base64",
);
type Box = { x: number; y: number; width: number; height: number };
function intersects(a: Box, b: Box) {
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
}
async function createCompany(board: APIRequestContext, name: string) {
const res = await board.post(`${BASE_URL}/api/companies`, { data: { name: `${name}-${Date.now()}` } });
if (!res.ok()) throw new Error(`POST /api/companies → ${res.status()}: ${await res.text()}`);
@@ -98,34 +93,4 @@ test.describe("Announcement well (N-13)", () => {
await page.screenshot({ path: test.info().outputPath("onboarding-task.png") });
await expect(page.getByRole("complementary", { name: "Paperclip announcements" })).toHaveCount(0);
});
test("where the upsell shows, it does not cover the account menu or its flyout", async ({ page }) => {
const company = await createCompany(board, "E2E-N13-Established");
companyIds.push(company.id);
await createIssue(board, company.id, { title: "Ordinary task", status: "todo" });
await page.goto(`/${company.prefix}/dashboard`);
const well = page.getByRole("complementary", { name: "Paperclip announcements" });
await expect(well).toBeVisible({ timeout: 15_000 });
await expect(well.getByRole("link", { name: "Explore connectors" })).toBeVisible();
await page.waitForTimeout(300); // Let the entry animation finish before measuring.
await page.screenshot({ path: test.info().outputPath("established-company.png") });
const trigger = page.getByRole("button", { name: "Open account menu" });
const wellBox = await well.boundingBox();
const triggerBox = await trigger.boundingBox();
expect(wellBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
expect(intersects(wellBox!, triggerBox!), `well ${JSON.stringify(wellBox)} covers account trigger ${JSON.stringify(triggerBox)}`).toBe(false);
// A real click lands on the trigger (Playwright fails if another element would receive it).
await trigger.click({ timeout: 5_000 });
const flyout = page.getByRole("dialog", { name: "Account" });
await expect(flyout).toBeVisible();
const flyoutBox = await flyout.boundingBox();
if (await well.isVisible()) {
const openWellBox = await well.boundingBox();
expect(intersects(openWellBox!, flyoutBox!), "well covers the open account flyout").toBe(false);
}
});
});
+2 -48
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type CSSProperties } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import { useAccountIdentity } from "@/api/companies-query";
@@ -45,47 +45,6 @@ function useOnboardingInProgress(companyId: string | null) {
return query.data.some((issue) => issue.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND && !FINISHED_ONBOARDING_STATUSES.has(issue.status));
}
const ACCOUNT_ROW_SELECTOR = "[data-sidebar-account]";
/**
* Distance in CSS pixels from the viewport bottom to the top of the visible
* sidebar account row, so the desktop well can sit above the account menu
* trigger instead of covering it. Zero when no account row is on screen.
*/
function useAccountRowClearance(enabled: boolean) {
const [clearance, setClearance] = useState(0);
useEffect(() => {
if (!enabled) return;
let frame = 0;
const observed = new Set<Element>();
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => schedule());
function measure() {
let next = 0;
for (const row of document.querySelectorAll(ACCOUNT_ROW_SELECTOR)) {
if (resizeObserver && !observed.has(row)) { observed.add(row); resizeObserver.observe(row); }
const rect = row.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0 || rect.right <= 0 || rect.left >= window.innerWidth) continue;
next = Math.max(next, Math.ceil(window.innerHeight - rect.top));
}
setClearance(next);
}
function schedule() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
}
measure();
const mutationObserver = new MutationObserver(schedule);
mutationObserver.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
return () => {
cancelAnimationFrame(frame);
mutationObserver.disconnect();
resizeObserver?.disconnect();
window.removeEventListener("resize", schedule);
};
}, [enabled]);
return clearance;
}
export function AnnouncementWell({ health }: { health?: HealthStatus }) {
const { userId: accountId, settled } = useAccountIdentity();
const { selectedCompanyId, loading } = useCompany();
@@ -104,14 +63,9 @@ export function AnnouncementWell({ health }: { health?: HealthStatus }) {
}),
});
const modalOpen = useModalOpen(Boolean(announcement));
const accountClearance = useAccountRowClearance(Boolean(announcement));
if (!announcement || modalOpen || (toasts?.length ?? 0) > 0) return null;
return (
<aside
aria-label="Paperclip announcements"
style={{ "--announcement-account-clearance": accountClearance } as CSSProperties}
className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:bottom-(--announcement-desktop-bottom) md:max-h-(--announcement-desktop-max-height)"
>
<aside aria-label="Paperclip announcements" className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:bottom-3 md:max-h-(--announcement-max-height)">
<AnnouncementCard key={announcement.id} announcement={announcement} onDismiss={() => dismiss(announcement.id)} />
</aside>
);
+1 -5
View File
@@ -3560,15 +3560,11 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--announcement-image-height: 8.5rem;
--announcement-image-mobile-height: 6rem;
--announcement-mobile-bottom: calc(var(--spacing) * 19 + env(safe-area-inset-bottom, 0px));
--announcement-max-height: calc(100dvh - var(--spacing) * 6);
--announcement-mobile-max-height: calc(100dvh - var(--announcement-mobile-bottom) - var(--spacing) * 3);
}
.announcement-well {
/* On desktop the well sits above the sidebar account row (measured by
AnnouncementWell as a unitless pixel count) so it never covers the
account menu trigger or its flyout. */
--announcement-desktop-bottom: calc(var(--announcement-account-clearance, 0) * 1px + var(--spacing) * 3);
--announcement-desktop-max-height: calc(100dvh - var(--announcement-desktop-bottom) - var(--spacing) * 3);
animation: announcement-enter var(--motion-duration-fast) var(--motion-ease-standard) both;
}
@keyframes announcement-enter {