# Code Guidelines and Best Practices

## Purpose

This document serves as a foundational reference for our development team, ensuring consistency, maintainability, and quality in our codebase. It defines standards and best practices for building the Mila membership management application.

## Project Context

We are building a membership management system (Mila) using the following technology stack:

**Backend & Runtime:**
- Elixir `~> 1.15` (currently 1.18.3-otp-27)
- Erlang/OTP 27.3.4
- Phoenix Framework `~> 1.8.0`
- Ash Framework `~> 3.0`
- AshPostgres `~> 2.0`
- Ecto `~> 3.10`
- Postgrex `>= 0.0.0`
- AshAuthentication `~> 4.9`
- AshAuthenticationPhoenix `~> 2.10`
- bcrypt_elixir `~> 3.0`

**Frontend & UI:**
- Phoenix LiveView `~> 1.1.0`
- Phoenix HTML `~> 4.1`
- Tailwind CSS 4.0.9
- DaisyUI (as Tailwind plugin)
- Heroicons v2.2.0
- JavaScript (ES2022)
- esbuild `~> 0.9`

**Database:**
- PostgreSQL 17.6 (dev), 16 (prod)

**Testing:**
- ExUnit (built-in)
- Ecto.Adapters.SQL.Sandbox

**Development Tools:**
- asdf 0.16.5 (version management)
- Just 1.43.0 (task runner)
- Credo `~> 1.7` (code analysis)
- Sobelow `~> 0.14` (security analysis)
- mix_audit `~> 2.1` (dependency audit)

**Infrastructure:**
- Docker & Docker Compose
- Bandit `~> 1.5` (HTTP server)

---

## Table of Contents

1. [Setup and Architectural Conventions](#1-setup-and-architectural-conventions)
2. [Coding Standards and Style](#2-coding-standards-and-style)
3. [Tooling Guidelines](#3-tooling-guidelines)
4. [Testing Standards](#4-testing-standards)
5. [Security Guidelines](#5-security-guidelines)
6. [Performance Best Practices](#6-performance-best-practices)
7. [Documentation Standards](#7-documentation-standards)
8. [Accessibility Guidelines](#8-accessibility-guidelines)

**Related documents:**
- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
- **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields).

---

## 1. Setup and Architectural Conventions

### 1.1 Project Structure

Our project follows a domain-driven design approach using Phoenix contexts and Ash domains:

```
lib/
├── accounts/              # Accounts domain (AshAuthentication)
│   ├── accounts.ex        # Domain definition
│   ├── user.ex            # User resource
│   ├── token.ex           # Token resource
│   ├── user_identity.exs  # User identity helpers
│   └── user/              # User-related modules
│       ├── changes/       # Ash changes for user
│       └── preparations/  # Ash preparations for user
├── membership/            # Membership domain
│   ├── membership.ex      # Domain definition
│   ├── member.ex          # Member resource
│   ├── custom_field.ex               # Custom field (definition) resource
│   ├── custom_field_value.ex        # Custom field value resource
│   ├── setting.ex         # Global settings (singleton resource)
│   ├── group.ex           # Group resource
│   ├── member_group.ex    # MemberGroup join table resource
│   └── email.ex           # Email custom type
├── membership_fees/        # MembershipFees domain
│   ├── membership_fees.ex # Domain definition
│   ├── membership_fee_type.ex    # Membership fee type resource
│   ├── membership_fee_cycle.ex   # Membership fee cycle resource
│   └── changes/           # Ash changes for membership fees
├── mv/authorization/       # Authorization domain
│   ├── authorization.ex   # Domain definition
│   ├── role.ex            # Role resource
│   ├── permission_sets.ex # Hardcoded permission sets
│   └── checks/            # Authorization checks
├── mv/                    # Core application modules
│   ├── accounts/          # Domain-specific logic
│   │   └── user/
│   │       ├── senders/   # Email senders for user actions
│   │       └── validations/
│   ├── email_sync/        # Email synchronization logic
│   │   ├── changes/       # Sync changes
│   │   ├── helpers.ex     # Sync helper functions
│   │   └── loader.ex      # Data loaders
│   ├── membership/        # Domain-specific logic
│   │   └── member/
│   │       └── validations/
│   ├── membership_fees/   # Membership fee business logic
│   │   ├── cycle_generator.ex  # Cycle generation algorithm
│   │   └── calendar_cycles.ex  # Calendar cycle calculations
│   ├── vereinfacht/       # Vereinfacht accounting API integration
│   │   ├── client.ex      # HTTP client (finance-contacts: create, update, find by email)
│   │   ├── vereinfacht.ex # Business logic (sync_member, sync_members_without_contact)
│   │   ├── sync_flash.ex  # Flash message helpers for sync results
│   │   └── changes/        # Ash changes (SyncContact, sync linked member)
│   ├── helpers.ex         # Shared helper functions (ash_actor_opts)
│   ├── constants.ex       # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
│   ├── application.ex     # OTP application
│   ├── mailer.ex          # Email mailer
│   ├── release.ex         # Release tasks
│   ├── repo.ex            # Database repository
│   ├── secrets.ex         # Secret management
│   └── statistics.ex      # Reporting: member/cycle aggregates (counts, sums by year)
├── mv_web/                # Web interface layer
│   ├── components/        # UI components
│   │   ├── core_components.ex
│   │   ├── table_components.ex
│   │   ├── layouts.ex
│   │   └── layouts/       # Layout templates
│   │       ├── sidebar.ex
│   │       └── root.html.heex
│   ├── controllers/       # HTTP controllers
│   │   ├── auth_controller.ex
│   │   ├── page_controller.ex
│   │   ├── locale_controller.ex
│   │   ├── error_html.ex
│   │   ├── error_json.ex
│   │   └── page_html/
│   ├── helpers/           # Web layer helper modules
│   │   ├── member_helpers.ex        # Member display utilities
│   │   ├── membership_fee_helpers.ex # Membership fee formatting
│   │   ├── date_formatter.ex        # Date formatting utilities
│   │   └── field_type_formatter.ex   # Field type display formatting
│   ├── live/              # LiveView modules
│   │   ├── components/    # LiveView-specific components
│   │   │   ├── search_bar_component.ex
│   │   │   └── sort_header_component.ex
│   │   ├── member_live/   # Member CRUD LiveViews
│   │   ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
│   │   ├── custom_field_live/
│   │   ├── user_live/     # User management LiveViews
│   │   ├── role_live/     # Role management LiveViews
│   │   ├── membership_fee_type_live/ # Membership fee type LiveViews
│   │   ├── membership_fee_settings_live.ex # Membership fee settings
│   │   ├── global_settings_live.ex # Global settings
│   │   ├── group_live/     # Group management LiveViews
│   │   ├── import_export_live.ex    # CSV import/export LiveView (mount/events/glue only)
│   │   ├── import_export_live/     # Import/Export UI components
│   │   │   └── components.ex       # custom_fields_notice, template_links, import_form, progress, results
│   │   ├── statistics_live.ex      # Statistics page (aggregates, year filter, joins/exits by year)
│   │   └── contribution_type_live/ # Contribution types (mock-up)
│   ├── auth_overrides.ex  # AshAuthentication overrides
│   ├── endpoint.ex        # Phoenix endpoint
│   ├── gettext.ex         # I18n configuration
│   ├── live_helpers.ex    # LiveView lifecycle hooks and helpers
│   ├── live_user_auth.ex  # LiveView authentication
│   ├── router.ex          # Application router
│   └── telemetry.ex       # Telemetry configuration
├── mv_web.ex              # Web module definition
└── mv.ex                  # Application module definition

test/
├── accounts/              # Accounts domain tests
│   ├── user_test.exs
│   ├── email_sync_edge_cases_test.exs
│   ├── email_uniqueness_test.exs
│   ├── user_email_sync_test.exs
│   ├── user_member_deletion_test.exs
│   └── user_member_relationship_test.exs
├── membership/            # Membership domain tests
│   ├── member_test.exs
│   └── member_email_sync_test.exs
├── mv_web/                # Web layer tests
│   ├── components/        # Component tests
│   │   ├── layouts/
│   │   │   └── navbar_test.exs
│   │   ├── search_bar_component_test.exs
│   │   └── sort_header_component_test.exs
│   ├── controllers/       # Controller tests
│   │   ├── auth_controller_test.exs
│   │   ├── error_html_test.exs
│   │   ├── error_json_test.exs
│   │   ├── oidc_integration_test.exs
│   │   └── page_controller_test.exs
│   ├── live/              # LiveView tests
│   │   └── profile_navigation_test.exs
│   ├── member_live/       # Member LiveView tests
│   │   └── index_test.exs
│   ├── user_live/         # User LiveView tests
│   │   ├── form_test.exs
│   │   └── index_test.exs
│   └── locale_test.exs
├── seeds_test.exs         # Database seed tests
└── support/               # Test helpers
    ├── conn_case.ex       # Controller test helpers
    ├── data_case.ex       # Data layer test helpers
    └── fixtures.ex        # Shared test fixtures (Mv.Fixtures)
```

### 1.2 Module Organization

**Module Naming:**

- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
- **Domains:** Top-level domains are `Mv.Accounts`, `Mv.Membership`, `Mv.MembershipFees`, and `Mv.Authorization`
- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`)
- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`)

**Module Structure:**

```elixir
defmodule Mv.Membership.Member do
  @moduledoc """
  Represents a club member with their personal information and membership status.
  """
  
  use Ash.Resource,
    domain: Mv.Membership,
    data_layer: AshPostgres.DataLayer
  
  # 1. Ash DSL sections in order (see Spark formatter config)
  admin do
    # ...
  end
  
  postgres do
    # ...
  end
  
  resource do
    # ...
  end
  
  code_interface do
    # ...
  end
  
  actions do
    # ...
  end
  
  policies do
    # ...
  end
  
  attributes do
    # ...
  end
  
  relationships do
    # ...
  end
  
  # 2. Public functions
  
  # 3. Private functions
end
```

### 1.2.1 Database Seeds

Seeds are split into **bootstrap** and **dev**:

- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`.
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.

In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds).

### 1.3 Domain-Driven Design

**Use Ash Domains for Context Boundaries:**

Each domain should:
- Have a clear boundary and responsibility
- Define a public API through code interfaces
- Encapsulate business logic within resources
- Handle cross-domain communication explicitly

Example domain definition:

```elixir
defmodule Mv.Membership do
  use Ash.Domain,
    extensions: [AshAdmin.Domain, AshPhoenix]

  admin do
    show? true
  end

  resources do
    resource Mv.Membership.Member do
      define :create_member, action: :create_member
      define :list_members, action: :read
      define :update_member, action: :update_member
      define :destroy_member, action: :destroy
    end
  end
end
```

### 1.4 Dependency Management

- **Use `mix.exs` for all dependencies:** Define versions explicitly
- **Keep dependencies up to date:** Use Renovate for automated updates
- **Version management:** Use `asdf` with `.tool-versions` for consistent environments

### 1.5 Scalability Considerations

- **Database indexing:** Add indexes for frequently queried fields
- **Pagination:** Use Ash's keyset pagination for large datasets (default configured)
- **Background jobs:** Plan for Oban or similar for async processing
- **Caching:** Consider caching strategies for expensive operations
- **Process design:** Use OTP principles (GenServers, Supervisors) for stateful components

---

## 2. Coding Standards and Style

### 2.1 Code Formatting

**Use `mix format` for all Elixir code:**

```bash
mix format
```

**Key formatting rules:**
- **Indentation:** 2 spaces (no tabs)
- **Line length:** Maximum 120 characters (configured in `.credo.exs`)
- **Trailing whitespace:** Not allowed
- **File endings:** Always include trailing newline

**Naming Conventions Summary:**

- **Elixir:** Use `snake_case` for functions/variables, `PascalCase` for modules
- **Phoenix:** Controllers end with `Controller`, LiveViews end with `Live`
- **Ash:** Resources are singular nouns, actions are verb-first (`:create_member`)
- **Files:** Match module names in `snake_case` (`user_controller.ex` for `UserController`)

### 2.2 Function Design

**Verb-First Function Names:**

```elixir
# Good
def create_user(attrs)
def list_members(query)
def send_email(recipient, content)

# Avoid
def user_create(attrs)
def members_list(query)
def email_send(recipient, content)
```

**Use Pattern Matching in Function Heads:**

```elixir
# Good - multiple clauses with pattern matching
def handle_result({:ok, user}), do: {:ok, user}
def handle_result({:error, reason}), do: log_and_return_error(reason)

# Avoid - case/cond when pattern matching suffices
def handle_result(result) do
  case result do
    {:ok, user} -> {:ok, user}
    {:error, reason} -> log_and_return_error(reason)
  end
end
```

**Keep Functions Small and Focused:**

- Aim for functions under 20 lines
- Each function should have a single responsibility
- Extract complex logic into private helper functions

**Use Guard Clauses for Early Returns:**

```elixir
def process_user(nil), do: {:error, :user_not_found}
def process_user(%{active: false}), do: {:error, :user_inactive}
def process_user(user), do: {:ok, perform_action(user)}
```

### 2.3 Error Handling

**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.

**Use Tagged Tuples:**

```elixir
# Standard pattern
{:ok, result} | {:error, reason}

# Examples
def create_member(attrs) do
  case Ash.create(Member, attrs) do
    {:ok, member} -> {:ok, member}
    {:error, error} -> {:error, error}
  end
end
```

**Use `with` for Complex Operations:**

```elixir
def register_user(params) do
  with {:ok, validated} <- validate_params(params),
       {:ok, user} <- create_user(validated),
       {:ok, _email} <- send_welcome_email(user) do
    {:ok, user}
  else
    {:error, reason} -> {:error, reason}
  end
end
```

**Let It Crash (with Supervision):**

Don't defensively program against every possible error. Use supervisors to handle process failures:

```elixir
# In your application.ex
children = [
  Mv.Repo,
  MvWeb.Endpoint,
  {Phoenix.PubSub, name: Mv.PubSub}
]

Supervisor.start_link(children, strategy: :one_for_one)
```

### 2.4 Functional Programming Principles

**Immutability:**

```elixir
# Good - return new data structures
def add_role(user, role) do
  %{user | roles: [role | user.roles]}
end

# Avoid - mutation (not possible in Elixir anyway)
# This is just conceptual - Elixir prevents mutation
```

**Pure Functions:**

Write functions that:
- Return the same output for the same input
- Have no side effects
- Are easier to test and reason about

```elixir
# Pure function
def calculate_total(items) do
  Enum.reduce(items, 0, fn item, acc -> acc + item.price end)
end

# Impure function (side effects)
def create_and_log_user(attrs) do
  Logger.info("Creating user: #{inspect(attrs)}")  # Side effect
  Ash.create!(User, attrs)                         # Side effect
end
```

**Pipe Operator:**

Use the pipe operator `|>` for transformation chains:

```elixir
# Good
def process_members(query) do
  query
  |> filter_active()
  |> sort_by_name()
  |> limit_results(10)
end

# Avoid
def process_members(query) do
  limit_results(sort_by_name(filter_active(query)), 10)
end
```

### 2.5 Elixir-Specific Patterns

**Avoid Using Else with Unless:**

```elixir
# Good
unless user.admin? do
  {:error, :unauthorized}
end

# Avoid - confusing
unless user.admin? do
  {:error, :unauthorized}
else
  perform_admin_action()
end
```

**Use `Enum` over List Comprehensions for Clarity:**

```elixir
# Preferred for readability
users
|> Enum.filter(&(&1.active))
|> Enum.map(&(&1.name))

# List comprehension (use when more concise)
for user <- users, user.active, do: user.name
```

**String Concatenation:**

```elixir
# Good - interpolation
"Hello, #{user.name}!"

# Avoid - concatenation with <>
"Hello, " <> user.name <> "!"
```

---

## 3. Tooling Guidelines

### 3.1 Elixir & Erlang/OTP

**Version Management with asdf:**

Always use the versions specified in `.tool-versions`:

```bash
# Install correct versions
asdf install

# Verify versions
elixir --version  # Should show 1.18.3
erl -version      # Should show 27.3.4
```

**OTP Application Design:**

```elixir
defmodule Mv.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Start the database repository
      Mv.Repo,
      # Start the Telemetry supervisor
      MvWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: Mv.PubSub},
      # Start the Endpoint
      MvWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: Mv.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
```

### 3.2 Phoenix Framework

**Context-Based Organization:**

- Use contexts to define API boundaries
- Keep controllers thin - delegate to contexts or Ash actions
- Avoid direct Repo/Ecto calls in controllers

```elixir
# Good - thin controller
defmodule MvWeb.MemberController do
  use MvWeb, :controller

  def create(conn, %{"member" => member_params}) do
    case Mv.Membership.create_member(member_params) do
      {:ok, member} ->
        conn
        |> put_flash(:info, "Member created successfully.")
        |> redirect(to: ~p"/members/#{member}")
      
      {:error, error} ->
        conn
        |> put_flash(:error, "Failed to create member.")
        |> render(:new, error: error)
    end
  end
end
```

**Phoenix LiveView Best Practices:**

```elixir
defmodule MvWeb.MemberLive.Index do
  use MvWeb, :live_view

  # Use mount for initial setup
  def mount(_params, _session, socket) do
    {:ok, assign(socket, members: [], loading: true)}
  end

  # Use handle_params for URL parameter handling
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  # Use handle_event for user interactions
  def handle_event("delete", %{"id" => id}, socket) do
    # Handle deletion
    {:noreply, socket}
  end

  # Use handle_info for asynchronous messages
  def handle_info({:member_updated, member}, socket) do
    {:noreply, update_member_in_list(socket, member)}
  end
end
```

**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.

**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.

**Component Design:**

```elixir
# Function components for stateless UI elements
def button(assigns) do
  ~H"""
  <button class="btn btn-primary" {@rest}>
    <%= render_slot(@inner_block) %>
  </button>
  """
end

# Use attrs and slots for documentation
attr :id, :string, required: true
attr :title, :string, default: nil
slot :inner_block, required: true

def card(assigns) do
  ~H"""
  <div id={@id} class="card">
    <h2 :if={@title}><%= @title %></h2>
    <%= render_slot(@inner_block) %>
  </div>
  """
end
```

### 3.3 CSV Import Configuration

**CSV Import Limits:**

CSV import functionality supports configurable limits to prevent resource exhaustion:

```elixir
# config/config.exs
config :mv,
  csv_import: [
    max_file_size_mb: 10,  # Maximum file size in megabytes
    max_rows: 1000          # Maximum number of data rows (excluding header)
  ]
```

**Accessing Configuration:**

Use `Mv.Config` helper functions:

```elixir
# Get max file size in bytes
max_bytes = Mv.Config.csv_import_max_file_size_bytes()

# Get max file size in megabytes
max_mb = Mv.Config.csv_import_max_file_size_mb()

# Get max rows
max_rows = Mv.Config.csv_import_max_rows()
```

**Best Practices:**
- Set reasonable limits based on server resources
- Display limits to users in UI
- Validate file size before upload
- Process imports in chunks (default: 200 rows per chunk)
- Cap error collection (default: 50 errors per import)

### 3.4 Page-Level Authorization

**CheckPagePermission Plug:**

Use `MvWeb.Plugs.CheckPagePermission` for page-level authorization:

```elixir
# lib/mv_web/router.ex
defmodule MvWeb.Router do
  use MvWeb, :router
  
  # Add plug to router pipeline
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MvWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug MvWeb.Plugs.CheckPagePermission  # Page-level authorization
  end
end
```

**Permission Set Route Matrix:**

Routes are mapped to permission sets:
- `own_data`: Can access `/profile` and `/members/:id` (own linked member only)
- `read_only`: Can read all data, cannot modify
- `normal_user`: Can read and modify most data
- `admin`: Full access to all routes

**Usage in LiveViews:**

```elixir
# Check page access before mount
def mount(_params, _session, socket) do
  actor = current_actor(socket)
  
  if MvWeb.Authorization.can_access_page?(actor, "/admin/roles") do
    {:ok, assign(socket, :roles, load_roles(actor))}
  else
    {:ok, redirect(socket, to: ~p"/")}
  end
end
```

**Public Paths:**

Public paths (login, OIDC callbacks) are excluded from permission checks automatically.

### 3.5 System Actor Pattern

**When to Use System Actor:**

Some operations must always run regardless of user permissions. These are **systemic operations** that are mandatory side effects:

- **Email synchronization** (Member ↔ User)
- **Email uniqueness validation** (data integrity requirement)
- **Cycle generation** (if defined as mandatory side effect)
- **Background jobs**
- **Seeds**

**Implementation:**

Use `Mv.Helpers.SystemActor.get_system_actor/0` for all systemic operations:

```elixir
# Good - Email sync uses system actor
def get_linked_member(user) do
  system_actor = SystemActor.get_system_actor()
  opts = Helpers.ash_actor_opts(system_actor)
  
  case Ash.get(Mv.Membership.Member, id, opts) do
    {:ok, member} -> member
    {:error, _} -> nil
  end
end

# Bad - Using user actor for systemic operation
def get_linked_member(user, actor) do
  opts = Helpers.ash_actor_opts(actor)  # May fail if user lacks permissions!
  # ...
end
```

**System Actor Details:**

- System actor is a user with admin role (email: "system@mila.local")
- Cached in Agent for performance
- Falls back to admin user from seeds if system user doesn't exist
- Should NEVER be used for user-initiated actions (only systemic operations)

**DO NOT use system actor as a fallback:**

- **Never** fall back to `Mv.Helpers.SystemActor.get_system_actor()` when an actor is missing or nil (e.g. in validations, changes, or when reading from context).
- Fallbacks hide bugs (callers forget to pass actor) and can cause privilege escalation (unauthenticated or low-privilege paths run with system rights).
- If no actor is available, fail explicitly (validation error, Forbidden, or clear error message). Fix the caller to pass the correct actor instead of adding a fallback.
- Use system actor only where the operation is **explicitly** a systemic operation (see list above); never as a "safety net" when actor is absent.

**User Mode vs System Mode:**

- **User Mode**: User-initiated actions use the actual user actor, policies are enforced
- **System Mode**: Systemic operations use system actor, bypass user permissions

**Authorization Bootstrap Patterns:**

Two mechanisms exist for bypassing standard authorization:

1. **system_actor** (systemic operations) - Admin user for operations that must always succeed
   ```elixir
   # Good: Systemic operation
   system_actor = SystemActor.get_system_actor()
   Ash.read(Member, actor: system_actor)
   
   # Bad: User-initiated action
   # Never use system_actor for user-initiated actions!
   ```

2. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies
   ```elixir
   # Good: Bootstrap (seeds, SystemActor loading)
   Accounts.create_user!(%{email: admin_email}, authorize?: false)
   
   # Bad: User-initiated action
   Ash.destroy(member, authorize?: false)  # Never do this!
   ```

**Decision Guide:**
- Use **system_actor** for email sync, cycle generation, validations, and test fixtures
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
- Always document why `authorize?: false` is necessary
- **Note:** NoActor bypass was removed to prevent masking authorization bugs in tests

**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)

### 3.6 Ash Framework

**Resource Definition Best Practices:**

```elixir
defmodule Mv.Membership.Member do
  use Ash.Resource,
    domain: Mv.Membership,
    data_layer: AshPostgres.DataLayer

  # Follow section order from Spark formatter config
  postgres do
    table "members"
    repo Mv.Repo
  end

  attributes do
    uuid_primary_key :id
    
    attribute :first_name, :string do
      allow_nil? false
      public? true
    end
    
    attribute :email, :string do
      allow_nil? false
      public? true
    end
    
    timestamps()
  end

  actions do
    # Define specific actions instead of using defaults.accept_all
    create :create_member do
      accept [:first_name, :last_name, :email]
      
      change fn changeset, _context ->
        # Custom validation or transformation
        changeset
      end
    end

    read :read do
      primary? true
    end

    update :update_member do
      accept [:first_name, :last_name, :email]
    end

    destroy :destroy
  end

  code_interface do
    define :create_member
    define :list_members, action: :read
    define :update_member
    define :destroy_member, action: :destroy
  end

  identities do
    identity :unique_email, [:email]
  end
end
```

**Ash Policies:**

```elixir
policies do
  # Admin can do everything
  policy action_type([:read, :create, :update, :destroy]) do
    authorize_if actor_attribute_equals(:role, :admin)
  end

  # Users can only read and update their own data
  policy action_type([:read, :update]) do
    authorize_if relates_to_actor_via(:user)
  end
end
```

**Ash Validations:**

```elixir
validations do
  validate present(:email), on: [:create, :update]
  validate match(:email, ~r/@/), message: "must be a valid email"
  validate string_length(:first_name, min: 2, max: 100)
end
```

### 3.4 AshPostgres & Ecto

**Migrations with Ash:**

```bash
# Generate migration for all changes
mix ash.codegen --name add_members_table

# Apply migrations
mix ash.setup
```

**Repository Configuration:**

```elixir
defmodule Mv.Repo do
  use AshPostgres.Repo,
    otp_app: :mv

  # Install PostgreSQL extensions
  def installed_extensions do
    ["citext", "uuid-ossp"]
  end
end
```

**Avoid N+1 Queries:**

```elixir
# Good - preload relationships
members = 
  Member
  |> Ash.Query.load(:custom_field_values)
  |> Mv.Membership.list_members!()

# Avoid - causes N+1 queries
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
  # This triggers a query for each member
  Ash.load!(member, :custom_field_values)
end)
```

### 3.5 Authentication (AshAuthentication)

**Resource with Authentication:**

```elixir
defmodule Mv.Accounts.User do
  use Ash.Resource,
    domain: Mv.Accounts,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication]

  authentication do
    strategies do
      password :password do
        identity_field :email
        hashed_password_field :hashed_password
      end

      oidc :oidc do
        client_id fn _, _ -> 
          Application.fetch_env!(:mv, :oidc)[:client_id]
        end
        # ... other config
      end
    end
  end
end
```

### 3.6 Frontend: Tailwind CSS & DaisyUI

**Utility-First Approach:**

```heex
<!-- Good - use utility classes -->
<div class="card bg-base-100 shadow-xl">
  <div class="card-body">
    <h2 class="card-title">Member Name</h2>
    <p class="text-sm text-gray-600">Email: member@example.com</p>
    <div class="card-actions justify-end">
      <button class="btn btn-primary">Edit</button>
    </div>
  </div>
</div>

<!-- Avoid - custom CSS for standard patterns -->
<div class="custom-member-card">
  <h2 class="custom-title">Member Name</h2>
  <!-- ... -->
</div>
```

**Responsive Design:**

```heex
<!-- Use Tailwind's responsive prefixes -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <%= for member <- @members do %>
    <.member_card member={member} />
  <% end %>
</div>
```

**DaisyUI Components:**

```heex
<!-- Leverage DaisyUI component classes -->
<!-- Note: Navbar has been replaced with Sidebar (see lib/mv_web/components/layouts/sidebar.ex) -->
<div class="drawer lg:drawer-open">
  <input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
  <div class="drawer-content">
    <!-- Page content -->
  </div>
  <div class="drawer-side">
    <label for="drawer-toggle" class="drawer-overlay"></label>
    <aside class="w-64 min-h-full bg-base-200">
      <!-- Sidebar content -->
    </aside>
  </div>
</div>
```

**Custom Tailwind Configuration:**

Update `assets/tailwind.config.js` for custom needs:

```javascript
module.exports = {
  content: [
    "../lib/mv_web.ex",
    "../lib/mv_web/**/*.*ex"
  ],
  theme: {
    extend: {
      colors: {
        brand: "#FD4F00",
      }
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
    // DaisyUI loaded from vendor
  ]
}
```

### 3.7 JavaScript & esbuild

**Minimal JavaScript Philosophy:**

Phoenix LiveView handles most interactivity. Use JavaScript only when necessary:

```javascript
// assets/js/app.js
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken}
})

// Show progress bar on live navigation
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())

liveSocket.connect()
```

**Custom Hooks (when needed):**

```javascript
let Hooks = {}

Hooks.DatePicker = {
  mounted() {
    // Initialize date picker
    this.el.addEventListener("change", (e) => {
      this.pushEvent("date_selected", {date: e.target.value})
    })
  }
}

let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})
```

### 3.8 Code Quality: Credo

**Static Code Analysis:**

We use **Credo** for static code analysis to ensure code quality, consistency, and maintainability. Credo checks are **mandatory** and must pass before code can be merged.

**Run Credo Regularly:**

```bash
# Check code quality
mix credo

# Strict mode for CI
mix credo --strict
```

**CI Enforcement:**

- ✅ **All Credo checks must pass in CI pipeline**
- ✅ Pull requests will be blocked if Credo checks fail
- ✅ Run `mix credo --strict` locally before pushing to catch issues early
- ✅ Address all Credo warnings and errors before requesting code review

**Key Credo Checks Enabled:**

- Consistency checks (spacing, line endings, parameter patterns)
- Design checks (FIXME/TODO tags, alias usage)
- Readability checks (max line length: 120, module/function names, **module documentation**)
- Refactoring opportunities (cyclomatic complexity, nesting)
- Warnings (unused operations, unsafe operations)

**Documentation Enforcement:**

- ✅ `Credo.Check.Readability.ModuleDoc` - **ENABLED** (as of November 2025)
- All modules require `@moduledoc` documentation
- Current coverage: 51 @moduledoc declarations across 47 modules (100% core modules)
- CI pipeline enforces documentation standards

**Address Credo Issues:**

```elixir
# Before
def complex_function(user, data, opts) do
  if user.admin? do
    if data.valid? do
      if opts[:force] do
        # deeply nested logic
      end
    end
  end
end

# After - flatten with guard clauses
def complex_function(user, _data, _opts) when not user.admin?,
  do: {:error, :unauthorized}

def complex_function(_user, data, _opts) when not data.valid?,
  do: {:error, :invalid_data}

def complex_function(_user, data, opts) do
  if opts[:force] do
    process_data(data)
  else
    validate_and_process(data)
  end
end
```

### 3.9 Security: Sobelow

**Run Security Analysis:**

```bash
# Security audit
mix sobelow --config

# With verbose output
mix sobelow --config --verbose
```

**Security Best Practices:**

- Never commit secrets to version control
- Use environment variables for sensitive configuration
- Validate and sanitize all user inputs
- Use parameterized queries (Ecto handles this)
- Keep dependencies updated

### 3.10 Dependency Auditing & Updates

**Regular Security Audits:**

```bash
# Audit dependencies for security vulnerabilities
mix deps.audit

# Audit hex packages
mix hex.audit

# Security scan with Sobelow
mix sobelow --config
```

**Update Dependencies:**

```bash
# Update all dependencies
mix deps.update --all

# Update specific dependency
mix deps.update phoenix

# Check for outdated packages
mix hex.outdated
```

### 3.11 Email: Swoosh

**Mailer Configuration:**

```elixir
defmodule Mv.Mailer do
  use Swoosh.Mailer, otp_app: :mv
end
```

**Sending Emails:**

```elixir
defmodule Mv.Accounts.WelcomeEmail do
  use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
  import Swoosh.Email

  def send(user) do
    new()
    |> to({user.name, user.email})
    |> from({"Mila", "noreply@mila.example.com"})
    |> subject("Welcome to Mila!")
    |> render_body("welcome.html", %{user: user})
    |> Mv.Mailer.deliver()
  end
end
```

### 3.12 Internationalization: Gettext

**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.

**Terminology (DE):** Use consistent terms in translations: “Benutzer*in” / “Benutzer*innen” (not “Nutzer*in”), “E-Mail” (with hyphen, capital M), “CSV-Datei” / “CSV-Import” (compound with hyphen). Keep placeholders (e.g. `%{count}`, `%{reason}`) in msgstr identical to msgid where applicable.

**Define Translations:**

```elixir
# In LiveView or controller
gettext("Welcome to Mila")

# With interpolation
gettext("Hello, %{name}!", name: user.name)

# Plural: always pass count binding when message uses %{count}
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)

# Domain-specific translations
dgettext("auth", "Sign in with email")
```

**Extract and Merge:**

```bash
# Extract new translatable strings
mix gettext.extract

# Merge into existing translations
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
```

### 3.13 Task Runner: Just

**Common Commands:**

```bash
# Start development environment
just run

# Run tests
just test

# Run linter
just lint

# Run security audit
just audit

# Reset database
just reset-database

# Format code
just format

# Regenerate migrations
just regen-migrations migration_name
```

**Define Custom Tasks:**

Edit `Justfile` for project-specific tasks:

```makefile
# Example custom task
setup-dev: install-dependencies start-database migrate-database
    mix phx.gen.secret
    @echo "Development environment ready!"
```

---

## 4. Testing Standards

### 4.1 Test Setup and Organization

**Test Directory Structure:**

Mirror the `lib/` directory structure in `test/`:

```
test/
├── accounts/              # Tests for Accounts domain
│   ├── user_test.exs
│   ├── email_sync_test.exs
│   └── ...
├── membership/            # Tests for Membership domain
│   ├── member_test.exs
│   └── ...
├── mv_web/                # Tests for Web layer
│   ├── controllers/
│   ├── live/
│   └── components/
└── support/               # Test helpers
    ├── conn_case.ex       # Controller test setup
    ├── data_case.ex       # Database test setup
    └── fixtures.ex        # Shared test fixtures (Mv.Fixtures)
```

**Test File Naming:**

- Use `_test.exs` suffix for all test files
- Match the module name: `user.ex` → `user_test.exs`
- Use descriptive names for integration tests: `user_member_relationship_test.exs`

### 4.2 ExUnit Basics

**Test Module Structure:**

```elixir
defmodule Mv.Membership.MemberTest do
  use Mv.DataCase, async: true  # async: true for parallel execution

  alias Mv.Membership.Member

  describe "create_member/1" do
    test "creates a member with valid attributes" do
      attrs = %{
        first_name: "John",
        last_name: "Doe",
        email: "john@example.com"
      }

      assert {:ok, %Member{} = member} = Mv.Membership.create_member(attrs)
      assert member.first_name == "John"
      assert member.email == "john@example.com"
    end

    test "returns error with invalid attributes" do
      attrs = %{first_name: nil}

      assert {:error, _error} = Mv.Membership.create_member(attrs)
    end
  end

  describe "list_members/0" do
    setup do
      # Setup code for this describe block
      {:ok, member: create_test_member()}
    end

    test "returns all members", %{member: member} do
      members = Mv.Membership.list_members()
      assert length(members) == 1
      assert List.first(members).id == member.id
    end
  end
end
```

### 4.3 Test Types

#### 4.3.1 Unit Tests

Test individual functions and modules in isolation:

```elixir
defmodule Mv.Membership.EmailTest do
  use ExUnit.Case, async: true

  alias Mv.Membership.Email

  describe "valid?/1" do
    test "returns true for valid email" do
      assert Email.valid?("user@example.com")
    end

    test "returns false for invalid email" do
      refute Email.valid?("invalid-email")
      refute Email.valid?("missing-at-sign.com")
    end
  end
end
```

#### 4.3.2 Integration Tests

Test interactions between multiple modules or systems:

```elixir
defmodule Mv.Accounts.UserMemberRelationshipTest do
  use Mv.DataCase, async: true

  alias Mv.Accounts.User
  alias Mv.Membership.Member

  describe "user-member relationship" do
    test "creating a user automatically creates a member" do
      attrs = %{
        email: "test@example.com",
        password: "SecurePassword123"
      }

      assert {:ok, user} = Mv.Accounts.create_user(attrs)
      assert {:ok, member} = Mv.Membership.get_member_by_user_id(user.id)
      assert member.email == user.email
    end

    test "deleting a user cascades to member" do
      {:ok, user} = create_user()
      {:ok, member} = Mv.Membership.get_member_by_user_id(user.id)

      assert :ok = Mv.Accounts.destroy_user(user)
      assert {:error, :not_found} = Mv.Membership.get_member(member.id)
    end
  end
end
```

#### 4.3.3 Controller Tests

Test HTTP endpoints:

```elixir
defmodule MvWeb.PageControllerTest do
  use MvWeb.ConnCase, async: true

  test "GET /", %{conn: conn} do
    conn = get(conn, ~p"/")
    assert html_response(conn, 200) =~ "Welcome to Mila"
  end
end
```

#### 4.3.4 LiveView Tests

Test LiveView interactions:

```elixir
defmodule MvWeb.MemberLive.IndexTest do
  use MvWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  setup do
    member = create_test_member()
    %{member: member}
  end

  test "displays list of members", %{conn: conn, member: member} do
    {:ok, view, html} = live(conn, ~p"/members")

    assert html =~ "Members"
    assert html =~ member.first_name
  end

  test "deletes member", %{conn: conn, member: member} do
    {:ok, view, _html} = live(conn, ~p"/members")

    assert view
           |> element("#member-#{member.id} a", "Delete")
           |> render_click()

    refute has_element?(view, "#member-#{member.id}")
  end
end
```

**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).

#### 4.3.5 Component Tests

Test function components:

```elixir
defmodule MvWeb.Components.SearchBarComponentTest do
  use MvWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  import MvWeb.Components.SearchBarComponent

  test "renders search input" do
    assigns = %{search_query: "", id: "search"}

    html = 
      render_component(&search_bar/1, assigns)

    assert html =~ "input"
    assert html =~ ~s(type="search")
  end
end
```

### 4.4 Test Helpers and Fixtures

**Create Test Helpers:**

```elixir
# test/support/fixtures.ex
defmodule Mv.Fixtures do
  def member_fixture(attrs \\ %{}) do
    default_attrs = %{
      first_name: "Test",
      last_name: "User",
      email: "test#{System.unique_integer()}@example.com"
    }

    {:ok, member} =
      default_attrs
      |> Map.merge(attrs)
      |> Mv.Membership.create_member()

    member
  end
end
```

**Use Setup Blocks:**

```elixir
describe "with authenticated user" do
  setup %{conn: conn} do
    user = create_user()
    conn = log_in_user(conn, user)
    %{conn: conn, user: user}
  end

  test "can access protected page", %{conn: conn} do
    conn = get(conn, ~p"/profile")
    assert html_response(conn, 200) =~ "Profile"
  end
end
```

### 4.5 Database Testing with Sandbox

**Use Ecto Sandbox for Isolation:**

```elixir
# test/test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)
```

```elixir
# test/support/data_case.ex
defmodule Mv.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Mv.DataCase

      alias Mv.Repo
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    :ok
  end
end
```

### 4.6 Test Coverage

**Run Tests with Coverage:**

```bash
# Run tests
mix test

# Run with coverage
mix test --cover

# Run specific test file
mix test test/membership/member_test.exs

# Run specific test line
mix test test/membership/member_test.exs:42
```

**Coverage Goals:**

- Aim for >80% overall coverage
- 100% coverage for critical business logic
- Focus on meaningful tests, not just coverage numbers

### 4.7 Testing Best Practices

**Testing Philosophy: Focus on Business Logic, Not Framework Functionality**

We test our business logic and domain-specific behavior, not core framework features. Framework features (Ash validations, Ecto relationships, etc.) are already tested by their respective libraries.

**What We Test:**
- Business rules and validations specific to our domain
- Custom business logic (slug generation, calculations, etc.)
- Integration between our resources
- Database-level constraints (unique constraints, foreign keys, CASCADE)
- Query performance (N+1 prevention)

**What We Don't Test:**
- Framework core functionality (Ash validations work, Ecto relationships work, etc.)
- Standard CRUD operations without custom logic
- Framework-provided features that are already tested upstream
- Detailed slug generation edge cases (Umlauts, truncation, etc.) if covered by reusable change tests

**Example:**
```elixir
# ✅ GOOD - Tests our business rule
test "slug is immutable (doesn't change when name is updated)" do
  {:ok, group} = Membership.create_group(%{name: "Original"}, actor: actor)
  original_slug = group.slug
  
  {:ok, updated} = Membership.update_group(group, %{name: "New"}, actor: actor)
  assert updated.slug == original_slug  # Business rule: slug doesn't change
end

# ❌ AVOID - Tests framework functionality
test "Ash.Changeset validates required fields" do
  # This is already tested by Ash framework
end
```

**Descriptive Test Names:**

```elixir
# Good - describes what is being tested
test "creates a member with valid email address"
test "returns error when email is already taken"
test "sends welcome email after successful registration"

# Avoid - vague or generic
test "member creation"
test "error case"
test "test 1"
```

**Arrange-Act-Assert Pattern:**

```elixir
test "updates member email" do
  # Arrange - set up test data
  member = member_fixture()
  new_email = "new@example.com"

  # Act - perform the action
  {:ok, updated_member} = Mv.Membership.update_member(member, %{email: new_email})

  # Assert - verify results
  assert updated_member.email == new_email
end
```

**Test One Thing Per Test:**

```elixir
# Good - focused test
test "validates email format" do
  attrs = %{email: "invalid-email"}
  assert {:error, _} = Mv.Membership.create_member(attrs)
end

test "requires email to be present" do
  attrs = %{email: nil}
  assert {:error, _} = Mv.Membership.create_member(attrs)
end

# Avoid - testing multiple things
test "validates email" do
  # Tests both format and presence
  assert {:error, _} = Mv.Membership.create_member(%{email: nil})
  assert {:error, _} = Mv.Membership.create_member(%{email: "invalid"})
end
```

**Use describe Blocks for Organization:**

```elixir
describe "create_member/1" do
  test "success case" do
    # ...
  end

  test "error case" do
    # ...
  end
end

describe "update_member/2" do
  test "success case" do
    # ...
  end
end
```

**Avoid Testing Implementation Details:**

```elixir
# Good - test behavior
test "member can be created with valid attributes" do
  attrs = valid_member_attrs()
  assert {:ok, %Member{}} = Mv.Membership.create_member(attrs)
end

# Avoid - testing internal implementation
test "create_member calls Ash.create with correct params" do
  # This is too coupled to implementation
end
```

**Keep Tests Fast:**

- Use `async: true` when possible
- Avoid unnecessary database interactions
- Mock external services
- Use fixtures efficiently

**Performance Tests:**

Performance tests that explicitly validate performance characteristics should be tagged with `@tag :slow` or `@describetag :slow` to exclude them from standard test runs. This improves developer feedback loops while maintaining comprehensive coverage.

**When to Tag as `:slow`:**

- Tests that explicitly measure execution time or validate performance characteristics
- Tests that use large datasets (e.g., 50+ records) to test scalability
- Tests that validate query optimization (N+1 prevention, index usage)

**Tagging Guidelines:**

- Use `@tag :slow` for individual tests
- Use `@describetag :slow` for entire describe blocks (not `@moduletag`, as it affects all tests in the module)
- Performance tests should include measurable assertions (query counts, timing with tolerance, etc.)

**UI Tests:**

UI tests that validate basic HTML rendering, Phoenix LiveView navigation, or framework functionality (Gettext translations, form elements, UI state changes) should be tagged with `@tag :ui` or `@describetag :ui` to exclude them from fast CI runs. Use `@tag :ui` for individual tests and `@describetag :ui` for describe blocks. UI tests can be consolidated when they test similar elements (e.g., multiple translation tests combined into one). Do not tag business logic tests (e.g., "can delete a user"), validation tests, or data persistence tests as `:ui`.

**Running Tests:**

```bash
# Fast tests only (excludes slow and UI tests)
mix test --exclude slow --exclude ui
# Or use the Justfile command:
just test-fast

# UI tests only
mix test --only ui
# Or use the Justfile command:
just ui

# Performance tests only
mix test --only slow
# Or use the Justfile command:
just slow

# All tests (including slow and UI tests)
mix test
# Or use the Justfile command:
just test
# Or use the Justfile command:
just test-all
```

**Test Organization Best Practices:**

- **Fast Tests (Standard CI):** Business logic, validations, data persistence, edge cases
- **UI Tests (Full Test Suite):** Basic HTML rendering, navigation, translations, UI state
- **Performance Tests (Full Test Suite):** Query optimization, large datasets, timing assertions

This organization ensures fast feedback in standard CI while maintaining comprehensive coverage via promotion before merge.
---

## 5. Security Guidelines

### 5.0 No system-actor fallbacks (mandatory)

**Do not use the system actor as a fallback when an actor is missing.**

Examples of forbidden patterns:

```elixir
# ❌ FORBIDDEN - Fallback to system actor when actor is nil
actor = Map.get(changeset.context, :actor) || Mv.Helpers.SystemActor.get_system_actor()

# ❌ FORBIDDEN - "Safety" fallback in validations, changes, or helpers
actor = opts[:actor] || Mv.Helpers.SystemActor.get_system_actor()

# ❌ FORBIDDEN - Default actor in function options
def list_something(opts \\ []) do
  actor = Keyword.get(opts, :actor) || Mv.Helpers.SystemActor.get_system_actor()
  # ...
end
```

**Why:** Fallbacks hide missing-actor bugs and can lead to privilege escalation (e.g. a request without actor would run with system privileges). Always require the caller to pass the actor for user-facing or context-dependent operations; if none is available, return an error or fail validation instead of using the system actor.

**Allowed:** Use the system actor only where the operation is **by design** a systemic operation (e.g. email sync, seeds, test fixtures, background jobs) and you explicitly call `SystemActor.get_system_actor()` at that call site—never as a fallback when `actor` is nil or absent.

### 5.1 Authentication & Authorization

**Use AshAuthentication:**

```elixir
# Authentication is configured at the resource level
authentication do
  strategies do
    password :password do
      identity_field :email
      hashed_password_field :hashed_password
    end

    oidc :oidc do
      # OIDC configuration
    end
  end
end
```

**Implement Authorization Policies:**

```elixir
policies do
  # Default deny
  policy action_type(:*) do
    authorize_if always()
  end

  # Use HasPermission check for role-based authorization
  policy action_type([:read, :update, :create, :destroy]) do
    authorize_if Mv.Authorization.Checks.HasPermission
  end
end
```

**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.

**Actor Handling in LiveViews:**

Always use the `current_actor/1` helper for consistent actor access:

```elixir
# In LiveView modules
import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1, submit_form: 3]

def mount(_params, _session, socket) do
  actor = current_actor(socket)
  
  case Ash.read(Mv.Membership.Member, ash_actor_opts(actor)) do
    {:ok, members} ->
      {:ok, assign(socket, :members, members)}
    {:error, error} ->
      {:ok, put_flash(socket, :error, "Failed to load members")}
  end
end

def handle_event("save", %{"member" => params}, socket) do
  actor = current_actor(socket)
  form = AshPhoenix.Form.for_create(Mv.Membership.Member, :create)
  
  case submit_form(form, params, actor) do
    {:ok, member} ->
      {:noreply, push_navigate(socket, to: ~p"/members/#{member.id}")}
    {:error, form} ->
      {:noreply, assign(socket, :form, form)}
  end
end
```

**Never use bang calls (`Ash.read!`, `Ash.get!`) without error handling:**

```elixir
# Bad - will crash on authorization errors
members = Ash.read!(Mv.Membership.Member, actor: actor)

# Good - proper error handling
case Ash.read(Mv.Membership.Member, actor: actor) do
  {:ok, members} -> # success
  {:error, %Ash.Error.Forbidden{}} -> # handle authorization error
  {:error, error} -> # handle other errors
end
```

### 5.1a Authorization in Tests

**IMPORTANT:** All tests must explicitly provide an actor for Ash operations. The NoActor bypass has been removed to prevent masking authorization bugs.

**Exception: AshAuthentication Bypass Tests**

Tests that verify the AshAuthentication bypass mechanism are a **conscious exception**. These tests must verify that registration/login works **without an actor** via the `AshAuthenticationInteraction` check. To enable this bypass in tests, set the context explicitly:

```elixir
# ✅ GOOD - Testing AshAuthentication bypass (conscious exception)
changeset =
  Accounts.User
  |> Ash.Changeset.for_create(:register_with_password, %{...})
  |> Ash.Changeset.set_context(%{private: %{ash_authentication?: true}})

{:ok, user} = Ash.create(changeset)  # No actor - tests bypass mechanism

# ❌ BAD - Using system_actor masks the bypass test
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Ash.create(changeset, actor: system_actor)  # Tests admin permissions, not bypass!
```

**Test Fixtures:**

All test fixtures use `system_actor` for authorization:

```elixir
# test/support/fixtures.ex
def member_fixture(attrs \\ %{}) do
  system_actor = Mv.Helpers.SystemActor.get_system_actor()
  
  attrs
  |> Enum.into(%{...})
  |> Mv.Membership.create_member(actor: system_actor)
end
```

**Why Explicit Actors in Tests:**

- Prevents masking authorization bugs
- Makes authorization requirements explicit
- Tests fail if authorization is broken (fail-fast)
- Consistent with production code patterns

**Using system_actor in Tests:**

```elixir
# ✅ GOOD - Explicit actor in tests
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Ash.create!(Member, attrs, actor: system_actor)

# ❌ BAD - Missing actor (will fail)
Ash.create!(Member, attrs)  # Forbidden error!
```

**For Bootstrap Operations:**

Use `authorize?: false` only for bootstrap scenarios (seeds, SystemActor initialization):

```elixir
# ✅ GOOD - Bootstrap only
Accounts.create_user!(%{email: admin_email}, authorize?: false)

# ❌ BAD - Never use in tests for normal operations
Ash.create!(Member, attrs, authorize?: false)  # Never do this!
```

### 5.2 Password Security

**Use bcrypt for Password Hashing:**

```elixir
# Configured in AshAuthentication resource
password :password do
  identity_field :email
  hashed_password_field :hashed_password
  hash_provider AshAuthentication.BcryptProvider
  
  confirmation_required? true
end
```

**Password Requirements:**

- Minimum 12 characters
- Mix of uppercase, lowercase, numbers (enforced by validation)
- Use `bcrypt_elixir` for hashing (never store plain text passwords)

### 5.3 Input Validation & Sanitization

**Validate All User Input:**

```elixir
attributes do
  attribute :email, :string do
    allow_nil? false
    public? true
  end
end

validations do
  validate present(:email)
  validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
end
```

**SQL Injection Prevention:**

Ecto and Ash handle parameterized queries automatically:

```elixir
# Safe - parameterized query
Ash.Query.filter(Member, email == ^user_email)

# Avoid raw SQL when possible
Ecto.Adapters.SQL.query(Mv.Repo, "SELECT * FROM members WHERE email = $1", [user_email])
```

### 5.4 CSRF Protection

**Phoenix Handles CSRF Automatically:**

```heex
<!-- CSRF token automatically included in forms -->
<.form for={@form} phx-submit="save">
  <!-- form fields -->
</.form>
```

**Configure in Endpoint:**

```elixir
# lib/mv_web/endpoint.ex
plug Plug.Session,
  store: :cookie,
  key: "_mv_key",
  signing_salt: "secret"

plug :protect_from_forgery
```

### 5.5 Secrets Management

**Never Commit Secrets:**

```bash
# .gitignore should include:
.env
.env.*
!.env.example
```

**Use Environment Variables:**

```elixir
# config/runtime.exs
config :mv, :oidc,
  client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
  client_secret: System.get_env("OIDC_CLIENT_SECRET"),
  base_url: System.get_env("OIDC_BASE_URL")
```

**Generate Secure Secrets:**

```bash
# Generate secret key base
mix phx.gen.secret

# Generate token signing secret
mix phx.gen.secret
```

### 5.6 Security Headers

**Configure Security Headers:**

```elixir
# lib/mv_web/endpoint.ex
plug Plug.Static,
  at: "/",
  from: :mv,
  gzip: false,
  only: MvWeb.static_paths(),
  headers: %{
    "x-content-type-options" => "nosniff",
    "x-frame-options" => "SAMEORIGIN",
    "x-xss-protection" => "1; mode=block"
  }
```

### 5.7 Dependency Security

- **Use Renovate for automated dependency updates**
- **Review changelogs before updating dependencies**
- **Test thoroughly after updates**
- **Run regular audits** (see section 3.10 for audit commands)

### 5.8 Logging & Monitoring

**Sanitize Logs:**

```elixir
# Don't log sensitive information
Logger.info("User login attempt", user_id: user.id)

# Avoid
Logger.info("User login attempt", user: inspect(user))  # May contain password
```

**Configure Logger:**

```elixir
# config/config.exs
config :logger, :default_formatter,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id, :user_id]
```

---

## 6. Performance Best Practices

### 6.1 Database Performance

**Indexing:**

```elixir
postgres do
  table "members"
  repo Mv.Repo

  # Add indexes for frequently queried fields
  index [:email], unique: true
  index [:last_name]
  index [:created_at]
end
```

**Avoid N+1 Queries:**

```elixir
# Good - preload relationships
members = 
  Member
  |> Ash.Query.load([:custom_field_values, :user])
  |> Mv.Membership.list_members!()

# Avoid - causes N+1
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
  custom_field_values = Ash.load!(member, :custom_field_values)  # N queries!
end)
```

**Pagination:**

```elixir
# Use keyset pagination (configured as default in Ash)
Ash.Query.page(Member, offset: 0, limit: 50)
```

**Batch Operations:**

```elixir
# Use bulk operations for multiple records
Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create)
```

### 6.2 LiveView Performance

**Optimize Assigns:**

```elixir
# Good - only assign what's needed
def mount(_params, _session, socket) do
  {:ok, assign(socket, members_count: get_count())}
end

# Avoid - assigning large collections unnecessarily
def mount(_params, _session, socket) do
  {:ok, assign(socket, all_members: list_all_members())}  # Heavy!
end
```

**Use Temporary Assigns:**

```elixir
# For data that's only needed for rendering
def handle_event("load_report", _, socket) do
  report_data = generate_large_report()
  {:noreply, assign(socket, report: report_data) |> assign(:report, temporary_assigns: [:report])}
end
```

**Stream Collections:**

```elixir
# For large collections
def mount(_params, _session, socket) do
  {:ok, stream(socket, :members, list_members())}
end

def render(assigns) do
  ~H"""
  <div id="members" phx-update="stream">
    <div :for={{id, member} <- @streams.members} id={id}>
      <%= member.name %>
    </div>
  </div>
  """
end
```

### 6.3 Caching Strategies

**Function-Level Caching:**

```elixir
defmodule Mv.Cache do
  use GenServer

  def get_or_compute(key, compute_fn) do
    case get(key) do
      nil ->
        value = compute_fn.()
        put(key, value)
        value
      
      value ->
        value
    end
  end
end
```

**ETS for In-Memory Cache:**

```elixir
# In application.ex
:ets.new(:mv_cache, [:named_table, :public, read_concurrency: true])

# Usage
:ets.insert(:mv_cache, {"key", "value"})
:ets.lookup(:mv_cache, "key")
```

### 6.4 Async Processing

**Background Jobs (Future):**

```elixir
# When Oban is added
defmodule Mv.Workers.EmailWorker do
  use Oban.Worker

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
    user = Mv.Accounts.get_user!(user_id)
    Mv.Mailer.send_welcome_email(user)
    :ok
  end
end

# Enqueue job
%{user_id: user.id}
|> Mv.Workers.EmailWorker.new()
|> Oban.insert()
```

**Task Async for Concurrent Operations:**

```elixir
def gather_dashboard_data do
  tasks = [
    Task.async(fn -> get_member_count() end),
    Task.async(fn -> get_recent_registrations() end),
    Task.async(fn -> get_payment_status() end)
  ]

  [member_count, recent, payments] = Task.await_many(tasks)

  %{
    member_count: member_count,
    recent: recent,
    payments: payments
  }
end
```

### 6.5 Profiling & Monitoring

**Use :observer:**

```elixir
# In iex session
:observer.start()
```

**Use Telemetry:**

```elixir
# Attach telemetry handlers
:telemetry.attach(
  "query-duration",
  [:mv, :repo, :query],
  fn event, measurements, metadata, _config ->
    Logger.info("Query took #{measurements.total_time}ms")
  end,
  nil
)
```

---

## 7. Documentation Standards

### 7.1 Module Documentation

**Use @moduledoc:**

```elixir
defmodule Mv.Membership.Member do
  @moduledoc """
  Represents a club member with their personal information and membership status.

  Members can have custom_field_values defined by the club administrators.
  Each member is optionally linked to a user account for self-service access.

  ## Examples

      iex> Mv.Membership.create_member(%{first_name: "John", last_name: "Doe", email: "john@example.com"})
      {:ok, %Mv.Membership.Member{}}

  """
end
```

**Module Documentation Should Include:**

- Purpose of the module
- Key responsibilities
- Usage examples
- Related modules

### 7.2 Function Documentation

**Use @doc:**

```elixir
@doc """
Creates a new member with the given attributes.

## Parameters

  - `attrs` - A map of member attributes including:
    - `:first_name` (required) - The member's first name
    - `:last_name` (required) - The member's last name
    - `:email` (required) - The member's email address

## Returns

  - `{:ok, member}` - Successfully created member
  - `{:error, error}` - Validation or creation error

## Examples

    iex> Mv.Membership.create_member(%{
    ...>   first_name: "Jane",
    ...>   last_name: "Smith",
    ...>   email: "jane@example.com"
    ...> })
    {:ok, %Mv.Membership.Member{first_name: "Jane"}}

    iex> Mv.Membership.create_member(%{first_name: nil})
    {:error, %Ash.Error.Invalid{}}

"""
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
def create_member(attrs) do
  # Implementation
end
```

### 7.3 Type Specifications

**Use @spec for Function Signatures:**

```elixir
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
def create_member(attrs)

@spec list_members(keyword()) :: [Member.t()]
def list_members(opts \\ [])

@spec get_member!(String.t()) :: Member.t() | no_return()
def get_member!(id)
```

### 7.4 Code Comments

**When to Comment:**

```elixir
# Good - explain WHY, not WHAT
def calculate_dues(member) do
  # Annual dues are prorated based on join date to be fair to mid-year joiners
  months_active = calculate_months_active(member)
  @annual_dues * (months_active / 12)
end

# Avoid - stating the obvious
def calculate_dues(member) do
  # Calculate the dues
  months_active = calculate_months_active(member)  # Get months active
  @annual_dues * (months_active / 12)  # Multiply annual dues by fraction
end
```

**Complex Logic:**

```elixir
def sync_member_email(member, user) do
  # Email synchronization priority:
  # 1. User email is the source of truth (authenticated account)
  # 2. Member email is updated to match user email
  # 3. Preserve member email history for audit purposes
  
  if member.email != user.email do
    # Archive old email before updating
    archive_member_email(member, member.email)
    update_member(member, %{email: user.email})
  end
end
```

### 7.5 README and Project Documentation

**Keep README.md Updated:**

- Installation instructions
- Development setup
- Running tests
- Deployment guide
- Contributing guidelines

**Additional Documentation:**

- `docs/` directory for detailed guides
- Architecture decision records (ADRs)
- API documentation (generated with ExDoc)

**Generate Documentation:**

```bash
# Generate HTML documentation
mix docs

# View documentation
open doc/index.html
```

### 7.6 Changelog

**Maintain CHANGELOG.md:**

```markdown
# Changelog

## [Unreleased]

### Added
- Member custom_field_values feature
- Email synchronization between user and member

### Changed
- Updated Phoenix to 1.8.0

### Fixed
- Email uniqueness validation bug

## [0.1.0] - 2025-01-15

### Added
- Initial release
- Basic member management
- OIDC authentication
```

---

## 8. Git Workflow

### 8.1 Branching Strategy

**Main Branches:**

- `main` - Production-ready code

**Feature Branches:**

```bash
# Create feature branch
git checkout -b feature/member-custom-custom_field_values

# Work on feature
git add .
git commit -m "Add custom_field_values to members"

# Push to remote
git push origin feature/member-custom-custom_field_values
```

### 8.2 Commit Messages

**Format:**

```
<type>: <subject>

<body>

<footer>
```

**Types:**

- `feat:` New feature
- `fix:` Bug fix
- `docs:` Documentation changes
- `style:` Code style changes (formatting)
- `refactor:` Code refactoring
- `test:` Adding or updating tests
- `chore:` Maintenance tasks

**Examples:**

```
feat: add email synchronization for members

Implement automatic email sync between user accounts and member records.
When a user updates their email, the associated member record is updated.

Closes #123
```

```
fix: resolve N+1 query in member list

Preload custom_field_values relationship when loading members to avoid N+1 queries.

Performance improvement: reduced query count from 100+ to 2.
```

### 8.3 Code Reviews

**Before Creating PR:**

- Run `mix format`
- Run `mix credo`
- Run `mix test`
- Run `mix sobelow --config`
- Update documentation if needed

**PR Description Should Include:**

- What changed
- Why it changed
- How to test it
- Screenshots (for UI changes)
- Related issues

---

## 9. Deployment

### 9.1 Environment Configuration

**Production Checklist:**

- [ ] Set `SECRET_KEY_BASE` (use `mix phx.gen.secret`)
- [ ] Set `TOKEN_SIGNING_SECRET`
- [ ] Configure `DATABASE_URL`
- [ ] Set `PHX_HOST`
- [ ] Configure OIDC credentials
- [ ] Set up SMTP for email
- [ ] Enable SSL/TLS
- [ ] Configure monitoring

### 9.2 Database Migrations

```bash
# In production Docker container
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
```

### 9.3 Building Docker Image

```bash
# Build production image
docker build -t mitgliederverwaltung .

# Or use Just
just build-docker-container
```

### 9.4 Health Checks

**Implement Health Check Endpoint:**

```elixir
# lib/mv_web/controllers/health_controller.ex
defmodule MvWeb.HealthController do
  use MvWeb, :controller

  def index(conn, _params) do
    # Check database connectivity
    case Ecto.Adapters.SQL.query(Mv.Repo, "SELECT 1", []) do
      {:ok, _} -> json(conn, %{status: "healthy"})
      {:error, _} -> conn |> put_status(503) |> json(%{status: "unhealthy"})
    end
  end
end
```

---

## 10. Additional Resources

### 10.1 Official Documentation

- **Elixir:** https://elixir-lang.org/docs.html
- **Phoenix:** https://hexdocs.pm/phoenix/
- **Ash Framework:** https://hexdocs.pm/ash/
- **LiveView:** https://hexdocs.pm/phoenix_live_view/
- **Ecto:** https://hexdocs.pm/ecto/
- **Tailwind CSS:** https://tailwindcss.com/docs
- **DaisyUI:** https://daisyui.com/

### 10.2 Books & Guides

- Programming Elixir by Dave Thomas
- Phoenix in Action by Geoffrey Lessel
- Testing Elixir by Andrea Leopardi & Jeffrey Matthias
- Designing Elixir Systems with OTP by James Edward Gray II

### 10.3 Community

- Elixir Forum: https://elixirforum.com/
- Ash Framework Discord
- Phoenix Framework Slack

---

## 8. Accessibility Guidelines

Building accessible applications ensures that all users, including those with disabilities, can use our application effectively. The following guidelines follow WCAG 2.1 Level AA standards.

### 8.1 Semantic HTML

**Use Semantic Elements:**

```heex
<!-- Good - semantic HTML -->
<nav class="navbar">
  <a href="/">Home</a>
</nav>

<main>
  <article>
    <h1>Member Details</h1>
    <section>
      <h2>Contact Information</h2>
      <p>Email: <%= @member.email %></p>
    </section>
  </article>
</main>

<!-- Avoid - non-semantic divs -->
<div class="navigation">
  <div class="link">Home</div>
</div>
```

### 8.2 ARIA Labels and Roles

**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.

**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.

```heex
<!-- Icon-only buttons need labels -->
<button aria-label={gettext("Delete member")} phx-click="delete">
  <.icon name="hero-trash" />
</button>

<!-- Loading states -->
<div role="status" aria-live="polite" aria-busy={@loading}>
  <%= if @loading do %>
    <span aria-label={gettext("Loading...")}>
      <.icon name="hero-loading" class="animate-spin" />
    </span>
  <% end %>
</div>

<!-- Navigation landmarks -->
<nav aria-label={gettext("Main navigation")}>
  <!-- navigation items -->
</nav>
```

### 8.3 Keyboard Navigation

**All Interactive Elements Must Be Keyboard Accessible:**

```heex
<!-- Good - keyboard accessible -->
<.link navigate={~p"/members"} class="btn">
  Members
</.link>

<!-- Good - custom keyboard handler -->
<div 
  tabindex="0" 
  role="button"
  phx-click="toggle"
  phx-keydown="toggle"
  phx-key="Enter"
  aria-pressed={@expanded}>
  Toggle
</div>

<!-- Avoid - div without keyboard support -->
<div phx-click="action">Click me</div>
```

**Tables (Core Component `<.table>` with `row_click`):**

- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.

**Empty table cells (missing values):**

- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.

**Tab Order:**

- Ensure logical tab order matches visual order
- Use `tabindex="0"` for custom interactive elements
- Use `tabindex="-1"` to programmatically focus (not in tab order)
- Never use positive `tabindex` values

### 8.4 Color and Contrast

**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**

- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.

```elixir
# Tailwind classes with sufficient contrast (4.5:1 minimum)
# Good
<p class="text-gray-900 bg-white">High contrast text</p>
<p class="text-white bg-gray-900">Inverted high contrast</p>

# Avoid - insufficient contrast
<p class="text-gray-400 bg-gray-300">Low contrast text</p>
```

**Don't Rely Solely on Color:**

```heex
<!-- Good - color + icon + text -->
<div class="alert alert-error">
  <.icon name="hero-exclamation-circle" />
  <span><%= gettext("Error: Email is required") %></span>
</div>

<!-- Avoid - color only -->
<div class="text-red-500">
  <%= gettext("Email is required") %>
</div>
```

### 8.5 Form Accessibility

**Label All Form Fields:**

```heex
<!-- Good - explicit labels -->
<.input
  field={@form[:email]}
  type="email"
  label={gettext("Email Address")}
  required
/>

<!-- Good - with helper text -->
<.input
  field={@form[:password]}
  type="password"
  label={gettext("Password")}
  help={gettext("Must be at least 12 characters")}
  required
/>
```

**Error Messages:**

```heex
<!-- Accessible error messages -->
<.input
  field={@form[:email]}
  type="email"
  label={gettext("Email")}
  errors={@errors[:email]}
  aria-describedby={@errors[:email] && "email-error"}
/>
<span :if={@errors[:email]} id="email-error" role="alert">
  <%= @errors[:email] %>
</span>
```

**Required Fields:**

Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. The Vereinfacht integration does not add extra required member fields (the external API accepts a minimal payload when creating contacts and supports filter-by-email for lookup).

```heex
<!-- Mark required fields (value from settings or always true for email) -->
<.input
  field={@form[:first_name]}
  label={gettext("First Name")}
  required={@member_field_required_map[:first_name]}
  aria-required="true"
/>
```

### 8.6 Focus Management

**Manage Focus in LiveView:**

```elixir
def handle_event("save", params, socket) do
  case save_member(params) do
    {:ok, member} ->
      socket
      |> put_flash(:info, gettext("Member saved successfully"))
      |> push_navigate(to: ~p"/members/#{member}")
      # Focus will move to the new page
    
    {:error, changeset} ->
      socket
      |> assign(:form, to_form(changeset))
      # Keep focus context
  end
end
```

**Skip Links:**

```heex
<!-- Add skip link for keyboard users -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-white">
  <%= gettext("Skip to main content") %>
</a>

<main id="main-content">
  <!-- main content -->
</main>
```

### 8.7 Images and Media

**Alt Text for Images:**

```heex
<!-- Good - descriptive alt text -->
<img src="/images/member-photo.jpg" alt={gettext("Photo of %{name}", name: @member.name)} />

<!-- Decorative images -->
<img src="/images/decoration.svg" alt="" role="presentation" />
```

**Icons:**

```heex
<!-- Icons with meaning need labels -->
<.icon name="hero-check-circle" aria-label={gettext("Success")} />

<!-- Decorative icons -->
<.icon name="hero-sparkles" aria-hidden="true" />
```

### 8.8 Tables

**Accessible Data Tables:**

```heex
<table>
  <caption><%= gettext("List of members") %></caption>
  <thead>
    <tr>
      <th scope="col"><%= gettext("Name") %></th>
      <th scope="col"><%= gettext("Email") %></th>
      <th scope="col"><%= gettext("Actions") %></th>
    </tr>
  </thead>
  <tbody>
    <tr :for={member <- @members}>
      <td><%= member.name %></td>
      <td><%= member.email %></td>
      <td>
        <.link navigate={~p"/members/#{member}"} aria-label={gettext("View %{name}", name: member.name)}>
          <%= gettext("View") %>
        </.link>
      </td>
    </tr>
  </tbody>
</table>
```

### 8.9 Live Regions

**Announce Dynamic Content:**

```heex
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
<div role="status" aria-live="polite" aria-atomic="true">
  <%= if @searched do %>
    <span class="sr-only">
      <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
    </span>
  <% end %>
</div>

<!-- Status messages -->
<div role="alert" aria-live="assertive">
  <%= if @error do %>
    <%= @error %>
  <% end %>
</div>
```

### 8.10 Testing Accessibility

**Tools and Practices:**

```bash
# Browser DevTools
# - Chrome: Lighthouse Accessibility Audit
# - Firefox: Accessibility Inspector

# Automated testing (future)
# - pa11y
# - axe-core
```

**Manual Testing:**

1. **Keyboard Navigation:** Navigate entire application using only keyboard
2. **Screen Reader:** Test with NVDA (Windows) or VoiceOver (Mac)
3. **Zoom:** Test at 200% zoom level
4. **Color Blindness:** Use browser extensions to simulate

**Checklist:**

- [ ] All images have alt text (or alt="" for decorative)
- [ ] All form inputs have labels
- [ ] All interactive elements are keyboard accessible
- [ ] Color contrast meets WCAG AA (4.5:1 for normal text)
- [ ] Focus indicators are visible
- [ ] Headings follow logical hierarchy (h1, h2, h3...)
- [ ] Error messages are announced to screen readers
- [ ] Skip links are available
- [ ] Tables have proper structure (th, scope, caption)
- [ ] ARIA labels used for icon-only buttons
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.

### 8.11 Modals and Dialogs

Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).

**Structure and semantics:**

- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose.
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).

**Focus management (WCAG 2.4.3):**

- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
  - If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
  - If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.

**Layout and consistency:**

- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
- Place Cancel (or neutral) first, primary/danger action second.
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.

**Closing:**

- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).

**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).

### 8.12 DaisyUI Accessibility

DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:

```heex
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
  <div class="modal-box">
    <h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
    <p><%= gettext("Are you sure?") %></p>
    <div class="modal-action">
      <.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
        <%= gettext("Cancel") %>
      </.button>
      <.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
    </div>
  </div>
</dialog>
```

---

## Conclusion

These guidelines are a living document and should evolve with our project and team. When in doubt, prioritize:

1. **Clarity over cleverness** - Write code that's easy to understand
2. **Consistency over perfection** - Follow established patterns
3. **Testing over hoping** - Write tests for confidence
4. **Documentation over memory** - Don't rely on tribal knowledge
5. **Communication over assumption** - Ask questions, discuss trade-offs
6. **Accessibility for all** - Build inclusive applications

Happy coding! 🚀

