From 1094914308d05d50bd8a728b480549cb60606ecb Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Sun, 6 Sep 2026 08:40:53 -0600 Subject: [PATCH] feat(announcements): render the banner surface (PR-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `banner` surface has been authorable since PR-1 and computed by the server since PR-2, but nothing in the SPA consumed `bannerItem()` — an admin who ticked "banner" got a field that did nothing, with no way to tell from the UI that the surface was unbuilt. `components/announcement-banner` renders the one banner the server picked (§D7) as a strip at the top of the shell: severity icon and colour from the `state-*` scale, the `summary` line when the author wrote one, an optional CTA, and a ✕ that records a durable `dismissed` ack. It writes `seen` on render — once per announcement per tab — which is what clears the unread dot for someone who reads the banner and never opens What's New. That write races the ✕, and deliberately relies on §D2's monotonic server-side rank rather than ordering the two client-side. Placement is a flex child of the shell's `
`, above the scroll container, so content reflows instead of hiding underneath. Three pieces of viewport-fixed chrome would otherwise paint over it, so the strip publishes its measured height as `--announcement-banner-height` and they offset against it: the chat topnav, the full-page empty-state overlay (which was `inset: 0`), and the two floating sidenav control clusters. The height is measured rather than hardcoded because the line wraps on narrow viewports. The voice overlay still covers it, which is right — that one is a modal. Gated on `isAuthenticated()`, not just chrome. `AnnouncementsService` loads its feed on the first read of `bannerItem()` and `resource()` loads exactly once, so mounting the banner on the login screen would fire `GET /announcements` unauthenticated, take the 401's empty-feed fallback, and never retry — announcements would be missing for the life of the tab. Found in the browser, not by a spec. Verified end to end against dev data with a local app-api: strip renders in light and dark and at 375px with no horizontal overflow, ✕ writes an ack that upgrades the existing `seen` row in place to rank 2 rather than duplicating it, the server then returns `banner: null` while the panel entry survives (§D1/§D2), and deleting the ack brings the banner back. Co-Authored-By: Claude Opus 5 --- frontend/ai.client/src/app/app.html | 17 +- frontend/ai.client/src/app/app.ts | 18 ++ .../announcement-banner.component.spec.ts | 232 ++++++++++++++++++ .../announcement-banner.component.ts | 221 +++++++++++++++++ .../announcements/announcement.model.ts | 4 +- .../announcements/announcements.service.ts | 2 +- .../chat-container.component.css | 14 +- 7 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 frontend/ai.client/src/app/components/announcement-banner/announcement-banner.component.spec.ts create mode 100644 frontend/ai.client/src/app/components/announcement-banner/announcement-banner.component.ts diff --git a/frontend/ai.client/src/app/app.html b/frontend/ai.client/src/app/app.html index 2ebb042d..136655fb 100644 --- a/frontend/ai.client/src/app/app.html +++ b/frontend/ai.client/src/app/app.html @@ -41,7 +41,9 @@ @if (sidenavService.isCollapsed() && !chromeHidden()) { -