busy-office-ui
Menu

Pattern: notification

Who uses it: anyone whose work finishes while they are not watching — a posting run, an export, an approval granted upstream. How often: glanced at many times a day, read fully a few. What "done" looks like: the unread count is zero because each item was seen and either dismissed or followed. This is the surface for things that happened; it never blocks on you. Not for work that needs your action — a row that blocks on a person belongs in the inbox, not here; and not for things happening while you watch — that is an alert or toast's moment. The grill that settled the shape (101.5): it is BOTH a navbar glance and a screen — the bell dropdown shows the latest few, the screen holds the readable history — and both compose from shipped pieces.

The navbar glance — a dropdown with an unread badge

The count is the badge — word plus tone, two channels. The dropdown holds only the latest few and one link to the screen below; history does not belong in a menu.

The screen — readable history with an unread contract

Unread Posting run finished — 412 documents posted, 0 errors.

Open result

Unread Export ready — Q3 open items (2.4 MB).

Download

PO-88101 approved — final step, by M. Okafor.

Open PO

Requisition R-4402 deleted upstream — the record this notification pointed to no longer exists.

The unread contract is two-channel: the badge (word + tone) is the visible cue, and "read" appears in the byline once cleared — never colour alone. The dead-record item keeps its text and loses its link; a notification that 404s is a support call.

Markup

<!-- One item. Unread = a real badge, not a colour; the byline carries
     source · time, and gains "read" when cleared. -->
<article class="bo-alert bo-alert--elevated" aria-label="Unread notification">
  <div>
    <p><span class="bo-badge bo-badge--warning">Unread</span>
    <strong>Posting run finished</strong> — 412 documents, 0 errors.</p>
    <p class="bo-byline bo-byline--compact">Batch · today 09:14</p>
  </div>
  <span class="bo-cluster">
    <a class="bo-btn bo-btn--sm bo-btn--secondary" href="/staging/412">Open result</a>
    <button class="bo-btn bo-btn--sm bo-btn--ghost" type="button">Mark read</button>
  </span>
</article>

Anatomy

  1. The bell — a dropdown off the navbar: unread-count badge on the trigger, latest few items, one "view all" link. Glance surface only.
  2. The list — a stack of alert cards, --elevated: a shadow + raised surface instead of the flat accent-bar strip, so a scanned LIST reads as cards rather than a row of warnings. Not .bo-toast — that carries an entrance animation for content that just arrived, wrong for a static list already in the page. Static severity chrome reused as item chrome either way, with no live-region role — these are present at load, the exact case the live-region rule excludes.
  3. Unread contract — the badge is the visible channel; the byline's "read" word is the second. Mark-read is per-item; "mark all read" belongs next to the filter, not per row. This is the human-monitoring signal — the AI-manages/human-monitors split below names it explicitly.
  4. Per-item actions — one link into the thing that happened (secondary button), one ghost dismiss naming its item in aria-label.
  5. The byline — source · time; the answer to "where did this come from" without opening it.

Data contract

RequestReturns / on failure
GET /notificationsthe list fragment + the unread count for the bell badge; each item carries data-notification-id and data-unread so an agent reads state without scraping prose. On 5xx/timeout: the bell keeps its last-known count and the dropdown shows an inline retry — never a blank menu, since "I don't know" and "you have zero" read as the same thing to a glancing reader
POST /notifications/:id/readthe item re-rendered read + the new count; idempotent — a second post returns the same state, never an error
POST /notifications/:id/dismiss204, item removed on next fetch; dismissing an already-dismissed item is 204 too
arrivalnew items appear on the next poll (hx-trigger="every 60s" on the bell count is enough); the SCREEN never reorders under a reader mid-scroll. The poll must be user-stoppable — WCAG 2.2 SC 2.2.2 applies to auto-updating values from the first tick, with no five-second grace. The bell is app-shell chrome on every screen, so the control belongs in settings as a notification-refresh preference (off included), not as a widget bolted next to the bell — the same reasoning that lets an OS reduced-motion preference satisfy the criterion for animation. A per-screen version of this control is shown on job monitor, where the updating region is the screen

The AI-manages / human-monitors split, stated: agents emit these items and read their state through the data attributes; the human's channels are the bell count, the Unread badges, and the byline trail. A notification an agent marked read on the human's behalf is a contract violation, not a convenience — read-state belongs to the person.

States

StateWhat ships
Empty"No notifications" — plain, not celebratory; unlike the inbox, an empty history is not an achievement
Unread presentthe bell badge carries the count; each unread item carries the worded badge — two channels at both levels
All readbell badge absent (not "0" — a zero badge reads as noise); items keep their "read" byline
Dead recordtext kept, link removed, stated plainly ("no longer exists") with dismiss as the only action
Very long historypaginate at the screen level (load-more); the dropdown never grows beyond its few
No JSthe bell opens its menu via native popovertarget (Popover API), so browsing notifications still works; "Mark read" and "Dismiss" are bare type="button" elements with no script or form wired to them on this page, so they do nothing — and the bell's live-count poll (hx-trigger="every 60s", described in Data contract) needs htmx's own script to run at all

Components used

complexity 2 of 4

↑↓ move Enter open Esc close