Skip to content

feat(announcements): shared announcement centre components - #24

Merged
KruGoL merged 12 commits into
mainfrom
feat/announcements
Aug 4, 2026
Merged

feat(announcements): shared announcement centre components#24
KruGoL merged 12 commits into
mainfrom
feat/announcements

Conversation

@KruGoL

@KruGoL KruGoL commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Shared UI for the platform announcement centre: the bell and its popover, the announcement modal, the row, a small Markdown renderer, the feed hook, and the design tokens they use. Consumed by sphere, sphere-dev-portal and sphere-quest-frontend; authored in sphere-backoffice.

What is in here

  • AnnouncementBell — unread badge, popover list, "View all announcements" footer (rendered only when onViewAll is supplied).
  • AnnouncementModal — full announcement with hero, body and per-portal CTA; focus trap.
  • AnnouncementRow — one entry, shared by the popover and the archive page so both look identical.
  • useAnnouncements — feed, unread count, read state, auto-open selection, background refresh on tab-visible and on a 30-minute interval.
  • theme.ts / icons.ts — priority and type styling from brand tokens, plus the admin-selectable icon set with a per-type default.
  • types.ts — the client port (getFeed, getArchive, markRead, markAllRead, recordClick) each portal implements.
  • media: announcement added as a media owner type, so the composer can upload hero images through the existing pipeline.

Notes for review

  • Only critical and major auto-open as a modal; normal waits in the bell. One modal per session.
  • The popover closes before handing off to onViewAll (06f6951). Its regression test lives here, and nothing in the consuming portals would catch its absence — so any consumer pinning this library must pin a version at or above the one containing that commit.
  • Tailwind class names are emitted as literals, never interpolated: the scanner reads source text, so a template-built class produces no rule in the consumer. theme.test.ts asserts no ${ appears in any pill class.
  • 168 tests pass; npm run typecheck and npm run build are clean. npm run lint cannot run in this repo at all — eslint is not in devDependencies and there is no config; pre-existing and tracked separately.

After merge

Publish via the "Publish to npm" workflow, then the four consumers swap their file:../sphere-ui links for that version and regenerate their lockfiles.

KruGoL added 12 commits August 3, 2026 18:32
… note single-instance rule

- theme.ts: add borderClass back to PriorityTheme so Task 2's coloured left
  edge reads it from the same map instead of a second, independently
  maintained border-colour map. Critical references --announcement-alert
  directly instead of duplicating its rgb triplet; major/normal ported
  unchanged from sphere-backoffice/src/lib/announcementTheme.ts.
- theme.test.ts: new test pinning all three priorities including
  borderClass, plus a source-text regression guard (mirrors backoffice's
  announcementTheme.test.ts) that no pillClass/borderClass line contains an
  unresolved interpolation artefact.
- useAnnouncements.ts: export a named UseAnnouncementsResult return type
  instead of an inferred shape, and document at the top of the hook that it
  is meant to be called once per app with its values passed down, since the
  session-modal-once guarantee is module-level state.
…f an opacity modifier

Replace border-[var(--announcement-alert)]/28 with a plain literal
border-[var(--announcement-alert-border)], backed by a new tokens.css
custom property that bakes in the 0.28 alpha directly. Removes reliance
on Tailwind's opacity modifier applied to a var() arbitrary value, a
form with no precedent elsewhere in this codebase and no guarantee of
support across every consumer's Tailwind version. major/normal already
used no modifier and needed no change. theme.test.ts now also asserts
no borderClass carries an opacity modifier.
…aria attrs

- useAnnouncements: re-arm the alive ref on every effect mount instead of
  only setting it false on cleanup. StrictMode's dev mount->cleanup->mount
  cycle left it permanently false, so isLoading never cleared in any host
  wrapped in <StrictMode> (sphere, sphere-quest-frontend).
- AnnouncementBell: close the popover when a row is clicked. The click
  lands inside rootRef so the outside-click handler never catches it,
  leaving the popover open behind whatever modal onOpenItem triggers.
- AnnouncementBell: add aria-haspopup/aria-expanded to the bell trigger so
  screen readers get a signal the popover opened.

Adds regression tests for all three (StrictMode render, popover-closes-on
row-click, aria attribute assertions).
The popover row centre-cropped the 3:1 hero banner into a 48px square,
showing readers a different framing than the one the author picked, with
no way to control it. Every row now renders only the type glyph — smaller
(34px) and quieter than before — so rows are structurally identical and
the banner keeps its one job in AnnouncementModal, which has the width to
show it uncropped.

The glyph is now selectable per announcement rather than fixed by type:
ANNOUNCEMENT_ICONS/ANNOUNCEMENT_ICON_KEYS define a closed set of lucide
icons (the five type defaults plus a handful of extras), and
iconForAnnouncement(type, icon) resolves an explicit override, falling
back to the type default for anything unset or unrecognised so a bad key
can never render an empty square.
…terval

useAnnouncements fetched the feed exactly once per mount, so a long-lived
tab could show a stale unread badge indefinitely. Add two refresh
triggers - visibilitychange to 'visible', and a 30-minute setInterval
that skips its tick while the document is hidden - both cleaned up on
unmount and both gated on `enabled`.

A background refresh must never open a modal (mid-send / mid-address-entry
is the wrong moment to interrupt), so `load` now takes a 'mount' | 'refresh'
mode: only 'mount' may claim the module-level session flag and set
autoOpenId. The exported `refresh()` uses the same non-opening semantics.
Bootstrap allowance and one-modal-per-session behavior on mount are
unchanged.
The View all announcements footer sits inside the bell's rootRef, so
neither the outside-click handler nor Escape ever fires for it - the same
blind spot handleRowClick already guards against one button higher.
Navigating does not clean it up either: every portal routes its
announcement centre under the same layout as the bell, so the bell stays
mounted across the navigation and the popover hangs over the destination
page until something unrelated closes it.
@KruGoL
KruGoL merged commit 6fd6223 into main Aug 4, 2026
2 checks passed
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