Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
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 viaJS.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-alertboxes — 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):
<%!-- Move from AFTER the layout div (current line 228) to INSIDE .admin-layout-content, above <main> --%>
<div class="admin-layout-content">
<header class="admin-topbar">...</header>
<.flash_group flash={@flash} /> <%!-- NEW POSITION --%>
<main class="admin-main">...</main>
</div>
Component change (core_components.ex):
<%!-- 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):
/* 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):
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
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:
.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):
# 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:
<div class="admin-form-actions">
<.button type="submit" variant="primary">Save</.button>
<.inline_feedback status={@save_status} message={@save_error} />
</div>
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):
window.addEventListener("beforeunload", () => {
document.body.classList.add("navigating-away")
})
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.
- Restyle
<.flash>from.admin-toast(fixed) to.admin-banner(document flow) - Move
<.flash_group>in admin layout from after the div to inside content area - Same for shop layout — restyle
.shop-flashto document flow - Build
<.inline_feedback>component incore_components.ex - Add
beforeunloadsuppression for connection error banners - Delete old
.admin-toastand.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