E01-S15b — Notifications: demote from the header, signal with a dot, future-proof the model
Epic: #201 · Backlog: .ai/specs/backlog-przebudowa.md (E01-S15) · Supersedes in part: .ai/specs/2026-10-17-e01-s15-notifications.md (#273, #323) · Related: #340 (header v8), #365 (desktop search field) · Risk: low (UI move; no migration in this change)
Owner decisions (2026-10-20) are recorded in Resolved decisions at the end. This spec is written under them.
📝 TLDR
Today a signed-in user reaches notifications through a prominent standalone “Powiadomienia” bell in the desktop header (from lg), while phones/tablets use the quieter pattern the owner prefers: a MENU-drawer row plus a count badge on the MENU button (#323). The feed (/powiadomienia, E01-S15) is mostly low-frequency failed-share errors (“niedodanie”) for the one person who added a link, plus in-progress and ready-recipe items. The bell’s weight does not match its value and it competes with the rebuilt desktop search field (#365). Proposed (future behavior): remove the standalone desktop bell, signal “something new” with a small dot on the MENU button at every width, give notifications a stable home via a “Powiadomienia” row in Settings (and keep the MENU-drawer row), and shape the model so a future “ktoś polubił mój przepis” notification drops in without redesigning the dot, the page, or the data layer.
📝 Problem Statement
- Prominence ≠ value. The desktop header gives notifications a full 56 px labelled bell with a numeric badge — the visual weight of “Dodaj przepis” and the search field — although the feed is almost entirely rare “niedodanie” errors. It crowds the navbar the desktop search field (#365) now occupies.
- Cramped navbar for the target group. The app is often used on older people’s smartphones with the system font enlarged (E01 high-readability rule). An enlarged numeric badge grows and can overflow or clip; a labelled bell is worse. There is very little horizontal room, so the header signal must be as small as possible.
- Two patterns by breakpoint. A standalone bell from
lgand a MENU row + count belowlg(#323). The owner wants one quiet pattern everywhere. - No account home. Settings (
/settings, “Mój profil”) is where a signed-in user manages their account, yet notifications are only reachable from a header icon older users miss. - Only one planned growth: likes. A future “someone favorited my recipe” notification has no
recipe_sharesrow — today’s count (problems + unreadReady) and feed derive entirely fromrecipe_shares. The signal and model must absorb a new type without a redesign.
📝 Proposed Solution
- Remove the standalone desktop bell. Delete
NotificationBellfrom thelgheader (and the component, now unused). At every width the MENU button is the only header affordance for notifications. - Dot, not a number. The MENU button carries a small red dot (fixed ~12 px, independent of font scaling) when anything is new, replacing the numeric badge below
lg. Best practice: a dot fits a signal whose exact count is not meaningful, and it is the smallest footprint for a cramped, large-font navbar (eleken, uxcel). The MENU button’s accessible name carries the text equivalent (“nowe powiadomienia”) so the signal is never colour/shape alone (WCAG via Lumen, Orange DS). - Settings is the home.
/settingsgains a “Powiadomienia” row linking to/powiadomienia, with the same dot when something is new. The MENU-drawer “Powiadomienia” row stays and now shows at all widths (it waslg:hidden), also with a dot instead of the red count pill. - The feed page is unchanged.
/powiadomieniakeeps its URL, four sections, cards, actions and the no-JS fallbacks (E01-S15). Reached from the MENU drawer and from Settings. The homeAttentionBanner(problems only, under the greeting) stays — it is the one high-value, loud signal and is separate from the header dot. - Dot rule = today’s meaning, rendered as presence. The dot lights when
notificationBadge(...).count > 0, i.e.problems + unreadReady(owner Q3). Nothing about the rule changes; only number → dot.loadFailedkeeps its distinct neutral-dot state (the signal must not claim “nothing” when the check failed, #137). - Future likes type — model deferred, path documented. No migration in this change. The dot is type-agnostic, so adding likes later needs no signal redesign. Phase 2’s landing spot (a generalized
notificationstable and a union into the feed/badge) is written down in Data Model so the later change is additive only.
Alternatives considered
- Keep a smaller icon-only desktop bell (dot, no label). Still adds a navbar element competing with the search field at exactly the widths where room is tightest; rejected for the MENU dot, which costs zero extra width.
- Keep the numeric count. The number is not meaningful here (one user’s rare items) and grows under font scaling; a dot is smaller and sturdier. Rejected per the owner’s “small dot” and the cramped-navbar constraint.
- Fold the feed into a
/settingssection (drop the page). Loses the stand-alone URL, refresh-safety and the no-JSopenReadyShareform the page gives for free, and reworks a shipped screen. Rejected (owner Q2: keep the page + a Settings link). - Build the
notificationstable now. Unnecessary for a UI demotion; a dot already future-proofs the signal. Deferred (owner Q4).
📝 Architecture
AppHeader (app-header.tsx) — signed-in ├─ (removed) desktop <NotificationBell> ← was lg-only; deleted └─ HeaderMenu ─► AppMenu ├─ MENU button: dot when notificationBadge.count > 0 (all widths now) └─ drawer "Powiadomienia" row: dot (all widths now; was lg:hidden)
/settings (page.tsx) — NEW: "Powiadomienia" row → /powiadomienia, dot from notificationBadge
/powiadomienia (unchanged) — getNotifications() → NotificationsViewHome MemberHome (unchanged) — AttentionBanner (problems only, under greeting)
notificationBadge(getFailedShares()) — unchanged rule (problems + unreadReady); rendered as a dotTakeaway. This is a presentation move: the only data-layer call is the existing getFailedShares → notificationBadge. app-header.tsx, app-menu.tsx and /settings/page.tsx change; /powiadomienia, notifications.ts, failed-shares.ts, notification-badge.ts (the rule) do not. notification-bell.tsx is deleted.
📝 Data Model
No migration in this change. The badge keeps deriving from recipe_shares via the existing getFailedShares (read_at/cleared_at already live from S15).
Phase 2 landing spot (documented, not built). When likes ship, add an additive, owner-gated table:
-- FUTURE, not part of E01-S15b:create table if not exists public.notifications ( id bigint generated always as identity primary key, user_id uuid not null references auth.users(id) on delete cascade, -- the recipient type text not null, -- 'favorite' (room for more) actor_id uuid references auth.users(id) on delete set null, -- who favorited recipe_id bigint references public.recipes(id) on delete cascade, read_at timestamptz, created_at timestamptz not null default now());-- RLS: recipient-only select/update (owner pattern, as recipe_shares).-- Insert path: a SECURITY DEFINER trigger on favorites, so an actor can notify an owner-- whose recipes RLS otherwise hides (favorites of other people's recipes).-- Dot rule generalizes to: problems + unreadReady + count(notifications where read_at is null).The dot’s presence semantics mean the later change adds a term to the count with no UI redesign; a new card type is added to getNotifications. favorite events are notified to the recipe’s first adder (“Moje przepisy” owner), never for self-favorites.
📝 API Contracts
No new endpoints, server actions or query functions. notificationBadge keeps its { count, loadFailed } shape; callers render count > 0 as a dot instead of printing count.
📝 UI/UX
Look stays within E01 tokens (no raw hex), WCAG 2.2 AA, ≥ 48 px targets, high readability.
- MENU button dot (
app-menu.tsx). Whennotifications.count > 0: abg-dangerdot,h-3 w-3,rounded-pill,border-2 border-white, absolutely positioned top-right of the button — no number, nolg:hidden(shown at all widths). The button’s accessible name gains a visually-hidden ”, nowe powiadomienia”. Whennotifications.loadFailed: the existing neutralbg-inkdot + sr-only ”, nie udało się sprawdzić powiadomień” (unchanged but no longerlg:hidden). A fixed-size dot does not grow with the system font, so it never clips the “MENU” caption. - MENU-drawer “Powiadomienia” row (
app-menu.tsx). Shown at all widths (droplg:hiddenon the<li>). The red numeric pill becomes abg-dangerdot (same size as above) placed where the pill was (ml-auto), with sr-only ”, masz nowe powiadomienia”.loadFailedkeeps its “Nie udało się sprawdzić” caption. - Settings row (
/settings). Below the email<dl>, above “Wyloguj”: a full-widthLinkto/powiadomienia,min-h-14,border-2 border-field rounded-field, aBellicon + “Powiadomienia” 19 px (label underlined, like the drawer rows), and aChevronRightaffordance. No live dot and no badge query here — the MENU button’s dot is the app-wide live signal and is visible on the Settings screen too, so loadinggetFailedShareson/settingswould only risk holding up its TTFB (the exact regression #359 removed from the header). The Settings row is the stable entry point, the MENU dot is the signal. - Desktop header. The bell area is gone; the row is logo · search field · (Dodaj przepis from
lg) · MENU. No layout gap — thelg:flexbell wrapper is removed, not hidden.
No change to /powiadomienia, the AttentionBanner, or the brand/header-search elements.
📝 Edge Cases & Failure Scenarios
| Case | Behavior |
|---|---|
| Nothing new | No dot anywhere; MENU name is just “MENU”; Settings row has no dot |
Check failed (loadFailed) | Neutral bg-ink dot on MENU + sr-only text; Settings row shows no dot (avoids a false red); never a confident “0” (#137) |
| Enlarged system font | Dot is fixed h-3 w-3, so it cannot overflow or clip the caption the way a number could |
| Guest | No badge query, no dot (guest has no shares); unchanged |
| Screen reader | Dot is aria-hidden; the text equivalent lives in the control’s accessible name, control-name-first |
| JS disabled | MENU is a <button> opening a <dialog> (unchanged); the Settings row and drawer row are plain links that work; /powiadomienia keeps its no-JS openReadyShare form |
| Future likes added | Dot already covers it once notificationBadge sums the new term; no UI redesign |
📝 Risks & Impact Review
- Shared header (S10 #213, S17 #219, #340, #365).
app-header.tsxandapp-menu.tsxare shared. The change only removes the desktop bell block and swaps number→dot; the search field, Dodaj, logo and guest CTAs are untouched. No breakpoint loses access to the page (MENU drawer row now shows at all widths; Settings row added). - Discoverability trade-off (product call, owner-approved). A dot is quieter than a labelled bell. Mitigations: the loud home
AttentionBannerstill fires for the high-value “wymaga uwagi” case, the MENU row names it, and Settings hosts it. Accepted per the owner’s direction. - Deleting
notification-bell.tsx. Only the desktop header imports it; grep before deleting. Its tests (if any) move/retire with it. - No migration, no deploy ordering. Pure front-end; merging to master deploys via Workers Builds with no DB step.
- Backlog/spec drift. This supersedes the S15 header-bell decisions (#273, #323) for the header only; the S15 page/feed/banner stay authoritative. Backlog E01-S15 and the S15 spec get a superseding note (Phase 1, step 5).
📋 Phasing
- Phase 1 (this PR) — UI demotion. Remove the desktop bell, dot on MENU at all widths, drawer row at all widths with a dot, “Powiadomienia” row in Settings, superseding notes. No DB. Independently shippable and complete on its own.
- Phase 2 (future, with likes, NOT now). The
notificationstable + favorites trigger + a “ktoś polubił Twój przepis” card ingetNotifications;notificationBadgesums the unread-notifications term. Additive migration, its own spec/PR.
📋 Implementation Plan
Phase 1 steps (each leaves the app working and green):
- Remove the desktop bell. In
src/app/components/app-header.tsx, delete theHeaderBellcomponent, thelg:flexSuspense wrapper that renders it, and the now-unused imports (Suspensestays only if still used elsewhere in the file — it is, forHeaderMenu). Deletesrc/app/components/notification-bell.tsxafter grepping that nothing else importsNotificationBell/NotificationBellPlaceholder. - MENU button → dot. In
src/app/components/app-menu.tsx, replace the numeric badge span with a fixed-sizebg-dangerdot and keep the sr-only text (change the count-count phrasing to ”, nowe powiadomienia”); removelg:hiddenfrom the dot and theloadFaileddot so both show at all widths. - Drawer row at all widths + dot. In
app-menu.tsx, droplg:hiddenfrom theNotificationsRow<li>; inNotificationsRow, replace the red numeric pill with the dot + sr-only ”, masz nowe powiadomienia”. - Settings row. In
src/app/(authenticated)/settings/page.tsx, render a plain “Powiadomienia” link row toNOTIFICATIONS_PATH(Bell+ underlined label +ChevronRight). NogetFailedSharescall here (see UI §3). The sharedNotificationDot(src/app/components/notification-dot.tsx, from step 2) is the one dot MENU and any future live surfaces reuse. - Superseding notes. Add a dated “Superseded in part (2026-10-20, E01-S15b)” note to
.ai/specs/2026-10-17-e01-s15-notifications.mdand to the E01-S15 backlog entry in.ai/specs/backlog-przebudowa.md.
Each step above folds in its own test edits so pnpm typecheck && pnpm test passes at the step boundary — step 1 removes notification-bell.test.ts and the NotificationBell mock in app-header.test.ts as it deletes the component; step 2/3 rewrite the app-menu.test.ts dot assertions as they change the markup. (The project merges on green tests per CLAUDE.md; the gate below is the final check.)
- Gate. Run
pnpm typecheck && pnpm lint && pnpm test. - Browser verification via
/api/dev/login?as=full|freeat 375 px (enlarged font) and desktop: dot on the MENU button (check placement on the inlinelgbutton, not only the stacked mobile one), the drawer “Powiadomienia” row, the Settings row, and/powiadomieniastill reachable from both; no desktop bell; no navbar horizontal scroll.
Files
src/app/components/app-header.tsx(remove desktop bell)src/app/components/notification-bell.tsx(delete) + anynotification-bell.test.*src/app/components/app-menu.tsx(MENU dot + drawer row dot, both all-width)src/app/components/notification-dot.tsx(new, shared dot)src/app/(authenticated)/settings/page.tsx(Powiadomienia link row, no badge query).ai/specs/2026-10-17-e01-s15-notifications.md,.ai/specs/backlog-przebudowa.md(superseding notes)- tests under
src/app/components/
Resolved decisions (owner, 2026-10-20)
| # | Question | Decision | Note |
|---|---|---|---|
| Q1 | Desktop bell: keep or drop? | Drop the standalone desktop bell; MENU dot at all widths | Owner delegated with “check best practices / cramped large-font navbar”; researched recommendation: a fixed-size dot on the existing MENU button is the smallest footprint and unifies breakpoints |
| Q2 | Where relative to Settings? | Keep the /powiadomienia page + a Settings link (and the MENU-drawer row) | Keeps URL, refresh-safety, no-JS form |
| Q3 | What lights the dot? | Problems + unread ready (today’s meaning), rendered as presence | No rule change; number → dot |
| Q4 | Future likes data model? | Defer the table; document the Phase-2 notifications table | Dot is type-agnostic; later change is additive only |