# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)

## Purpose
This document defines Mila’s **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages:

- consistent DaisyUI usage
- typography & spacing
- button intent & labeling
- list/search/filter UX
- tables behavior (row click, tooltips, alignment)
- flash/toast UX (position, stacking, auto-dismiss, tones)
- standard page skeletons (index/detail/form)
- microcopy conventions (German “du” tone)

> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `CODE_GUIDELINES.md`.  
> This document focuses on **visual + UX** consistency and references engineering rules where needed.

---

## 1) Principles

### 1.1 Components first (no raw DaisyUI classes in views)
- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`).
- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents.
- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc.

### 1.2 DaisyUI for look, Tailwind for layout
- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`).
- Tailwind: spacing, alignment, responsiveness.

### 1.3 Semantics over hard-coded colors
- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …).
- **MUST:** Express intent via component props / DaisyUI semantic variants.

---

## 2) Page Skeleton & “Chrome” (mandatory)

### 2.1 Standard page layout
Every authenticated page should follow the same structure:

1) `<.header>` (title + optional subtitle + actions)
2) content area with consistent vertical rhythm (`mt-6 space-y-6`)
3) optional footer actions for forms

**MUST:** Use `<.header>` on every page (except login/public pages).  
**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.

### 2.2 Edit/New form header: Back button left (mandatory)

For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):

- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right).
- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right.

**Template for form pages:**
```heex
<.header>
  <:leading>
    <.button navigate={return_path(@return_to, @resource)} variant="neutral">
      <.icon name="hero-arrow-left" class="size-4" />
      {gettext("Back")}
    </.button>
  </:leading>
  Page title (e.g. “Edit Member” or “New User”)
  <:subtitle>Short explanation.</:subtitle>
  <:actions>
    <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
      {gettext("Save")}
    </.button>
  </:actions>
</.header>
```

If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).

### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm)

Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:

- **Component:** `Layouts.public_page` renders:
  - **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
  - Main content slot, Flash group. No sidebar, no authenticated-layout logic.
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`.
- **Implementation:**
  - **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the library’s Banner is hidden via `show_banner: false`).
  - **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form.
  - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `<Layouts.public_page flash={@flash}>` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in.

## 3) Typography (system)

Use these standard roles:

| Role | Use | Class |
|---|---|---|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
| Subtitle | helper under title | `text-sm text-base-content/85` |
| Section title (H2) | section headings | `text-lg font-semibold` |
| Helper text | under inputs | `text-sm text-base-content/85` |
| Fine print | small hints | `text-xs text-base-content/80` |
| Empty state | no data | `text-base-content/80 italic` |
| Destructive text | danger | `text-error` |

**MUST:** Page titles via `<.header>`.  
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).

**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `<span class="label-text">` as usual; no extra classes needed.

---

## 4) States: Loading, Empty, Error (mandatory consistency)

### 4.1 Loading state
- **MUST:** Show a consistent loading indicator when data is not ready.
- **MUST NOT:** Render empty states while loading (avoid flicker).
- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area.

### 4.2 Empty state pattern
Empty states must be consistent:
- short message
- optional primary CTA (“Create …”)
- optional secondary help link

**Example:**
```heex
<div class="space-y-3">
  <p class="text-base-content/60 italic">No members yet.</p>
  <.button variant="primary" navigate={~p"/members/new"}>Create member</.button>
</div>

### 4.3 Error state pattern
- **MUST:** Use flash/toast for global errors.
- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed.

---

## 5) Buttons (intent, labels, variants)

### 5.1 Decision rule: action vs status
- **MUST:** Button labels describe **actions** (verb-first):
  - ✅ Save, Create member, Send invite, Import CSV
  - ❌ Active, Success, Done (status belongs elsewhere)
- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs.

### 5.2 Standard variants (mandatory set)
Buttons must be rendered via `<.button>` and mapped to DaisyUI internally.

**Supported variants:**
- `primary` (main CTA)
- `secondary` (supporting)
- `neutral` (cancel/back)
- `ghost` (low emphasis; table/toolbars)
- `outline` (alternative CTA)
- `danger` (destructive)
- `link` (inline; rare)
- `icon` (icon-only)

**Sizes:** `sm`, `md` (default), `lg` (rare)

### 5.3 Placement rules
- Header CTA inside `<.header><:actions>`.
- Form footer: primary right; cancel/secondary left.
- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows).

### 5.4 Primary vs Secondary (UX consistency rules)

#### One primary action per screen
- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import).  
- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary.

#### Primary vs Secondary meaning
- Primary = the most important/most common action to complete the user task.
- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis.

#### Order and placement (choose and apply consistently)
We follow these ordering rules:
- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**.
- Forms: Decide once (primary-left OR primary-right) and apply everywhere.
- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge).

#### Cancel/Back consistency
- MUST: Cancel/Back is **never** styled as primary.
- MUST: Cancel/Back placement is consistent across the app (same side, same label).

#### Implementation requirement
- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props.
- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc.

#### Ghost buttons (accessibility requirements)

Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but:

- MUST: Focus indicator is clearly visible (do not remove outlines).
- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1).
- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip.
- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px).
If these cannot be met, use `secondary`/`outline` instead of `ghost`.


---

## 6) Forms (structure + interaction rules)

### 6.1 Structure
- **MUST:** Forms are grouped into `<.form_section title="…">`.
- **MUST:** All inputs via `<.input>`.

### 6.2 Validation timing (consistent UX)
- **MUST:** Validate on submit always.
- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”.
- **MUST:** Define a consistent “when errors appear” rule:
  - Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere).

> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default).

### 6.3 Required fields
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.

### 6.4 Form layout (settings / long forms)
- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths).
- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header).
- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels.

---

## 7) Lists, Search & Filters (mandatory UX consistency)

### 7.1 Standard filter/search bar pattern
- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere).
  - Recommended: top area above the table, aligned with page actions.
- **MUST:** Always provide “Clear filters” when filters are active.
- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently).

### 7.2 URL behavior (UX rule)
- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs.
- Use `push_navigate` for actual page transitions: details, edit, new.

---

## 8) Tables (mandatory UX)

### 8.1 Default behavior: row click opens details
- **DEFAULT:** Clicking a row navigates to the details page.
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
- **Row highlight (CoreComponents):** When `row_click` is set, rows use a neutral background highlight on `hover` and `tr:has(:focus-visible)` (see `assets/css/app.css`), so keyboard focus is visible while mouse-only focus does not appear "stuck". For non-sticky tables, `selected_row_id` can still add a stronger selected ring. For sticky-first-column tables, selection emphasis is handled by the sticky-column accent stripe.

**IMPORTANT (correctness with our `<.table>` CoreComponent):**  
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.

**LiveStream rows:** Do not enumerate `@rows` with `Enum.with_index` in the table template; streams must be consumed only through `:for`. Sticky-first-column zebra striping for those tables is handled in CSS (`nth-child` under `data-sticky-first-col-rows`), not by assigning odd/even classes from an index.

So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.

✅ Correct pattern (one click handler that both stops propagation and triggers an event):
```heex
<.table
  id="members"
  rows={@members}
  row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end}
>
  <:col :let={m} label="Name">
    <%= m.last_name %>, <%= m.first_name %>
  </:col>

  <:col :let={m} label="Newsletter">
    <input
      type="checkbox"
      class="checkbox checkbox-sm"
      checked={m.newsletter}
      phx-click={JS.push("toggle_newsletter", value: %{id: m.id}) |> JS.stop_propagation()}
    />
  </:col>

  <:action :let={m}>
    <.button
      variant="ghost"
      size="sm"
      navigate={~p"/members/#{m.id}/edit"}
      phx-click={JS.stop_propagation()}
    >
      Edit
    </.button>
  </:action>
</.table>

Notes:
- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it won’t trigger row navigation.
- The Edit button also stops propagation to avoid accidental row navigation when clicked.

### 8.2 Tooltips (mandatory where needed)
- **MUST:** Tooltips for:
  - icon-only actions
  - truncated content
  - status badges that require explanation
- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent).
- **MUST NOT:** Scatter ad-hoc tooltip markup in views.

### 8.3 Alignment & density conventions
- **MUST:** Text columns left-aligned.
- **MUST:** Numeric columns right-aligned.
- **MUST:** Action column right-aligned.
- **SHOULD:** Table density is consistent:
  - default density for most tables
  - a single “dense” option only if needed (via a prop, not per-page random classes)

### 8.4 Truncation standard
- **MUST:** Truncate long values consistently (same max widths for name/email-like fields).
- **MUST:** Tooltip reveals full value when truncated.

### 8.5 Loading/Lists/Tables: keep filters visible on desktop
- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-<offset>)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling.
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.

### 8.6 Empty table cells (missing values)
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells.
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".

---

## 9) Flash / Toast messages (mandatory UX)

### 9.1 Location + stacking
- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent).
- **MUST:** Stack all flash messages with consistent spacing.
- **SHOULD:** Newest appears on top.

### 9.2 Auto-dismiss
- **MUST:** Flash messages disappear automatically:
  - info/success: 4–6s
  - warning: 6–8s
  - error: 8–12s (or manual dismiss for critical errors)
- **MUST:** Keep a dismiss button for accessibility and user control.
- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.

### 9.3 Variants (unified)
- Supported semantic variants: `info`, `success`, `warning`, `error`.
- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app.

### 9.4 Accessibility
- Flash must work with screen readers (live region behavior belongs in the flash component implementation).
- See `CODE_GUIDELINES.md` Accessibility → live regions.

---

## 10) Mutations & feedback patterns (create/update/delete/import)

### 10.1 Mutation feedback is always two-part
For create/update/delete:
- **MUST:** Show a toast/flash message
- **MUST:** Show a visible UI update (navigate, row removed, values updated)

No “silent success”.

### 10.2 Destructive actions: one standard confirmation pattern
- **MUST:** All destructive actions use the same confirm style and wording conventions.
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible.

**Recommended copy style:**
- Title/confirm text is clear and specific (what will be deleted, consequences).
- Buttons: `Cancel` (neutral) + `Delete` (danger).

### 10.3 Dialogs and modals (mandatory)
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).

---

## 11) Detail pages (consistent structure)

Detail pages should not drift into random layouts.

**MUST:** Use consistent structure:
- header with primary action (Edit)
- sections/cards for grouped info
- “Danger zone” section at bottom for destructive actions

---

## 12) Navigation rules (UX consistency)

- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs.
- **MUST:** `push_navigate` for page transitions: detail/edit/new.
- **SHOULD:** Back button behavior must feel predictable (URL reflects state).

---

## 13) Microcopy conventions (German “du” tone + glossary)

### 13.1 Tone
- **MUST:** All German user-facing text uses informal address (“du”).
- **MUST:** Use consistent verbs for common actions:
  - Save: “Speichern”
  - Cancel: “Abbrechen”
  - Delete: “Löschen”
  - Edit: “Bearbeiten”

### 13.2 Preferred terms (starter glossary)
- Member: “Mitglied”
- Fee/Contribution: “Beitrag”
- Settings: “Einstellungen”
- Group: “Gruppe”
- Import/Export: “Import/Export”
- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX)

Add to this glossary when new terminology appears.

---

## 14) Destructive actions: Delete flow (canonical)

This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.

### Tables: no row action buttons
- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.

### Navigation: row click → details
- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
- **MUST NOT:** Use the table for primary edit/delete actions.

### Edit: from details header, not from table
- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.

### Delete: only via “Danger zone”
- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.

### Danger zone layout and wording (canonical pattern)
- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.

### Confirmation and button semantics
- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.

### Accessibility
- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).

### Authorization visibility
- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.

---
