diff --git a/docs/pr-screenshots/pap-9963/briefing-desktop.png b/docs/pr-screenshots/pap-9963/briefing-desktop.png index 3b35cc16615..8f93503de7a 100644 Binary files a/docs/pr-screenshots/pap-9963/briefing-desktop.png and b/docs/pr-screenshots/pap-9963/briefing-desktop.png differ diff --git a/docs/pr-screenshots/pap-9963/briefing-empty.png b/docs/pr-screenshots/pap-9963/briefing-empty.png index 0b1e30b2fbb..35d9973b55e 100644 Binary files a/docs/pr-screenshots/pap-9963/briefing-empty.png and b/docs/pr-screenshots/pap-9963/briefing-empty.png differ diff --git a/docs/pr-screenshots/pap-9963/briefing-mobile.png b/docs/pr-screenshots/pap-9963/briefing-mobile.png index 775e8e8e02b..493d37b2c46 100644 Binary files a/docs/pr-screenshots/pap-9963/briefing-mobile.png and b/docs/pr-screenshots/pap-9963/briefing-mobile.png differ diff --git a/packages/plugins/plugin-briefs/src/ui/app.tsx b/packages/plugins/plugin-briefs/src/ui/app.tsx index 0be7fff793c..e593fc3bf01 100644 --- a/packages/plugins/plugin-briefs/src/ui/app.tsx +++ b/packages/plugins/plugin-briefs/src/ui/app.tsx @@ -182,9 +182,9 @@ function PageShell({ children, meta, action, preferences }: { }) { return (
-
+

Briefing

-
{meta}
+
{meta}
{preferences} {action} @@ -946,12 +946,16 @@ function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void if (typeof document !== "undefined" && !document.getElementById("briefs-plugin-styles")) { const style = document.createElement("style"); style.id = "briefs-plugin-styles"; + // !important is required so these rules win against the inline + // style attributes set on
and its
; + // attribute-selector specificity (0,1,1) loses to inline (1,0,0,0) otherwise. style.textContent = ` @media (max-width: 700px) { [data-briefs-mobile-tabs] { display: flex !important; } - [data-briefs-section] > header { display: none; } - [data-briefs-section][data-mobile-hidden="true"] { display: none; } + [data-briefs-section] > header { display: none !important; } + [data-briefs-section][data-mobile-hidden="true"] { display: none !important; } [data-briefs-grid] { grid-template-columns: 1fr !important; } + [data-briefs-page-header] > [data-briefs-page-meta] { flex-basis: 100% !important; order: 2 !important; } } `; document.head.appendChild(style); diff --git a/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx new file mode 100644 index 00000000000..157709d7f5a --- /dev/null +++ b/packages/plugins/plugin-briefs/tests/ui/briefing-page.spec.tsx @@ -0,0 +1,116 @@ +/** + * Regression tests for the Briefing page chrome — the parts the screenshot + * fixture used to skip (MobileTabs, Legend, responsive CSS injection). + * + * These tests served as the verification path for the UX review on PAP-9963: + * before the fix, the screenshot fixture hand-wrote the page shell and never + * exercised these components, which masked a CSS-specificity bug that broke + * the mobile section filter. Asserting on the real `` output + * keeps that gap closed. + */ +import { describe, expect, it, vi } from "vitest"; + +import type { BriefCard, BriefPreferences } from "../../src/contracts.js"; +import { gallery } from "./fixtures.js"; + +type PageData = { + cards: BriefCard[]; + preferences: BriefPreferences; + fetchedAt: string; +}; + +const defaultPreferences: BriefPreferences = { + companyId: "company-1", + userId: "user-1", + cadence: "daily", + retentionDays: 14, + doneRetentionHours: 48, + staleAfterDays: 7, + maxUnpinnedCards: 24, + scope: "user", +}; + +let mockPageData: PageData = { + cards: [], + preferences: defaultPreferences, + fetchedAt: "2026-05-22T10:00:00.000Z", +}; + +vi.mock("@paperclipai/plugin-sdk/ui", () => { + return { + useHostNavigation: () => ({ + resolveHref: (to: string) => to, + navigate: () => {}, + linkProps: (to: string) => ({ href: to, onClick: () => {} }), + }), + usePluginAction: () => vi.fn(async () => ({ ok: true })), + usePluginData: (key: string) => { + if (key === "page") { + return { data: mockPageData, loading: false, error: null, refresh: () => {} }; + } + return { data: null, loading: false, error: null, refresh: () => {} }; + }, + usePluginToast: () => vi.fn(), + useHostLocation: () => ({ pathname: "/PAP/briefs", search: "", hash: "" }), + usePluginStream: () => ({ events: [], lastEvent: null, connecting: false, connected: false, error: null, close: () => {} }), + }; +}); + +import { renderToStaticMarkup } from "react-dom/server"; +import { BriefingPage } from "../../src/ui/app.js"; + +const hostContext = { + companyId: "company-1", + companyPrefix: "PAP", + projectId: null, + entityId: null, + entityType: null, + userId: "user-1", +} as const; + +function renderPage(cards: BriefCard[]): string { + mockPageData = { cards, preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z" }; + return renderToStaticMarkup(); +} + +describe("BriefingPage", () => { + it("renders the MobileTabs sticky bar so it appears in screenshots", () => { + const html = renderPage(gallery()); + expect(html).toContain("data-briefs-mobile-tabs"); + expect(html).toMatch(/aria-label="Briefing sections"/); + // All four tabs must be present: All / Needs you / Live / Done. + expect(html).toContain(">All<"); + expect(html).toContain(">Needs you<"); + expect(html).toContain(">Live<"); + expect(html).toContain(">Done<"); + }); + + it("renders the Legend below the section grid", () => { + const html = renderPage(gallery()); + expect(html).toContain("data-briefs-legend"); + }); + + it("marks non-active sections with data-mobile-hidden so the CSS filter has a target", () => { + const html = renderPage(gallery()); + // The default mobile tab is "attention", so every other section must be + // tagged hidden. Without the CSS `!important` fix, this attribute existed + // but did nothing — the test still guards the data contract. + expect(html).toContain('data-briefs-section="live"'); + expect(html).toContain('data-briefs-section="settled"'); + expect(html).toMatch(/data-briefs-section="live"[^>]*data-mobile-hidden="true"/); + expect(html).toMatch(/data-briefs-section="settled"[^>]*data-mobile-hidden="true"/); + }); + + it("tags the page header meta block so mobile reflow CSS can reorder it", () => { + const html = renderPage(gallery()); + expect(html).toContain("data-briefs-page-header"); + expect(html).toContain("data-briefs-page-meta"); + }); + + it("renders the empty state without rendering MobileTabs or sections", () => { + const html = renderPage([]); + expect(html).not.toContain("data-briefs-mobile-tabs"); + expect(html).not.toContain("data-briefs-section"); + expect(html).toContain("No briefs yet"); + }); +}); diff --git a/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx b/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx index 866a391b73f..9e4673b25cb 100644 --- a/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx +++ b/packages/plugins/plugin-briefs/tests/ui/screenshots.spec.tsx @@ -5,15 +5,42 @@ * * Skipped by default — set `BRIEFS_CAPTURE_SCREENSHOTS=1` to opt in. * - * The flag avoids dragging Playwright into the regular fast unit-test run while - * still letting CI / agents capture deterministic fixtures from the same view - * components the host renders. + * Mounts the real `` component (the same React tree the host + * renders) so the captured PNGs reflect MobileTabs, Legend, and the live + * responsive CSS — not a hand-rolled approximation. The Paperclip plugin SDK + * is mocked so `usePluginData("page", …)` returns the gallery fixture. */ import path from "node:path"; import fs from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, describe, expect, it, vi } from "vitest"; +import type { BriefCard, BriefPreferences } from "../../src/contracts.js"; +import { gallery } from "./fixtures.js"; + +type PageData = { + cards: BriefCard[]; + preferences: BriefPreferences; + fetchedAt: string; +}; + +const defaultPreferences: BriefPreferences = { + companyId: "company-1", + userId: "user-1", + cadence: "daily", + retentionDays: 14, + doneRetentionHours: 48, + staleAfterDays: 7, + maxUnpinnedCards: 24, + scope: "user", +}; + +let mockPageData: PageData = { + cards: [], + preferences: defaultPreferences, + fetchedAt: "2026-05-22T10:00:00.000Z", +}; + vi.mock("@paperclipai/plugin-sdk/ui", () => { return { useHostNavigation: () => ({ @@ -22,7 +49,12 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { linkProps: (to: string) => ({ href: to, onClick: () => {} }), }), usePluginAction: () => vi.fn(async () => ({ ok: true })), - usePluginData: () => ({ data: null, loading: false, error: null, refresh: () => {} }), + usePluginData: (key: string) => { + if (key === "page") { + return { data: mockPageData, loading: false, error: null, refresh: () => {} }; + } + return { data: null, loading: false, error: null, refresh: () => {} }; + }, usePluginToast: () => vi.fn(), useHostLocation: () => ({ pathname: "/PAP/briefs", search: "", hash: "" }), usePluginStream: () => ({ events: [], lastEvent: null, connecting: false, connected: false, error: null, close: () => {} }), @@ -30,9 +62,7 @@ vi.mock("@paperclipai/plugin-sdk/ui", () => { }); import { renderToStaticMarkup } from "react-dom/server"; -import { BriefCardView } from "../../src/ui/app.js"; -import { groupCardsIntoSections } from "../../src/ui/view-model.js"; -import { gallery } from "./fixtures.js"; +import { BriefingPage } from "../../src/ui/app.js"; const ENABLED = process.env.BRIEFS_CAPTURE_SCREENSHOTS === "1"; @@ -44,72 +74,53 @@ const outDir = path.resolve(repoRoot, "docs/pr-screenshots/pap-9963"); const desktopWidth = 1440; const mobileWidth = 390; -function staticPage({ cards, viewportWidth }: { cards: ReturnType; viewportWidth: number }) { +const hostContext = { + companyId: "company-1", + companyPrefix: "PAP", + projectId: null, + entityId: null, + entityType: null, + userId: "user-1", +} as const; + +function renderPageHtml({ cards, viewportWidth }: { cards: BriefCard[]; viewportWidth: number }): string { + mockPageData = { cards, preferences: defaultPreferences, fetchedAt: "2026-05-22T10:00:00.000Z" }; const isMobile = viewportWidth < 700; - const sections = groupCardsIntoSections(cards); - const visibleSections = isMobile - ? sections.filter((section) => section.cards.length > 0).slice(0, 1).concat(sections.slice(1)) - : sections; - const active = cards.filter((c) => !c.hidden); - const pinned = active.filter((c) => c.pinned).length; + // BriefingPage injects the responsive stylesheet via document.head on mount. + // SSR doesn't run that side effect, so we inline the same rules in the page + // + - -
-
-

Briefing

-
${active.length} active · ${pinned} pinned · refreshed just now
-
- - -
-
-

Durable cards for areas of work that involve you. Pin the ones you always want to see.

- ${active.length === 0 - ? `
-
No briefs yet
-
Cards appear here once the Briefing Analyst picks up recent work. Pinned cards never expire.
-
` - : visibleSections.map((section) => section.cards.length === 0 ? "" : ` -
-
-

${section.label}

- ${section.cards.length} - -
-
- ${section.cards.map((card) => renderToStaticMarkup( {}} />)).join("")} -
-
- `).join("")} -
- + ${body} `; } @@ -132,9 +143,9 @@ describe.skipIf(!ENABLED)("Briefs screenshots", () => { const tmpDir = await fs.mkdtemp(path.join(__dirname, ".tmp-briefs-")); const cards = gallery(); - const desktopHtml = staticPage({ cards, viewportWidth: desktopWidth }); - const mobileHtml = staticPage({ cards, viewportWidth: mobileWidth }); - const emptyHtml = staticPage({ cards: [], viewportWidth: desktopWidth }); + const desktopHtml = renderPageHtml({ cards, viewportWidth: desktopWidth }); + const mobileHtml = renderPageHtml({ cards, viewportWidth: mobileWidth }); + const emptyHtml = renderPageHtml({ cards: [], viewportWidth: desktopWidth }); const desktopFile = path.join(tmpDir, "briefing-desktop.html"); const mobileFile = path.join(tmpDir, "briefing-mobile.html");