# Notification system overhaul Status: Planned 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. ## Current state 144 `put_flash` calls across the codebase (59 `:info`, 34 `:error`, rest multi-line). Two separate toast implementations: - **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 Both are overlays — fixed-position, outside document flow, disappear on click. **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) **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) ## New approach: two layers ### 1. Inline feedback (contextual) Feedback next to the field or action that triggered it. Persistent until state changes. - **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. Best for: form saves, field validation, toggle changes, individual actions. ### 2. Top banner (page-level) For outcomes not tied to a specific field. - 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 Best for: "Products synced", "Page deleted", auth messages, operation outcomes. ### 3. No floating toasts The fixed-position overlay toast pattern is removed entirely. ## 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` | 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. ## 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 --%>
...
``` **Component change** (`core_components.ex`): ```heex <%!-- Change wrapper class from .admin-toast (fixed position) to .admin-banner (document flow) --%>

{msg}

``` **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 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)} end end def handle_info(:clear_save_status, socket) do {:noreply, assign(socket, :save_status, :idle)} end ``` **Template**: ```heex
<.button type="submit" variant="primary">Save <.inline_feedback status={@save_status} message={@save_error} />
``` ### 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") }) ``` **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