From 48eb7a9d9c8e35ec27bfe6b9ef8fbaaef114d2c6 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Mar 2026 07:55:55 +0000 Subject: [PATCH] update notification system docs as implementation reference 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 --- docs/plans/notification-overhaul.md | 484 ++++++++++------------------ 1 file changed, 175 insertions(+), 309 deletions(-) diff --git a/docs/plans/notification-overhaul.md b/docs/plans/notification-overhaul.md index 91e2ba6..9a1de92 100644 --- a/docs/plans/notification-overhaul.md +++ b/docs/plans/notification-overhaul.md @@ -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` -- **Shop** (`shop_components/content.ex` → `.shop-flash`): fixed top-right, theme-aware, CSS slide-in, click anywhere to dismiss +## Components -Both are overlays — fixed-position, outside document flow, disappear on click. +### `<.inline_feedback>` — contextual save status -**Existing inline patterns** already in the codebase: -- `<.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) +Used for form saves that stay on the same page. Shows status next to the save button. -**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 +<.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). -- **Error:** red border on field + error text below. Stays until fixed. Works identically with and without JS (changeset errors re-render the form). -- **Loading:** subtle spinner + "Saving..." / "Syncing..." while an async action is in-flight. +# Validate event — clear status on form change +def handle_event("validate", params, socket) do + {: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) -- **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 +Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay. -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 -| Scenario | No JS (controller POST → redirect) | With JS (LiveView) | -|----------|-------------------------------------|---------------------| -| 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` | +Forms should work without JavaScript. The pattern: -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 -<%!-- Move from AFTER the layout div (current line 228) to INSIDE .admin-layout-content, above
--%> -
-
...
- <.flash_group flash={@flash} /> <%!-- NEW POSITION --%> -
...
-
+<.form + for={@form} + action={~p"/admin/settings/email"} + method="post" + phx-change="validate" + phx-submit="save" +> ``` -**Component change** (`core_components.ex`): -```heex -<%!-- Change wrapper class from .admin-toast (fixed position) to .admin-banner (document flow) --%> -
-
-

{msg}

-
-
-``` +The controller sets a flash and redirects: -**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 -attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle -attr :message, :string, default: nil - -def inline_feedback(assigns) do - ~H""" - - <.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" /> - {feedback_text(@status, @message)} - - """ -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 +def update(conn, params) do case Context.save(params) do {:ok, _} -> - Process.send_after(self(), :clear_save_status, 3000) - {:noreply, assign(socket, :save_status, :saved)} - {:error, reason} -> - {:noreply, assign(socket, save_status: :error, save_error: reason)} + conn + |> put_flash(:info, "Settings saved") + |> redirect(to: ~p"/admin/settings") + {:error, changeset} -> + render(conn, :edit, form: to_form(changeset)) 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 ` ``` -### Connection error handling - -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: - -**JS** (`app.js`): -```js -window.addEventListener("beforeunload", () => { - document.body.classList.add("navigating-away") -}) +**Right:** Click handler on the close button +```heex +
+

{msg}

+ +
``` - -**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