Skip to content

feat(notices): host-driven notice banners on the dashboard home - #2667

Open
camielvs wants to merge 8 commits into
banners-03-contractfrom
banners-04-strip
Open

feat(notices): host-driven notice banners on the dashboard home#2667
camielvs wants to merge 8 commits into
banners-03-contractfrom
banners-04-strip

Conversation

@camielvs

@camielvs camielvs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Why

This is the PR that swaps the announcements system for the notices one. Everything below it was preparation; everything above it is optional.

What you get

AnnouncementBanners and src/config/announcements.ts are deleted. NoticeBanners takes the exact slot <AnnouncementBanners /> occupied — first child of DashboardHomeView's BlockStack, dashboard home only, nowhere else in the app.

Scope of this PR is announcement parity, deliberately. A reader can see banners and dismiss the ones the host marked dismissible. That's it. The notice inbox is #2668 and hiding is #2681, so each can be judged — or dropped — on its own.

What parity buys over the deleted version, all of it a consequence of #2666 rather than new surface here:

  • an unrecognised variant no longer crashes the render
  • a body renders as Markdown, so a notice can link and emphasise
  • an optional action button
  • notices appear when the host publishes them, without a reload, and a dismissal in one tab applies in the others
  • NoticeBanners and useNotices are React Compiler-enabled; AnnouncementBanners could not be, because it read a mutable global and called new Date() in render

Two visible differences from the deleted banners, both intentional:

  • Severity order. errorwarningsuccessinfo, rather than host array order, so an outage isn't below a nice-to-know.
  • Three-column grid matching the dashboard's other rows, rather than full-width stacked. A long body scrolls inside its own card with the action pinned below it, so one verbose notice can't push the rest of the page down.

Reviewer notes

src/hooks/useNotices.ts is where the non-obvious code is:

Dismissal is two-tier, and this is the bit worth reading. dismissible: true from the host means "the reader may retire this permanently" → localStorage. A notice without dismissible is mandatory, so it can be cleared for the current session only and returns on the next load. In this PR the banners only ever offer the X on dismissible notices, so the session tier has no UI yet — it's what #2668's inbox and #2681's hide build on, and it is tested directly.

refresh is optional and this is the only caller. One visibilitychange listener, throttled to at most once per 30s, calling source.refresh?.(). Nothing in this feature polls on a timer — no setInterval is added anywhere. If the host doesn't implement refresh, this is a no-op and notices simply update whenever the host publishes.

Store rather than context. Module-level state + useSyncExternalStore, because two unrelated subtrees read it (the banners here, the header button in #2668) and neither owns the other. It also gives a stable snapshot to the React Compiler and picks up the cross-tab storage event for free.

Also here: notices.ts's TangleNoticeAction/TangleNoticeSource stop being exported now that nothing outside the module names them, and knip.json drops both src/config/announcements.ts and src/config/notices.ts from its ignore list — the first because the file is gone, the second because it now has real consumers.

How to test

Install a source in the console and dispatch the event (see src/config/NOTICES.md):

window.__TANGLE_NOTICE_SOURCE__ = {
  version: 1,
  getSnapshot: () => [
    { id: "1", title: "Scheduled maintenance", body: "Submissions paused **09:00–11:00 UTC**.", variant: "warning", dismissible: true },
    { id: "2", title: "Read-only mode", body: "", variant: "error" },
  ],
  subscribe: () => () => {},
};
window.dispatchEvent(new CustomEvent("tangle:notice-source"));
  • Dashboard home shows both, error first, no reload needed.
  • The warning has an X and stays gone after a reload; the error has none.
  • No banners on any other route.
  • With the global unset, the home page is byte-for-byte what it is on master with no announcements installed.

Where this sits

PR
1 #2664 shared Markdown renderer
2 #2666 validated host contract
3 #2667 notice banners on the dashboard home — announcement parity (you are here)
4 #2668 notices button in the header
5 #2681 hide a banner without retiring it

This is the merge point. Stopping here is a complete, shippable replacement for the announcements system. #2668 and #2681 add surface on top and are independently droppable.

camielvs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

🎩 Preview

A preview build has been created at: banners-04-strip/d5d72bf

Comment thread react-compiler.config.js Outdated
Comment thread src/hooks/useBannerInbox.ts Outdated
Comment thread src/components/shared/Banners/BannerRegion.tsx Outdated
Comment thread src/components/shared/Banners/BannerRegion.tsx Outdated
Comment thread src/hooks/useNoticeInbox.ts Outdated
Comment thread src/hooks/useNotices.ts
@camielvs
camielvs force-pushed the banners-03-contract branch from a100783 to eb0c6de Compare August 26, 2026 23:45
@camielvs
camielvs force-pushed the banners-04-strip branch 2 times, most recently from 91b5f62 to 45113d5 Compare August 27, 2026 00:44
@camielvs
camielvs force-pushed the banners-03-contract branch from d9ad21b to 4fb20f2 Compare August 27, 2026 01:18
@camielvs camielvs changed the title feat(notices): app-wide notice strip, replacing the dashboard banners feat(notices): host-driven notice banners on the dashboard home Aug 27, 2026
@camielvs
camielvs marked this pull request as ready for review August 28, 2026 00:02
@camielvs
camielvs requested a review from a team as a code owner August 28, 2026 00:02
@camielvs
camielvs force-pushed the banners-04-strip branch 2 times, most recently from ec2d04d to 8d1683b Compare August 28, 2026 23:30
@camielvs
camielvs force-pushed the banners-03-contract branch from 4fb20f2 to 189fa37 Compare August 28, 2026 23:30
camielvs and others added 7 commits August 28, 2026 16:34
Renders notices from the host contract as a strip directly under the top nav,
so they are visible on every page rather than only on the dashboard home. The
strip scrolls horizontally when there are more notices than fit, sorts them
most-severe-first, and publishes its own height so full-height layouts stay
inside the viewport.

"Hide notices" clears the strip. A notice the host marked dismissible stays
hidden across reloads; one it did not comes back on the next load, since the
host is still asking for it to be shown.

Replaces the old dashboard-only AnnouncementBanners, which read a plain array
off the window and is deleted here along with its config module.

Note for reviewers: the store lands complete, but its read-tracking and
per-notice dismiss are unused until the header UI in the next PR.
Every notice in the strip can now be taken off it, whatever the host said
about dismissibility, because the inbox keeps it reachable. Hiding is
persisted for a dismissible notice and session-only for a mandatory one,
the same split hideStrip already used in bulk. The strip's control is an
eye-off "Hide notice", distinct from the inbox's destructive "Dismiss", so
the two actions do not look alike. isStripHidden becomes hasHiddenBanners,
since a partly-hidden strip must still offer to bring the rest back.

Register both new hooks in react-compiler.config.js — src/hooks is still
opt-out, so a new top-level hook file gets no coverage by default. The
compiler also takes care of the identity of the object useBannerInbox
returns, so a consumer can safely put it in a dep array.

dismissBanner now enforces the same rule hideStrip does: only a notice the
host marked dismissible is retired permanently, everything else is
session-only.

handleStorage ignores the synthetic same-tab event typedStorage dispatches
on every write, so an action publishes once instead of twice. A real
cross-tab event still refreshes, which is now pinned by a test.

isOpen moves into the cached snapshot rather than being read off module
state during render, and the tab-focus refresh is throttled.

BannerCard replaces bodyClassName with a clampBody flag, so the card owns
the classes and the strip only states its intent. The body keeps scrolling
inside the card with the action pinned below it.

resetBannerStateForTests clears the snapshot cache, the session sets, the
open flag and the refresh watcher, so the suites stop reserving ids to
avoid each other.

With banners.ts fully wired, its knip ignore comes off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the contract rename below it. Notices are the domain concept; the
banner row is one surface they appear on, so the store exposes both:
`notices` is everything the reader still has, `banners` the subset showing
on the page. Storage keys move with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewer feedback: match the announcements this replaces — inline with the
page content, dashboard home only, rather than a strip above every route.

Each notice takes one column of the dashboard's three-column grid, so the
cards line up with the panels beneath them. The content-offset publisher goes
with the strip; nothing sits between the top bar and the content any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewer feedback: the action should read as a button, not a text link.

It stays an anchor underneath — it navigates to an external URL, so a real
<button> would be the wrong element and would cost the middle-click,
copy-link and new-tab behaviour a link gets for free. Button asChild gives
it the button's chrome while the accessibility tree still sees a link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hiding gave a reader a way out of a notice the publisher marked mandatory. It
cost a third persisted id list, a bulk restore toggle, and a second dismiss
affordance on every card, for a case the publisher already controls through
is_dismissible and ends_at. Banners now behave as the announcements they
replace: an X on the notices the host allows, nothing on the rest.

InfoBox needs no change at all as a result.
Read state and the open/closed state of the notice inbox move to the PR that
introduces the inbox. What is left here is what the announcements it replaces
did: show the active notices, and retire the ones the host marks dismissible.
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