# SOCIAL NOTIFICATIONS + TOPBAR CHROME — GOAL DOC

**Status:** §1 notifications DONE at v9.10.375. §2 topbar strip DONE at v9.10.374.
Written at v9.10.370.

---

## ⚠️ CORRECTION — this doc's §1 diagnosis was wrong

Written before reading the code properly, §1 below claims the bell is "structurally correct and
starved" with "almost nothing ever writes a notification row", and proposes building an
`emitNotification()` write path from scratch.

**That was inaccurate.** A far more complete system already existed:

- `buildNotificationsFor()` derives notifications **at read time** — which is the architecture §1.2
  recommends, already implemented, and better than the per-recipient store §1.1 proposed.
- Five categories were already live: engagement on your posts/shorts (likes, reactions, shares,
  reposts, comments), replies to your comments, DMs, incoming friend requests, and new shorts
  subscribers. There is a `read`/`firstSeen` sidecar that self-trims.
- The client was already wired too: `refreshNotifs()` first-loads at 2.5s then polls every 60s while
  the tab is visible, and updates the badge. My claim that "there is no background poll, so the
  badge never lights up" was **also wrong** — I asserted it after grepping for one call site and
  not reading the surrounding code.

The bell looked empty for a much narrower reason: **the two categories the owner named specifically
— @mentions and friends' posts — were the exact two not covered.** No write path needed building;
two read-time passes did. Shipped in v9.10.375 (§5 below).

**Lesson for future sessions:** read the whole subsystem before writing a plan that says it doesn't
exist. Most of §1 below is a plan for something already built — kept only as a record of the
reasoning, not as work to do.

---
**Owner ask (verbatim):** *"i stil dont get notifications in my notifications bell area for when smoeone on my feed posts somentghing or when someone messages me or when someone tags me in a post or mentions me via @ method."* and *"that bar thing that has the feed shorts and me thing should go in this area replacing that 'Home · feed' on top … so the page under that has more room."*

---

## 1. Notifications — why the bell is empty

The bell UI exists (`#tb-notif-btn`, `#tb-notif-badge`, `toggleNotifMenu()`), and there is a
server route `GET /api/social/notifications` plus `POST /api/social/notifications/read`. What is
missing is **production**: almost nothing ever writes a notification row. Per the earlier code
survey, `_notifItems` had only two producers, so the bell is structurally correct and starved.

### The four events the owner expects

| # | Event | Where it must be emitted (server) | Notes |
|---|---|---|---|
| 1 | Someone I follow / am friends with posts | `POST /api/social/posts` | Fan-out to the author's friends. Highest volume — needs care (see §1.2). |
| 2 | Someone messages me | `POST /api/social/dm` | 1 recipient. Cheapest and most valuable — **do this first.** |
| 3 | Someone tags/@mentions me in a post | `POST /api/social/posts` | Parse `@handle` server-side; resolve to user ids. |
| 4 | Someone @mentions me in a comment/reply | `POST /api/social/engage` (comment path) | Same parser as #3. |

Also worth including once the pipe exists, since they're the same shape: likes/emotes on your
post, comments on your post, friend requests + accepts, new follower on your shorts channel.

### 1.1 Design

**One emitter, called from the write paths.** A single `shared/notifications.mjs`:

```
emitNotification(toUserId, { type, actorId, targetType, targetId, preview, ts })
```

Rules it must own, not each call site:
- **Never notify yourself** (`toUserId === actorId` → drop). This is the #1 source of "why am I
  being told about my own post".
- **Dedupe** on `(toUserId, type, targetId, actorId)` within a short window, so an edit or a
  double-submit doesn't stack.
- **Cap + trim** per user (e.g. keep newest 200) so the store can't grow without bound.
- **Respect blocks/unfriend** at read time, not write time.

**Storage.** Follow the existing per-tenant JSON pattern (`readTenantData`/`writeTenantData`)
rather than inventing a store. A notification belongs to the RECIPIENT's tenant.

**Mention parsing must be server-side.** The client already has `guestLinkifyTags()`, but the
client is not authority — a post created through the API directly must still notify. Reuse the
same regex shape as the client (`/(^|[^A-Za-z0-9_&])@([A-Za-z0-9_]{2,30})/g`) and resolve each
handle through the existing user lookup. Cap mentions honoured per post (e.g. 10) so a post
containing 500 handles can't be used to fan-out spam.

### 1.2 The fan-out question — decide before building #1

"Someone on my feed posts something" is the one event that does NOT scale as a stored row per
recipient: one post by a user with N friends writes N notifications. Two options:

- **(a) Store per-recipient** — simple, matches the other three, fine at current scale (16 accounts).
- **(b) Derive at read time** — the feed already knows who you follow; compute "new since your
  last read cursor" on GET and never store anything.

**Recommendation: (b) for post-fan-out, (a) for the targeted events** (DM, mention, like,
comment, friend request). Targeted events are low-volume and genuinely per-person; broadcast
events are just "the feed moved", which the feed itself already knows. This also avoids the
classic bug where unfriending someone leaves their notifications stranded in your list.

### 1.3 Delivery

Start with **polling** — the dashboard already polls several endpoints, so add the unread count
to an existing poll rather than introducing a new timer. Only move to SSE/websocket if the
latency is actually a complaint. `GET /api/social/notifications` must return `{ items, unread }`
so the badge is one number and does not require fetching the whole list.

### 1.4 Security notes (do not skip)

- `/api/social/notifications` is already on the guest allowlist. Confirm it returns **only the
  caller's** rows — resolve the recipient from `currentContentUser()`, never from a query param.
- `preview` text must be truncated and escaped on render; it carries other users' content.
- A notification must never leak a user id, Discord snowflake, or the existence of a private
  channel/DM the recipient can't otherwise see. Mirror the `sanitizeIdentitiesForGuest` posture.
- Add assertions to `test-cc-separation.mjs` (or a new `test-notifications.mjs`): a guest cannot
  read another user's notifications; self-notify is dropped; mention parsing caps out.

### 1.5 Suggested order

1. DM notification (§ #2) — one recipient, immediate payoff, proves the pipe end-to-end.
2. @mention in posts and comments (§ #3, #4) — shared parser.
3. Likes/comments on your own post.
4. Friend requests + accepts.
5. Feed fan-out (§ #1) via the read-time cursor approach.

---

## 2. Move the Feed / Shorts / Me tab strip into the topbar

**Goal:** the tab strip replaces `Home · feed` in the topbar, reclaiming a full row of vertical
space for the content below.

**Why it wasn't done in v9.10.370:** `.ghome-tabs` (oracle.html ~L8274) is emitted from inside a
render template, so it is **re-created on every render**. A one-time DOM move is silently undone
the next time `renderGuestHome()` or `setGuestHomeTab()` runs — it would appear to work and then
break, which is the worst failure mode. It is also a layout change to `oracle.html`, and this
session already had one layout change reverted; `CLAUDE.md` is explicit that visual work is
eyeball-iterated with the owner via the change → screenshot → tune loop, not blind-edited.

**Recommended approach — reparent after every render, not once:**

1. Add an empty mount point in the topbar next to `#view-title`, e.g.
   `<div id="topbar-tabs-mount"></div>`.
2. Add `mountGuestTabsInTopbar()`: if `scope-guest` **and** `activeView === 'home'`, move the
   live `#ghome-tabs` node into the mount and set `#view-title` to `display:none`; otherwise
   restore. Moving the **existing node** (not cloning) preserves every handler and the
   `data-active` underline state.
3. Call it at the end of `renderGuestHome()` and `setGuestHomeTab()` — the two functions that
   re-create the strip — and from `setView()` so leaving home restores the title.
4. Mobile: `.ghome-tabs` is already `display:none` under 900px (L783) and the mobile tab bar
   (`.m-tabbar`) covers that case, so the mount must be a **no-op on mobile** — guard on the same
   900px breakpoint or the strip will vanish into a hidden topbar.

**Verify between steps:** load as guest on desktop and mobile widths, switch Feed → Shorts → Me
→ another view → back, and confirm the strip survives each re-render and the underline still
tracks the active tab.

---

## 3. Feed column width — already shipped, tune freely

v9.10.370 widened the feed column and put it behind one knob at the top of the width block in
`oracle.html`:

```css
:root{
  --feed-col-w: 900px;      /* >=1000px viewports */
  --feed-col-w-xl: 1100px;  /* >=1500px viewports */
}
```

Raise for a wider column and less visible wallpaper, lower for the reverse. At 1920px the XL
value leaves roughly 410px of wallpaper per side. Mobile (<1000px) is a single full-width column
and is deliberately unaffected.

> ⚠️ These are the feed width rules that `CLAUDE.md` lists as do-not-touch. They were changed at
> the owner's explicit request. If a future session is told not to touch feed width, that
> instruction still stands — this doc is the record that this one change was asked for.

---

## 5. What v9.10.375 actually added

Two new read-time passes inside `buildNotificationsFor()`, matching the five that were already
there — no new store, no new write path, no schema change.

**Pass 6 — @mentions of me**, across posts, shorts captions, comments and replies. Parsed
**server-side**: the client linkifies `@handle` for display, but a post created through the API
directly must still notify, so the client can never be the authority on who got tagged. The regex
mirrors the client's (preceded by a non-word character, so `a@b.com` is not a mention) and is built
from the caller's own handle with regex metacharacters escaped. Anything you authored is skipped, so
tagging yourself notifies nobody.

**Pass 7 — new posts by my friends.** Deliberately NOT stored per-recipient: one post by someone
with N friends would write N rows, and unfriending would strand them. Derived from the feed, which
already knows. Bounded **twice** on purpose — a 48h window and a cap of 15 — because this is the
only broadcast source in the system, and without both bounds it would bury every targeted
notification (a DM, a tag) under routine feed traffic. That is the usual way a notification bell
becomes worthless.

No client change was required: the renderer is generic over `title`/`body`, and row clickability
keys on `pid`, which both new kinds carry — so they route to the post detail like any other.

**Still worth doing later:** the `read`/`firstSeen` sidecar keys on synthetic ids, so a first load
after this ships will surface recent mentions and friend posts as unread in one batch. Harmless, but
if it feels noisy, seed `firstSeen` for pre-existing items on first build per user.

---

## 4. Related fixes already shipped (context)

- **v9.10.369** — friend tiles + friend-request rows clickable; @mentions live in comments,
  replies, DMs and every `formatMessage()` surface; browser tab reflects the open profile;
  page wallpaper behind the feed gutters.
- **v9.10.370** — mini profile card now closes on un-hover (it had dismissal on scroll, resize,
  outside click and Escape — every path except the one matching how it opens); and the card's
  authoritative refresh wrote `avatarUrl` while the renderer read `avatar`, so the correct
  picture was fetched every time and then dropped, falling back to the dicebear robot.
- **v9.10.370** — wallpaper SCOPE fix. v9.10.369 painted it app-wide from boot, so a *profile*
  decoration leaked onto Feed, Shorts, Kaiverse and every other view. It is now context-driven:
  someone else's profile shows THEIR wallpaper, your Me tab shows yours, everywhere else shows
  none. Ecosystem-wide theming remains a separate, untouched concern.

### Known rough edge (decide next phase)
The theme editor now has **two overlapping controls**: the older *Background Art (Steam-style —
faded behind your page)* and the newer *Page wallpaper*. They feed the same backdrop (`appBgUrl`
falls back to `themeImageUrl`), which is why an already-saved Background Art started working
immediately — but two controls for one visible result is confusing. Recommend merging into a
single "Profile wallpaper" control with the presets + dim slider, keeping `themeImageUrl` as the
read-fallback for existing saved values so nobody loses their current image.
