feat(announcements): render the banner surface (PR-4) - #976
Merged
Conversation
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 `<main>`, 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 <noreply@anthropic.com>
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth PR in the feature-announcements epic (spec:
docs/specs/feature-announcements.md). PR-1 shipped the data layer and admin API, PR-2 the user feed and What's New panel, PR-3 the admin pages. Thebannersurface has been authorable and server-computed that whole time — but no SPA surface consumedbannerItem(), so ticking "banner" in the admin form did nothing and there was no way to tell from the UI that the surface was unbuilt. That is what turned up this week when a published banner never appeared.What this adds
components/announcement-bannerrenders the single banner the server picked (§D7) as a strip at the top of the shell:state-*token scale (info/success/warning), as full literal class strings — a concatenatedbg-state-${severity}-50compiles to nothingsummaryline when the author wrote one, falling back totitle(a 140-char title is a heading, not a strip)dismissedackrole="status"+aria-live="polite", never assertive; the ✕ carries the announcement title in itsaria-labelIt writes
seenon render, once per announcement per tab — that is what clears the unread dot for someone who reads the banner and never opens What's New. That write races the user's ✕, and deliberately relies on §D2's monotonic server-side rank rather than ordering the two client-side. The DynamoDB conditional update is the guard; adding a second one here would be the same mistake as #741/#751 in a new place.Body markdown is not rendered — this surface is one line, and the full body lives in What's New. That also sidesteps the inert-
prosetrap.Layout
The strip is a flex child of the shell's
<main>, above the scroll container, so content reflows rather than hiding underneath. Three pieces of viewport-fixed chrome would otherwise paint over it, so the banner publishes its measured height as--announcement-banner-heightand they offset against it:.chat-topnav-wrappertop: 0top: var(--announcement-banner-height, 0px).chat-container-empty.full-pageinset: 0toptop-4calc(1rem + var(…))Measured rather than hardcoded because the line wraps on narrow viewports. The voice overlay still covers the strip, which is correct — that one is a modal, like a dialog backdrop.
The bug the browser caught
The banner is gated on
isAuthenticated(), not just on chrome.AnnouncementsServiceloads its feed on the first read ofbannerItem(), andresource()loads exactly once — so mounting the banner on the login screen firesGET /announcementsunauthenticated, takes the 401's empty-feed fallback, and never retries. The user would land in the app with announcements silently missing for the life of the tab. No spec would have caught it; the network panel did.Verification
Unit: 14 new specs, including the #974 regression shape — read the derived state while the feed is still empty first, then populate, so a computed that guard-clauses out before touching its signal fails the test.
End to end against dev data with a local app-api (
SKIP_AUTH, dev announcements table), which is what the epic's earlier browser-only bugs argue for:scrollWidth === clientWidth)seenrow in place to rank 2 rather than writing a duplicate — the monotonic contract, observed in DynamoDBbanner: nullwhile the panel entry survives with the record intact (§D1/§D2)Full SPA suite: no new failures. The 3 failures in
admin/marketplace/pages/submission-review.page.spec.tsare pre-existing — cleandevelopfails the same file with 5, consistent with the knownisolate: falsecross-file flake.Not verified in a browser: the
.chat-topnav-wrapperoffset needs a loaded session to render, and the local stack has no inference-api. The same variable in the same stylesheet is proven live by the empty-state overlay fix, and the rule has a0pxfallback, but it is worth a glance on dev after deploy.Remaining in the epic: PR-5 (modal), PR-6 (
/stats).🤖 Generated with Claude Code