mitgliederverwaltung/CODE_GUIDELINES.md
Moritz 150bba2ef8
docs: enable Credo ModuleDoc check and fix remaining modules
Add @moduledoc to Secrets, LiveHelpers, AuthOverrides, and Membership domain.
Enable Credo.Check.Readability.ModuleDoc in .credo.exs.
2025-11-13 11:20:33 +01:00

56 KiB

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
  2. Coding Standards and Style
  3. Tooling Guidelines
  4. Testing Standards
  5. Security Guidelines
  6. Performance Best Practices
  7. Documentation Standards
  8. Accessibility Guidelines

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
│   ├── property.ex        # Custom property resource
│   ├── property_type.ex   # Property type resource
│   └── email.ex           # Email custom type
├── 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/
│   ├── application.ex     # OTP application
│   ├── mailer.ex          # Email mailer
│   ├── release.ex         # Release tasks
│   ├── repo.ex            # Database repository
│   └── secrets.ex         # Secret management
├── mv_web/                # Web interface layer
│   ├── components/        # UI components
│   │   ├── core_components.ex
│   │   ├── table_components.ex
│   │   ├── layouts.ex
│   │   └── layouts/       # Layout templates
│   │       ├── navbar.ex
│   │       └── root.html.heex
│   ├── controllers/       # HTTP controllers
│   │   ├── auth_controller.ex
│   │   ├── page_controller.ex
│   │   ├── locale_controller.ex
│   │   ├── error_html.ex
│   │   ├── error_json.ex
│   │   └── page_html/
│   ├── live/              # LiveView modules
│   │   ├── components/    # LiveView-specific components
│   │   │   ├── search_bar_component.ex
│   │   │   └── sort_header_component.ex
│   │   ├── member_live/   # Member CRUD LiveViews
│   │   ├── property_live/ # Property CRUD LiveViews
│   │   ├── property_type_live/
│   │   └── user_live/     # User management LiveViews
│   ├── auth_overrides.ex  # AshAuthentication overrides
│   ├── endpoint.ex        # Phoenix endpoint
│   ├── gettext.ex         # I18n configuration
│   ├── live_helpers.ex    # LiveView 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

1.2 Module Organization

Module Naming:

  • Modules: Use PascalCase with full namespace (e.g., Mv.Accounts.User)
  • Domains: Top-level domains are Mv.Accounts and Mv.Membership
  • 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:

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.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:

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:

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:

# 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:

# 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:

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

Use Tagged Tuples:

# 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:

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:

# 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:

# 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
# 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:

# 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:

# 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:

# 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:

# 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:

# Install correct versions
asdf install

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

OTP Application Design:

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
# 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:

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

Component Design:

# 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 Ash Framework

Resource Definition Best Practices:

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:

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:

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:

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

# Apply migrations
mix ash.setup

Repository Configuration:

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:

# Good - preload relationships
members = 
  Member
  |> Ash.Query.load(:properties)
  |> 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, :properties)
end)

3.5 Authentication (AshAuthentication)

Resource with Authentication:

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

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

3.6 Frontend: Tailwind CSS & DaisyUI

Utility-First Approach:

<!-- 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:

<!-- 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:

<!-- Leverage DaisyUI component classes -->
<div class="navbar bg-base-100">
  <div class="navbar-start">
    <a class="btn btn-ghost text-xl">Mila</a>
  </div>
  <div class="navbar-end">
    <.link navigate={~p"/members"} class="btn btn-primary">
      Members
    </.link>
  </div>
</div>

Custom Tailwind Configuration:

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

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:

// 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):

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

Run Credo Regularly:

# Check code quality
mix credo

# Strict mode for CI
mix credo --strict

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:

# 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:

# 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:

# Audit dependencies for security vulnerabilities
mix deps.audit

# Audit hex packages
mix hex.audit

# Security scan with Sobelow
mix sobelow --config

Update Dependencies:

# 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:

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

Sending Emails:

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

Define Translations:

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

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

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

Extract and Merge:

# 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:

# 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:

# 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

Test File Naming:

  • Use _test.exs suffix for all test files
  • Match the module name: user.exuser_test.exs
  • Use descriptive names for integration tests: user_member_relationship_test.exs

4.2 ExUnit Basics

Test Module Structure:

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:

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:

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:

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:

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

4.3.5 Component Tests

Test function components:

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:

# 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:

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:

# test/test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)
# 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:

# 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

Descriptive Test Names:

# 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:

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:

# 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:

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:

# 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

5. Security Guidelines

5.1 Authentication & Authorization

Use AshAuthentication:

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

    oauth2 :rauthy do
      # OIDC configuration
    end
  end
end

Implement Authorization Policies:

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

  # Specific permissions
  policy action_type([:read, :update]) do
    authorize_if relates_to_actor_via(:user)
  end

  policy action_type(:destroy) do
    authorize_if actor_attribute_equals(:role, :admin)
  end
end

5.2 Password Security

Use bcrypt for Password Hashing:

# 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:

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:

# 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:

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

Configure in Endpoint:

# 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:

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

Use Environment Variables:

# config/runtime.exs
config :mv, :rauthy,
  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:

# Generate secret key base
mix phx.gen.secret

# Generate token signing secret
mix phx.gen.secret

5.6 Security Headers

Configure Security Headers:

# 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:

# 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:

# 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:

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:

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

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

Pagination:

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

Batch Operations:

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

6.2 LiveView Performance

Optimize Assigns:

# 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:

# 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:

# 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:

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:

# 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):

# 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:

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:

# In iex session
:observer.start()

Use Telemetry:

# 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:

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

  Members can have custom properties 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:

@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:

@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:

# 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:

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:

# Generate HTML documentation
mix docs

# View documentation
open doc/index.html

7.6 Changelog

Maintain CHANGELOG.md:

# Changelog

## [Unreleased]

### Added
- Member custom properties 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:

# Create feature branch
git checkout -b feature/member-custom-properties

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

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

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 properties 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

# 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

# Build production image
docker build -t mitgliederverwaltung .

# Or use Just
just build-docker-container

9.4 Health Checks

Implement Health Check Endpoint:

# 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

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


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:

<!-- 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

Use ARIA Attributes When Necessary:

<!-- 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:

<!-- 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>

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:

# 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:

<!-- 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:

<!-- 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:

<!-- 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:

<!-- Mark required fields -->
<.input
  field={@form[:first_name]}
  label={gettext("First Name")}
  required
  aria-required="true"
/>

8.6 Focus Management

Manage Focus in LiveView:

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:

<!-- 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:

<!-- 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:

<!-- 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:

<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:

<!-- Search results announcement -->
<div role="status" aria-live="polite" aria-atomic="true">
  <%= if @searched do %>
    <span class="sr-only">
      <%= ngettext("Found %{count} member", "Found %{count} members", @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:

# 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

8.11 DaisyUI Accessibility

DaisyUI components are designed with accessibility in mind, but ensure:

<!-- Modal accessibility -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
  <div class="modal-box">
    <h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
    <p><%= gettext("Are you sure?") %></p>
    <div class="modal-action">
      <button class="btn" onclick="document.getElementById('my-modal').close()">
        <%= gettext("Cancel") %>
      </button>
      <button class="btn btn-error" 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! 🚀