update notification system docs as implementation reference
All checks were successful
deploy / deploy (push) Successful in 1m22s

Rewrote planning doc as a reference guide with:
- decision tree for choosing feedback type
- implementation patterns with code examples
- accessibility requirements
- common mistakes to avoid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-08 07:55:55 +00:00
parent 5e03dccb69
commit 48eb7a9d9c

View File

@ -1,349 +1,215 @@
# Notification system overhaul # Notification system
Status: Planned Status: Complete
Replace floating toast/flash notifications with inline feedback and persistent top banners. Based on usability testing (March 2026) — flash messages overlay UI, auto-dismiss means users miss them, and the overall pattern feels messy. This document describes the notification and feedback patterns used throughout the app. Follow these rules when implementing new features or modifying existing ones.
## Current state ## Core principles
144 `put_flash` calls across the codebase (59 `:info`, 34 `:error`, rest multi-line). Two separate toast implementations: 1. **No floating toasts** — fixed-position overlays are removed entirely
2. **Inline feedback for form saves** — contextual, next to the action
3. **Banners for page-level outcomes** — document flow, persistent until dismissed
4. **Progressive enhancement** — works without JS, enhanced with LiveView
- **Admin** (`core_components.ex` → `.admin-toast`): fixed top-right, 20rem wide, click to dismiss via `JS.push("lv:clear-flash")` + `JS.hide` ## Components
- **Shop** (`shop_components/content.ex` → `.shop-flash`): fixed top-right, theme-aware, CSS slide-in, click anywhere to dismiss
Both are overlays — fixed-position, outside document flow, disappear on click. ### `<.inline_feedback>` — contextual save status
**Existing inline patterns** already in the codebase: Used for form saves that stay on the same page. Shows status next to the save button.
- `<.error>` component — red text below form fields (`.admin-error`) via changeset errors
- Static `.admin-alert` boxes — persistent info/error in auth/setup pages
- `.admin-checklist-banner` — contextual "back to checklist" banners
- `.admin-banner-warning` — full-width warning banner (email settings)
**Top files by flash count:** settings.ex (17), onboarding.ex (9), campaign_form.ex (9), pages/editor.ex (8), theme/index.ex (6), media.ex (6) ```heex
<.button type="submit">Save</.button>
<.inline_feedback status={@save_status} />
```
## New approach: two layers **States:**
- `:idle` — hidden
- `:saving` — spinner + "Saving..."
- `:saved` — green tick + "Saved"
- `:error` — red icon + "Something went wrong" (or custom message)
### 1. Inline feedback (contextual) **Implementation pattern:**
Feedback next to the field or action that triggered it. Persistent until state changes. ```elixir
# Mount
assign(socket, :save_status, :idle)
- **Success:** green tick + "Saved" label next to save button. Auto-clears after 3s (LiveView only). # Validate event — clear status on form change
- **Error:** red border on field + error text below. Stays until fixed. Works identically with and without JS (changeset errors re-render the form). def handle_event("validate", params, socket) do
- **Loading:** subtle spinner + "Saving..." / "Syncing..." while an async action is in-flight. {:noreply, assign(socket, save_status: :idle)}
end
Best for: form saves, field validation, toggle changes, individual actions. # Save event
def handle_event("save", params, socket) do
case Context.save(params) do
{:ok, _} -> {:noreply, assign(socket, :save_status, :saved)}
{:error, _} -> {:noreply, assign(socket, :save_status, :error)}
end
end
```
### 2. Top banner (page-level) **When to use:** Theme editor, page editor, settings forms, media metadata, product details, navigation editor, newsletter drafts.
For outcomes not tied to a specific field. ### `<.flash>` / `<.shop_flash_group>` — page-level banners
- Full-width coloured bar: green (success), red (error), amber (warning) Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay.
- **Document flow** — pushes content down, not an overlay
- **Persistent until dismissed** (close button). No auto-dismiss.
- Positioned at top of content area, below header/nav
Best for: "Products synced", "Page deleted", auth messages, operation outcomes. ```elixir
socket
|> put_flash(:info, "Product sync started")
|> push_navigate(to: ~p"/admin/products")
```
### 3. No floating toasts **Behaviour:**
- Persistent until dismissed (close button)
- Clears automatically on next navigation
- Info messages use `role="status"` + `aria-live="polite"`
- Error messages use `role="alert"` + `aria-live="assertive"`
The fixed-position overlay toast pattern is removed entirely. **When to use:**
- Operation outcomes: "Sync started", "Page deleted", "Campaign sent"
- Auth messages: "Welcome back!", "Logged out"
- Connection/provider status changes
- Any action that navigates to a new page
## Decision tree
```
Is this feedback for a form save that stays on the page?
├─ YES → Use inline_feedback
│ - Add :save_status assign
│ - Clear to :idle on validate
│ - Set to :saved/:error on submit
└─ NO → Does the action navigate away or affect the whole page?
├─ YES → Use put_flash (banner)
│ - :info for success
│ - :error for failures
└─ NO → Use inline_feedback anyway
(contextual is usually better)
```
## Progressive enhancement ## Progressive enhancement
| Scenario | No JS (controller POST → redirect) | With JS (LiveView) | Forms should work without JavaScript. The pattern:
|----------|-------------------------------------|---------------------|
| Save success | Banner via flash (PRG redirect) | Inline "Saved" tick next to button |
| Validation error | Inline field errors (controller re-renders form with changeset) | Inline field errors (LiveView validates on change/submit) |
| Page-level outcome | Banner via flash | Banner via flash |
| Connection lost | N/A (no WebSocket) | Banner via `phx-disconnected` |
Banners are the baseline that works everywhere. Inline feedback is a progressive enhancement on top — LiveView pages get richer feedback, but no-JS pages still get inline validation errors via changeset re-rendering and success banners via flash. 1. Form has both `action` (controller POST) and `phx-submit` (LiveView)
2. With JS: LiveView handles the event, shows inline feedback
3. Without JS: Form POSTs to controller, redirects with flash
## Component spec
### `<.banner>` — replaces `<.flash>`
Reuses the existing flash infrastructure. Migration path: change CSS + move position in layout. Zero LiveView code changes needed — all 144 `put_flash` calls continue working.
**Layout change** (admin):
```heex ```heex
<%!-- Move from AFTER the layout div (current line 228) to INSIDE .admin-layout-content, above <main> --%> <.form
<div class="admin-layout-content"> for={@form}
<header class="admin-topbar">...</header> action={~p"/admin/settings/email"}
<.flash_group flash={@flash} /> <%!-- NEW POSITION --%> method="post"
<main class="admin-main">...</main> phx-change="validate"
</div> phx-submit="save"
>
``` ```
**Component change** (`core_components.ex`): The controller sets a flash and redirects:
```heex
<%!-- Change wrapper class from .admin-toast (fixed position) to .admin-banner (document flow) --%>
<div :if={msg = ...} id={@id} class="admin-banner" ...>
<div class={["admin-banner-inner", kind_class(@kind)]}>
<icon /> <p>{msg}</p> <close button />
</div>
</div>
```
**CSS** (evolves from existing `.admin-banner-warning`):
```css
/* replaces .admin-toast (fixed position) */
.admin-banner {
/* document flow, no position: fixed */
}
.admin-banner-inner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
line-height: 1.5;
}
.admin-banner-info {
background: color-mix(in oklch, var(--t-status-info) 12%, var(--t-surface-base));
color: var(--t-status-info);
border-bottom: 1px solid color-mix(in oklch, var(--t-status-info) 25%, var(--t-surface-base));
}
.admin-banner-error {
background: color-mix(in oklch, var(--t-status-error) 12%, var(--t-surface-base));
color: var(--t-status-error);
border-bottom: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
}
/* .admin-banner-warning already exists and matches this pattern */
```
**No-JS**: Works perfectly. Banner is HTML in document flow — displays without JS. Close button uses `phx-click` so without LiveView the banner stays visible (desired — persistent by design). Flash clears naturally on next navigation.
**Shop layout**: Same treatment — move `<.shop_flash_group>` from fixed overlay to document flow inside the content area.
### `<.inline_feedback>` — new component
Lightweight status indicator placed next to a button or form section.
**Component** (`core_components.ex`):
```elixir ```elixir
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle def update(conn, params) do
attr :message, :string, default: nil
def inline_feedback(assigns) do
~H"""
<span
:if={@status != :idle}
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
role={@status == :error && "alert"}
>
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
<.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
<span>{feedback_text(@status, @message)}</span>
</span>
"""
end
defp feedback_text(:saving, _), do: "Saving..."
defp feedback_text(:saved, nil), do: "Saved"
defp feedback_text(:saved, msg), do: msg
defp feedback_text(:error, nil), do: "Something went wrong"
defp feedback_text(:error, msg), do: msg
defp feedback_text(:idle, _), do: nil
```
**CSS**:
```css
.admin-inline-feedback {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
}
.admin-inline-feedback-saving {
color: var(--admin-text-soft);
}
.admin-inline-feedback-saved {
color: var(--t-status-success, oklch(0.55 0.15 145));
}
.admin-inline-feedback-error {
color: var(--t-status-error);
font-weight: 600;
}
```
**Usage pattern** (LiveView):
```elixir
# Mount
assign(socket, save_status: :idle, save_error: nil)
# Save handler
def handle_event("save", params, socket) do
case Context.save(params) do case Context.save(params) do
{:ok, _} -> {:ok, _} ->
Process.send_after(self(), :clear_save_status, 3000) conn
{:noreply, assign(socket, :save_status, :saved)} |> put_flash(:info, "Settings saved")
{:error, reason} -> |> redirect(to: ~p"/admin/settings")
{:noreply, assign(socket, save_status: :error, save_error: reason)} {:error, changeset} ->
render(conn, :edit, form: to_form(changeset))
end end
end end
def handle_info(:clear_save_status, socket) do
{:noreply, assign(socket, :save_status, :idle)}
end
``` ```
**Template**: **Result:** With JS, user sees instant inline feedback. Without JS, user gets a banner after redirect.
## Accessibility requirements
All notification components must include:
1. **Appropriate role:**
- `role="status"` for info/success (non-urgent)
- `role="alert"` for errors (urgent)
2. **aria-live attribute:**
- `aria-live="polite"` for info/success
- `aria-live="assertive"` for errors
3. **Dismiss buttons:**
- Must be actual `<button>` elements
- Include `aria-label="Close"`
- Icon must have `aria-hidden="true"`
## Files
- **Component:** `lib/berrypod_web/components/core_components.ex``inline_feedback/1`, `flash/1`
- **Shop flash:** `lib/berrypod_web/components/shop_components/content.ex``shop_flash_group/1`
- **Admin CSS:** `assets/css/admin/core.css``.admin-inline-feedback`, `.admin-banner`, `.admin-alert`
- **Shop CSS:** `assets/css/shop/components.css``.shop-flash`
## Examples in codebase
| Page | Feedback type | File |
|------|--------------|------|
| Theme editor | inline_feedback | `lib/berrypod_web/live/admin/theme/index.ex` |
| Page editor | inline_feedback | `lib/berrypod_web/live/admin/pages/editor.ex` |
| Settings | inline_feedback | `lib/berrypod_web/live/admin/settings.ex` |
| Email settings | inline_feedback | `lib/berrypod_web/live/admin/email_settings.ex` |
| Media library | inline_feedback | `lib/berrypod_web/live/admin/media.ex` |
| Product show | inline_feedback | `lib/berrypod_web/live/admin/product_show.ex` |
| Navigation | inline_feedback | `lib/berrypod_web/live/admin/navigation.ex` |
| Newsletter | inline_feedback | `lib/berrypod_web/live/admin/newsletter/campaign_form.ex` |
| Provider form | flash (navigates) | `lib/berrypod_web/live/admin/providers/form.ex` |
| Contact | flash (navigates) | `lib/berrypod_web/live/shop/contact.ex` |
## Common mistakes
**Wrong:** Using `put_flash` for a form save that stays on the page
```elixir
# Don't do this
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, put_flash(socket, :info, "Saved")}
end
```
**Right:** Using inline feedback
```elixir
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, assign(socket, :save_status, :saved)}
end
```
**Wrong:** Forgetting to clear status on validate
```elixir
# Status stays "Saved" even while user is editing
def handle_event("validate", params, socket) do
{:noreply, assign(socket, :form, to_form(params))}
end
```
**Right:** Clear status when form changes
```elixir
def handle_event("validate", params, socket) do
{:noreply, assign(socket, form: to_form(params), save_status: :idle)}
end
```
**Wrong:** Making flash the whole-div clickable
```heex ```heex
<div class="admin-form-actions"> <div phx-click={dismiss()}>
<.button type="submit" variant="primary">Save</.button> <p>{msg}</p>
<.inline_feedback status={@save_status} message={@save_error} /> <button>×</button> <!-- button does nothing -->
</div> </div>
``` ```
### Connection error handling **Right:** Click handler on the close button
```heex
The `client-error` and `server-error` flashes (LiveView disconnect/reconnect) become banners too. Suppress them during intentional navigation to avoid a brief flash on refresh: <div>
<p>{msg}</p>
**JS** (`app.js`): <button phx-click={dismiss()} aria-label="Close">×</button>
```js </div>
window.addEventListener("beforeunload", () => {
document.body.classList.add("navigating-away")
})
``` ```
**CSS**:
```css
.navigating-away #flash-group { display: none; }
```
`beforeunload` fires on refresh/navigation but NOT on genuine connection drops, so the banner still shows when actually needed.
## Migration categories
### Category 1: Form saves → inline feedback
| Current flash | Location | Replacement |
|---|---|---|
| "Theme saved successfully" | Theme editor | Inline "Saved" tick next to save button |
| "Email settings saved" | Email settings | Inline "Saved" tick near save |
| "Settings saved" | Provider form | Inline "Saved" tick near save |
| "Navigation saved" | Navigation editor | Inline "Saved" tick near save |
| "Page settings saved" | Page editor | Inline "Saved" tick near save |
| "Metadata updated" | Media library | Inline "Saved" tick on metadata form |
| "Product updated" | Product show | Inline "Saved" tick near changed field |
### Category 2: Validation errors → inline field errors
These flash-based validation messages become proper inline field errors. Works with and without JS — controller re-renders form with changeset errors, LiveView validates on change.
| Current flash | Location | Replacement |
|---|---|---|
| "Please enter your API token" | Setup wizard | Red border + error on API key field |
| "Missing required fields: {labels}" | Email settings | Red border on each missing field |
| "Please enter your Stripe secret key" | Settings | Red border + error on Stripe key field |
| "Please fill in all required fields" | Contact form | Red border on each empty required field |
| "Please enter a valid email address" | Newsletter | Red border + error on email field |
| "Password must be at least 12 characters" | Setup recover | Red border + error on password field |
| "Please enter a shop name" / "email" | Setup | Red border on relevant field |
### Category 3: State changes → top banner
Page-level outcomes, kept as `put_flash` → render as banner instead of toast:
| Current flash | Location |
|---|---|
| "Shop is now live" / "Shop taken offline" | Settings |
| "Connected to {provider}!" | Provider form |
| "Email provider disconnected" | Email settings |
| "Stripe connected and webhook endpoint created" | Settings |
| "Provider connection deleted" | Providers index |
| "Page deleted" / "Page created" | Pages index |
### Category 4: Operation outcomes → top banner
| Current flash | Location |
|---|---|
| "Sync started for {name}" | Providers/settings |
| "Connected! Product sync started in the background." | Setup |
| "Submission retry enqueued" | Activity log |
| "Image uploaded" / "Image deleted" | Media library |
| "Campaign is being sent!" | Newsletter |
| "Test email sent to {email}" | Email settings |
| "Message sent! We'll get back to you soon." | Contact |
### Category 5: Auth/system → top banner
| Current flash | Location |
|---|---|
| "Welcome back!" | Dashboard |
| "Logged out successfully" | Login page |
| "Invalid email or password" | Login (could be inline under form) |
| "User confirmed successfully" | Email confirm |
### Category 6: Cart feedback → inline
| Current flash | Replacement |
|---|---|
| "Added to basket" | Brief inline confirmation near add button / cart icon badge update |
| "Removed from basket" | Item visually removed, no separate message |
## Implementation plan
### Phase 1: Build components (~2h)
Build both components. Convert flash rendering from toast to banner. No behaviour changes — all existing `put_flash` calls work, just render differently.
1. Restyle `<.flash>` from `.admin-toast` (fixed) to `.admin-banner` (document flow)
2. Move `<.flash_group>` in admin layout from after the div to inside content area
3. Same for shop layout — restyle `.shop-flash` to document flow
4. Build `<.inline_feedback>` component in `core_components.ex`
5. Add `beforeunload` suppression for connection error banners
6. Delete old `.admin-toast` and `.shop-flash-group` (fixed-position) CSS
### Phase 2: Migrate admin forms (~3h)
Replace `put_flash(:info, "Saved")` with inline feedback on the highest-traffic pages:
- Theme editor
- Page editor
- Settings
- Email settings
- Provider forms
Each migration: add `:save_status` assign, swap `put_flash` for `assign(:save_status, :saved)`, add `<.inline_feedback>` to template, add `Process.send_after` auto-clear.
### Phase 3: Migrate remaining admin pages (~2h)
- Media library
- Products
- Activity log
- Newsletter
- Redirects
- Navigation
### Phase 4: Migrate shop pages (~2h)
- Cart (add/remove → inline feedback)
- Contact form (success → replace form with success state)
- Checkout errors
- Auth flows (login, registration, confirmation)
### Phase 5: Migrate setup wizard (~1h)
Update setup flow — ties into onboarding-ux v2 plan. If onboarding tasks A/B are done first, this phase applies inline feedback to the new guided flow.
## Task breakdown
| # | Task | Est | Status |
|---|------|-----|--------|
| 1 | Build banner + inline feedback components, restyle flash rendering | 2h | planned |
| 2 | Migrate admin forms (theme, pages, settings, email, providers) | 3h | planned |
| 3 | Migrate remaining admin pages (media, products, activity, newsletter, redirects, nav) | 2h | planned |
| 4 | Migrate shop pages (cart, contact, checkout, auth) | 2h | planned |
| 5 | Migrate setup wizard notifications | 1h | planned |
Total estimate: ~10h