Status: §1 notifications DONE at v9.10.375. §2 topbar strip DONE at v9.10.374.
Written at v9.10.370.
---
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.2recommends, already implemented, and better than the per-recipient store §1.1 proposed.
reposts, comments), replies to your comments, DMs, incoming friend requests, and new shorts
subscribers. There is a read/firstSeen sidecar that self-trims.
refreshNotifs() first-loads at 2.5s then polls every 60s whilethe 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."*
---
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.
POST /api/social/posts | Fan-out to the author's friends. Highest volume — needs care (see §1.2). |POST /api/social/dm | 1 recipient. Cheapest and most valuable — do this first. |POST /api/social/posts | Parse @handle server-side; resolve to user ids. |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.
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:
toUserId === actorId → drop). This is the #1 source of "why am Ibeing told about my own post".
(toUserId, type, targetId, actorId) within a short window, so an edit or adouble-submit doesn't stack.
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.
"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:
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.
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.
/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. channel/DM the recipient can't otherwise see. Mirror the sanitizeIdentitiesForGuest posture.
test-cc-separation.mjs (or a new test-notifications.mjs): a guest cannotread another user's notifications; self-notify is dropped; mention parsing caps out.
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.
---
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.
---
v9.10.370 widened the feed column and put it behind one knob at the top of the width block in
oracle.html:
: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.
---
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 [email protected] 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.
---
replies, DMs and every formatMessage() surface; browser tab reflects the open profile;
page wallpaper behind the feed gutters.
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.
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.
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.