diff --git a/ROADMAP.md b/ROADMAP.md
index 6aaf851..2217f1b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -57,6 +57,7 @@ Shippable here: read only, no accounts, no database, but properly indexed.
- [x] NIP-22 comments and replies, NIP-25 reactions with NIP-09 retraction
- [x] NIP-57 zaps, over WebLN, NIP-47 or an invoice the reader carries
- [x] Editor, publishing kind 30817
+- [x] Notifications, read off the relays by the browser holding the key
- [ ] NIP-37 encrypted drafts
- [ ] Forks, NIP-32 approvals, NIP-84 highlights
@@ -67,4 +68,5 @@ Shippable here: read only, no accounts, no database, but properly indexed.
- [ ] Full revision history, read from the kind 1349 snapshots the crawler
archives, so it is a projection of events rather than server only state
- [ ] Meilisearch
-- [ ] Web Push
+- [ ] Web Push, which reaches a reader whose tab is closed. The list itself is
+ already in Lot 4: what a server adds is the knock on the door, not the news.
diff --git a/apps/web/app/components/chrome.tsx b/apps/web/app/components/chrome.tsx
new file mode 100644
index 0000000..ebe0a71
--- /dev/null
+++ b/apps/web/app/components/chrome.tsx
@@ -0,0 +1,31 @@
+/**
+ * What the header's own controls are drawn with. Shared rather than copied,
+ * because two panels hanging off the same line that drift apart by a pixel read
+ * as two different pieces of software.
+ */
+export const CHROME =
+ "rounded-sm border border-rule px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink";
+
+/**
+ * The header is set in wide-tracked capitals, and everything inside it inherits
+ * that. A panel is not chrome, it is a place to read a sentence and a key, so it
+ * puts the type back to normal and lets what wants the chrome ask for it.
+ */
+export const Panel = ({
+ children,
+ width = "w-[min(20rem,calc(100vw-2rem))]",
+}: {
+ children: React.ReactNode;
+ /**
+ * Wide enough for what it holds. The default suits a sentence and a key; a
+ * list of rows wants more, since every pixel it lacks is taken out of the
+ * sentence in each of them at once.
+ */
+ width?: string;
+}) => (
+
+ {children}
+
+);
diff --git a/apps/web/app/components/discussion/comment.tsx b/apps/web/app/components/discussion/comment.tsx
index a756718..aac581f 100644
--- a/apps/web/app/components/discussion/comment.tsx
+++ b/apps/web/app/components/discussion/comment.tsx
@@ -79,7 +79,10 @@ export const CommentThread = ({
const author = authors[comment.pubkey] ?? null;
return (
-
+ // Addressable, so a notification can land on the comment it is about rather
+ // than on the conversation holding it. The margin is what keeps the header
+ // off it once the browser has scrolled there.
+