Unread Posting run finished — 412 documents posted, 0 errors.
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 Export ready — Q3 open items (2.4 MB).
PO-88101 approved — final step, by M. Okafor.
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
- The bell — a dropdown off the navbar: unread-count badge on the trigger, latest few items, one "view all" link. Glance surface only.
- 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. - 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.
- Per-item actions — one link into the thing that
happened (secondary button),
one ghost dismiss naming its item in
aria-label. - The byline — source · time; the answer to "where did this come from" without opening it.
Data contract
| Request | Returns / on failure |
|---|---|
| GET /notifications | the 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/read | the item re-rendered read + the new count; idempotent — a second post returns the same state, never an error |
| POST /notifications/:id/dismiss | 204, item removed on next fetch; dismissing an already-dismissed item is 204 too |
| arrival | new 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
| State | What ships |
|---|---|
| Empty | "No notifications" — plain, not celebratory; unlike the inbox, an empty history is not an achievement |
| Unread present | the bell badge carries the count; each unread item carries the worded badge — two channels at both levels |
| All read | bell badge absent (not "0" — a zero badge reads as noise); items keep their "read" byline |
| Dead record | text kept, link removed, stated plainly ("no longer exists") with dismiss as the only action |
| Very long history | paginate at the screen level (load-more); the dropdown never grows beyond its few |
| No JS | the 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