mitgliederverwaltung/CODE_GUIDELINES.md
Moritz 8400e727a7
All checks were successful
continuous-integration/drone/push Build is passing
refactor: Rename Property/PropertyType to CustomFieldValue/CustomField
Complete refactoring of resources, database tables, code references, tests, and documentation for improved naming consistency.
2025-11-13 18:04:53 +01:00

2578 lines
56 KiB
Markdown

# 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)
---
## 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_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue 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
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
│ │ ├── custom_field_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:**
```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.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
**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
```
**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 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
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:**
```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 -->
<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:
```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
**Run Credo Regularly:**
```bash
# 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:**
```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
**Define Translations:**
```elixir
# 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:**
```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
```
**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
```
#### 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
**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
---
## 5. Security Guidelines
### 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
oauth2 :rauthy do
# OIDC configuration
end
end
end
```
**Implement Authorization Policies:**
```elixir
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:**
```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, :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:**
```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
**Use ARIA Attributes When Necessary:**
```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>
```
**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:**
```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:**
```heex
<!-- Mark required fields -->
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required
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 -->
<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:**
```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
### 8.11 DaisyUI Accessibility
DaisyUI components are designed with accessibility in mind, but ensure:
```heex
<!-- 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! 🚀