Skip to content

feat(announcements): render the banner surface (PR-4) - #976

Merged
philmerrell merged 1 commit into
developfrom
feature/announcement-banner
Sep 6, 2026
Merged

feat(announcements): render the banner surface (PR-4)#976
philmerrell merged 1 commit into
developfrom
feature/announcement-banner

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

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. The banner surface has been authorable and server-computed that whole time — but no SPA surface consumed bannerItem(), 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-banner renders the single banner the server picked (§D7) as a strip at the top of the shell:

  • severity icon and colour from the state-* token scale (info / success / warning), as full literal class strings — a concatenated bg-state-${severity}-50 compiles to nothing
  • the summary line when the author wrote one, falling back to title (a 140-char title is a heading, not a strip)
  • optional CTA link, and a ✕ that records a durable dismissed ack
  • role="status" + aria-live="polite", never assertive; the ✕ carries the announcement title in its aria-label

It writes seen on 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-prose trap.

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-height and they offset against it:

Surface Was Now
.chat-topnav-wrapper top: 0 top: var(--announcement-banner-height, 0px)
.chat-container-empty.full-page inset: 0 explicit sides + the same top
the two floating sidenav control clusters top-4 calc(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. AnnouncementsService loads its feed on the first read of bannerItem(), and resource() loads exactly once — so mounting the banner on the login screen fires GET /announcements unauthenticated, 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:

  • strip renders in light and dark, and at 375px with no horizontal overflow (scrollWidth === clientWidth)
  • on the chat empty state, the admin pages, and correctly absent on minimal-chrome routes
  • ✕ hides it, and the ack upgrades the existing seen row in place to rank 2 rather than writing a duplicate — the monotonic contract, observed in DynamoDB
  • the server then returns banner: null while the panel entry survives with the record intact (§D1/§D2)
  • deleting the ack row brings the banner back, confirming suppression is entirely ack-driven and server-computed

Full SPA suite: no new failures. The 3 failures in admin/marketplace/pages/submission-review.page.spec.ts are pre-existing — clean develop fails the same file with 5, consistent with the known isolate: false cross-file flake.

Not verified in a browser: the .chat-topnav-wrapper offset 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 a 0px fallback, but it is worth a glance on dev after deploy.

Remaining in the epic: PR-5 (modal), PR-6 (/stats).

🤖 Generated with Claude Code

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>
@philmerrell
philmerrell merged commit 3f7d2d2 into develop Sep 6, 2026
4 checks passed
@philmerrell
philmerrell deleted the feature/announcement-banner branch September 6, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant