berrypod/docs/plans/notification-overhaul.md
jamey 48eb7a9d9c
All checks were successful
deploy / deploy (push) Successful in 1m22s
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 <noreply@anthropic.com>
2026-03-08 07:55:55 +00:00

6.4 KiB
Raw Permalink Blame History

Notification system

Status: Complete

This document describes the notification and feedback patterns used throughout the app. Follow these rules when implementing new features or modifying existing ones.

Core principles

  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

Components

<.inline_feedback> — contextual save status

Used for form saves that stay on the same page. Shows status next to the save button.

<.button type="submit">Save</.button>
<.inline_feedback status={@save_status} />

States:

  • :idle — hidden
  • :saving — spinner + "Saving..."
  • :saved — green tick + "Saved"
  • :error — red icon + "Something went wrong" (or custom message)

Implementation pattern:

# Mount
assign(socket, :save_status, :idle)

# Validate event — clear status on form change
def handle_event("validate", params, socket) do
  {:noreply, assign(socket, save_status: :idle)}
end

# 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

When to use: Theme editor, page editor, settings forms, media metadata, product details, navigation editor, newsletter drafts.

<.flash> / <.shop_flash_group> — page-level banners

Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay.

socket
|> put_flash(:info, "Product sync started")
|> push_navigate(to: ~p"/admin/products")

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"

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

Forms should work without JavaScript. The pattern:

  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
<.form
  for={@form}
  action={~p"/admin/settings/email"}
  method="post"
  phx-change="validate"
  phx-submit="save"
>

The controller sets a flash and redirects:

def update(conn, params) do
  case Context.save(params) do
    {:ok, _} ->
      conn
      |> put_flash(:info, "Settings saved")
      |> redirect(to: ~p"/admin/settings")
    {:error, changeset} ->
      render(conn, :edit, form: to_form(changeset))
  end
end

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.exinline_feedback/1, flash/1
  • Shop flash: lib/berrypod_web/components/shop_components/content.exshop_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

# 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

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

# 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

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

<div phx-click={dismiss()}>
  <p>{msg}</p>
  <button>×</button>  <!-- button does nothing -->
</div>

Right: Click handler on the close button

<div>
  <p>{msg}</p>
  <button phx-click={dismiss()} aria-label="Close">×</button>
</div>