Reserve
Kept for a power cut.
Popover
A card hung from the key that opened it, with a printed leader running back to the key. Not modal: the page stays live around it.
Kit part · Notifications
A key with a counter window in its cap, and a card of messages hung from it. Each unread message has its lamp lit; reading it puts the lamp out and the counter steps down.
Meter 2291 missed three readings
It is back. The gap was filled from the hub’s own log.
Tariff changed to Flux
The schedule now charges between 00:00 and 02:00.
Both finishes, side by side. Hover, pressed and focus are forced with .is-hover, .is-down and .is-focus; checked and disabled are real.
Aluminium
| Part | Nothing unread | Unread | Card out | Focus |
|---|---|---|---|---|
| Counter | ||||
| Lamp |
Graphite
| Part | Nothing unread | Unread | Card out | Focus |
|---|---|---|---|---|
| Counter | ||||
| Lamp |
| Name | Kind | Values | What it does |
|---|---|---|---|
| .tui-notifications__trigger | class | on a .tui-key | The key. Latched while the card is out. |
| .tui-notifications__count | class | with .tui-lcd, data-ghost="88" | The counter window in the cap: two digits, 00 to 99, aria-hidden (the key’s label says it). |
| .tui-lamp in the key | element | data-lamp="amber" | For an icon key: a lamp in the cap’s corner, lit while anything is unread. |
| data-label | attribute | on the key | The first words of its aria-label; the count is added after. “Notifications” by default. |
| .tui-notifications | class | with .tui-popover | The card, up to 372px wide, hung from the key; data-align="end" keeps it under a key at the right of a bar. |
| .tui-notifications__item · data-unread | class · attribute | on <li> | A message. Unread: its lamp lit and its title bold. |
| .tui-notifications__title · time · __body · __actions | class · element | — | The title, when (printed right), one or two lines, and its keys. |
| data-notifications-read-all | attribute | on a flat key | Reads them all: the lamps go out top to bottom, 70ms apart. |
| data-notifications-status | attribute | on a <span> | Rewritten as “3 unread” or “All read”. Put it in a polite live region. |
| .tui-notifications__empty · __foot | class | — | The line shown when the list is empty; a foot for “See all”. |
| updateNotifications(card) · markNotificationsRead(card, items?) | function | kit.mjs | Recount after adding or removing messages; read some or all of them. |
| tui-notifications | event | detail.unread | Fired on the card after every recount. |
| Enter · Space · Escape · Tab | keyboard | — | Open and close from the key; Tab walks the messages’ keys; Escape returns to the key. |
<button class="tui-key tui-notifications__trigger" popovertarget="inbox"
aria-expanded="false" aria-label="Notifications, 2 unread">
<svg …bell…></svg>
<span class="tui-lcd tui-notifications__count" data-ghost="88" aria-hidden="true">02</span>
</button>
<div class="tui-popover tui-notifications" id="inbox" popover role="dialog"
aria-labelledby="inbox-t" data-align="end">
<div class="tui-notifications__head">
<h2 class="tui-label" id="inbox-t">Notifications</h2>
<span class="tui-status" aria-live="polite">
<span class="tui-lamp" data-lamp="amber" data-on></span>
<span data-notifications-status>2 unread</span>
</span>
<button class="tui-key tui-key--flat tui-key--sm" data-notifications-read-all>Mark all read</button>
</div>
<ul class="tui-notifications__list">
<li class="tui-notifications__item" data-unread>
<span class="tui-lamp" data-lamp="amber" data-on></span>
<p class="tui-notifications__title">Firmware 4.2 is ready</p>
<time datetime="2026-09-28T11:02">11:02</time>
<p class="tui-notifications__body">It installs tonight at 02:00.</p>
<div class="tui-notifications__actions"><button class="tui-key tui-key--sm">Install now</button></div>
</li>
</ul>
<p class="tui-notifications__empty">Nothing yet. Messages from the hub arrive here.</p>
</div>import { updateNotifications, markNotificationsRead } from '/kit/kit.mjs';
const card = document.getElementById('inbox');
card.querySelector('ul').insertAdjacentHTML('afterbegin', messageHTML);
updateNotifications(card); // counter, lamps, words, aria-label
card.addEventListener('tui-notifications', (e) => saveUnread(e.detail.unread));Needs kit.css and kit.mjs. How to install the kit.
aria-label carries the count (“Notifications, 3 unread”); the counter window is aria-hidden, so it is not read twice.role="dialog" with a heading; the head’s status is a polite live region, so “All read” is announced after Mark all read.The key carries a real LCD from the kit, cut down to 12px, with a ghost of unlit 8s behind the digits; it always shows two digits, because a display does not vanish at zero. The card is a .tui-popover, so it is placed and given its leader by popover.mjs. notifications.mjs recounts the data-unread messages, rewrites the counter, the status and the key’s label, and on Mark all read puts the lamps out one by one with a 70ms step: the only motion, and it stops under reduced motion.
The counter always shows two digits, even at nothing new; a message is read when someone acts on it, never merely because the card opened.
5 rules in this file, with the foundations they rest on and the anti-patterns that break them. Full rules ship in the brain with the Pass.
Reserve
Kept for a power cut.
A card hung from the key that opened it, with a printed leader running back to the key. Not modal: the page stays live around it.
A value that changes, on grey-green liquid crystal, with a ghost of the unlit segments behind it: clocks, battery, counters.
The only colour in the kit: a lamp that is on. Green is on, amber is waiting, red is recording or failing, blue is linked, white is standing by. Always next to words.