# Story 2.2: Shared Template Components (8 Components)

Status: review

## Story

As a developer,
I want all 8 shared Jinja2 template components created before any blueprint view is built,
So that every feature in Epics 3–10 uses consistent, tested UI building blocks with no pattern duplication.

## Acceptance Criteria

1. All 8 files exist in `app/templates/components/`: `alert.html`, `empty_state.html`, `page_header.html`, `stat_card.html`, `pagination.html`, `confirm_modal.html`, `form_row.html`, `progress_bar.html` — each fully implemented (replacing the current `{# ... stub #}` comment-only files).
2. `tests/test_blueprints/test_components.py` renders each component with minimal valid context and asserts the result is non-empty HTML with no Jinja2 `UndefinedError`.
3. Each component uses the `{% macro %}` pattern — NOT `{% extends %}`. Components are imported via `{% from 'components/X.html' import render_X %}`.
4. `pytest tests/` passes with all Story 2.1 tests still green (45 passed, 1 skipped minimum; no regressions).

## Tasks / Subtasks

- [x] **Task 1: Write `tests/test_blueprints/test_components.py`** (TDD RED phase — all tests fail before implementation) (AC: 2)
  - [x] Write tests for all 8 components using `render_template_string` + `app.test_request_context('/')`
  - [x] Confirm all tests FAIL before any component is implemented (red phase)
  - [x] Test `alert.html`: success / error / warning / info variants each render their message
  - [x] Test `empty_state.html`: minimal (title + description) and with CTA render correctly
  - [x] Test `page_header.html`: title-only and title + subtitle + action both render
  - [x] Test `stat_card.html`: minimal (label + value) and with delta render
  - [x] Test `pagination.html`: next link rendered on page 1 of 10; prev link rendered on page 2
  - [x] Test `confirm_modal.html`: renders modal structure with title, message, and confirm URL
  - [x] Test `form_row.html`: renders label and input HTML for a WTForms StringField
  - [x] Test `progress_bar.html`: renders progress bar with auto-computed state (value=50→safe, value=85→warning, value=95→danger)

- [x] **Task 2: Implement `app/templates/components/alert.html`** (AC: 1, 3)
  - [x] Macro `render_alert(type, message)`
  - [x] Types: `success` / `error` / `warning` / `info` — each uses distinct color styling
  - [x] `role="alert"` for errors, `role="status"` for all others (WCAG)
  - [x] Inline styles (Primer class equivalents added in Story 2.3)
  - [x] CSS class: `fin-alert fin-alert-{{ type }}`

- [x] **Task 3: Implement `app/templates/components/empty_state.html`** (AC: 1, 3)
  - [x] Macro `render_empty_state(title, description, cta_text=None, cta_url=None)`
  - [x] CTA link only rendered when BOTH `cta_text` and `cta_url` are provided
  - [x] Centered layout; heading in primary color, description in muted color
  - [x] CSS class: `fin-empty-state`

- [x] **Task 4: Implement `app/templates/components/page_header.html`** (AC: 1, 3)
  - [x] Macro `render_page_header(title, subtitle=None, action_text=None, action_url=None)`
  - [x] Primary action link only rendered when BOTH `action_text` and `action_url` provided
  - [x] `<h1>` for title; `<p>` for subtitle; right-aligned action button
  - [x] CSS class: `fin-page-header`

- [x] **Task 5: Implement `app/templates/components/stat_card.html`** (AC: 1, 3)
  - [x] Macro `render_stat_card(label, value, delta=None, delta_type=None)`
  - [x] `delta_type`: `'positive'` (green) / `'negative'` (red) / `None` (muted gray)
  - [x] Delta only rendered when `delta` is provided
  - [x] CSS class: `fin-stat-card`

- [x] **Task 6: Implement `app/templates/components/pagination.html`** (AC: 1, 3)
  - [x] Macro `render_pagination(page, total, per_page, url_for_page)`
  - [x] `url_for_page` is a callable — `url_for_page(page_num)` returns the URL string
  - [x] `total_pages` computed as `((total - 1) // per_page + 1) if total > 0 else 1`
  - [x] Previous link rendered as disabled span (not `<a>`) when `page == 1`
  - [x] Next link rendered as disabled span when `page == total_pages`
  - [x] Shows "Page N of M (X total)" status text
  - [x] CSS class: `fin-pagination`

- [x] **Task 7: Implement `app/templates/components/confirm_modal.html`** (AC: 1, 3)
  - [x] Macro `render_confirm_modal(modal_id, title, message, confirm_url, confirm_text='Confirm', cancel_text='Cancel', method='POST')`
  - [x] Macro `render_confirm_trigger(modal_id, label, css_class='')` — renders trigger button with `data-confirm-target`
  - [x] Modal element: `id="{{ modal_id }}"`, `role="dialog"`, `aria-modal="true"`, `aria-labelledby="{{ modal_id }}-title"`
  - [x] Initially hidden via `style="display: none"` — `modal.js` (Story 2.4) shows/hides it
  - [x] Cancel button: `data-close-modal="{{ modal_id }}"` (JS hook for Story 2.4)
  - [x] Confirm button: `data-confirm-url="{{ confirm_url }}"`, `data-confirm-method="{{ method }}"` (JS submits a form in Story 2.4)
  - [x] **NO `csrf_token()` inline** — CSRF handled by `modal.js` when it builds the POST form (Story 2.4)
  - [x] CSS class: `fin-modal`

- [x] **Task 8: Implement `app/templates/components/form_row.html`** (AC: 1, 3)
  - [x] Macro `render_field(field, label_text=None)`
  - [x] `field` is a bound WTForms field object (from a `FlaskForm` subclass)
  - [x] Renders: `<label for="{{ field.id }}">{{ label_text or field.label.text }}</label>` + `{{ field() }}` + inline error list
  - [x] Error span: `role="alert"` for accessibility
  - [x] CSS class: `fin-form-row`; errors: `fin-form-error`

- [x] **Task 9: Implement `app/templates/components/progress_bar.html`** (AC: 1, 3)
  - [x] Macro `render_progress_bar(value, label=None, state=None)`
  - [x] `value`: 0–100+ (integer or float; represents percentage)
  - [x] `state` auto-computed from `value` when not provided: `safe` (<70), `warning` (70–89), `danger` (≥90)
  - [x] Color mapping: `safe` → green (#1a7f37 fill), `warning` → amber (#9a6700 fill), `danger` → red (#cf222e fill)
  - [x] Bar width capped at 100% in CSS (`min(value, 100)%`); overflow shown via red border on outer container
  - [x] ARIA: `role="progressbar"`, `aria-valuenow="{{ value }}"`, `aria-valuemin="0"`, `aria-valuemax="100"`
  - [x] Optional `label` displayed above or beside the bar
  - [x] CSS class: `fin-progress-bar fin-progress-bar-{{ computed_state }}`

- [x] **Task 10: Run full test suite and confirm GREEN** (AC: 4)
  - [x] `pytest tests/ -v` → all component tests pass, no regressions from Stories 1.x / 2.1
  - [x] Minimum: 45 previously-passing tests still pass; all new component tests pass

## Dev Notes

### ⚠️ Critical: Macro Pattern — NOT `{% extends %}`

Every component MUST be a Jinja2 `{% macro %}`. Never use `{% extends 'base.html' %}` in component files — components are fragments, not full pages.

**Import and call pattern:**
```jinja2
{# In a blueprint template: #}
{% from 'components/alert.html' import render_alert %}
{{ render_alert(type='success', message='Saved!') }}
```

**Multi-import pattern:**
```jinja2
{% from 'components/empty_state.html' import render_empty_state %}
{% from 'components/page_header.html' import render_page_header %}
```

---

### Component Rendering in Tests

Since components are macros, tests use `render_template_string` inside `app.test_request_context()`:

```python
from flask import render_template_string

class TestAlertComponent:
    def test_alert_success_renders(self, app):
        with app.test_request_context('/'):
            html = render_template_string(
                "{% from 'components/alert.html' import render_alert %}"
                "{{ render_alert(type='success', message='Operation successful') }}"
            )
        assert 'Operation successful' in html
        assert '<div' in html
```

**Why `test_request_context('/')` not `app_context()`?**
Some components may call `url_for()` or access request-level Jinja2 globals. Using `test_request_context('/')` ensures a full Flask request context is available without needing an HTTP request.

**Why not `client.get('/some-route')`?**
No test routes are needed. `render_template_string` with request context tests Jinja2 template compilation and rendering directly — cleaner and faster.

---

### Exact Component Implementations

#### `app/templates/components/alert.html`
```jinja2
{% macro render_alert(type, message) %}
<div class="fin-alert fin-alert-{{ type }}"
     role="{% if type == 'error' %}alert{% else %}status{% endif %}"
     style="padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; border-left: 4px solid;
            {% if type == 'success' %}border-color: #1a7f37; background: #dafbe1; color: #1a7f37;
            {% elif type == 'error' %}border-color: #cf222e; background: #ffebe9; color: #cf222e;
            {% elif type == 'warning' %}border-color: #9a6700; background: #fff8c5; color: #9a6700;
            {% else %}border-color: #0969da; background: #ddf4ff; color: #0969da;{% endif %}">
  {{ message }}
</div>
{% endmacro %}
```

**Note:** `alert.html` is for **inline** contextual messages (e.g., duplicate detection warning inline on a form). It is DISTINCT from flash messages (page-level, rendered by `base.html` automatically). Do NOT use `render_alert` as a replacement for `flash()`.

---

#### `app/templates/components/empty_state.html`
```jinja2
{% macro render_empty_state(title, description, cta_text=None, cta_url=None) %}
<div class="fin-empty-state"
     style="text-align: center; padding: 48px 24px; color: #636c76;">
  <h3 style="font-size: 20px; font-weight: 600; color: #1f2328; margin-bottom: 8px;">
    {{ title }}
  </h3>
  <p style="margin-bottom: {% if cta_text and cta_url %}24px{% else %}0{% endif %}; margin-top: 0;">
    {{ description }}
  </p>
  {% if cta_text and cta_url %}
    <a href="{{ cta_url }}"
       style="display: inline-block; padding: 8px 16px; background: #0969da; color: #fff;
              text-decoration: none; border-radius: 6px; font-weight: 500;">
      {{ cta_text }}
    </a>
  {% endif %}
</div>
{% endmacro %}
```

---

#### `app/templates/components/page_header.html`
```jinja2
{% macro render_page_header(title, subtitle=None, action_text=None, action_url=None) %}
<div class="fin-page-header"
     style="display: flex; justify-content: space-between; align-items: flex-start;
            margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #d0d7de;">
  <div>
    <h1 style="font-size: 24px; font-weight: 600; color: #1f2328; margin: 0;">{{ title }}</h1>
    {% if subtitle %}
      <p style="color: #636c76; margin: 4px 0 0 0; font-size: 14px;">{{ subtitle }}</p>
    {% endif %}
  </div>
  {% if action_text and action_url %}
    <a href="{{ action_url }}"
       style="display: inline-block; padding: 8px 16px; background: #0969da; color: #fff;
              text-decoration: none; border-radius: 6px; font-weight: 500; white-space: nowrap;
              flex-shrink: 0; margin-left: 16px;">
      {{ action_text }}
    </a>
  {% endif %}
</div>
{% endmacro %}
```

---

#### `app/templates/components/stat_card.html`
```jinja2
{% macro render_stat_card(label, value, delta=None, delta_type=None) %}
<div class="fin-stat-card"
     style="background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px;
            padding: 16px; min-width: 160px;">
  <span class="fin-stat-label"
        style="display: block; font-size: 12px; color: #636c76; margin-bottom: 4px;
               text-transform: uppercase; letter-spacing: 0.5px;">
    {{ label }}
  </span>
  <span class="fin-stat-value"
        style="display: block; font-size: 24px; font-weight: 600; color: #1f2328;">
    {{ value }}
  </span>
  {% if delta is not none %}
    <span class="fin-stat-delta"
          style="display: block; font-size: 12px; margin-top: 4px;
                 {% if delta_type == 'positive' %}color: #1a7f37;
                 {% elif delta_type == 'negative' %}color: #cf222e;
                 {% else %}color: #636c76;{% endif %}">
      {{ delta }}
    </span>
  {% endif %}
</div>
{% endmacro %}
```

---

#### `app/templates/components/pagination.html`
```jinja2
{% macro render_pagination(page, total, per_page, url_for_page) %}
{% set total_pages = ((total - 1) // per_page + 1) if total > 0 else 1 %}
<nav class="fin-pagination" aria-label="Pagination"
     style="display: flex; align-items: center; gap: 16px; margin-top: 24px; font-size: 14px;">
  {% if page > 1 %}
    <a href="{{ url_for_page(page - 1) }}"
       style="padding: 6px 12px; border: 1px solid #d0d7de; border-radius: 6px;
              text-decoration: none; color: #0969da;">
      ← Previous
    </a>
  {% else %}
    <span aria-disabled="true"
          style="padding: 6px 12px; color: #636c76; border: 1px solid #d0d7de;
                 border-radius: 6px; cursor: default;">
      ← Previous
    </span>
  {% endif %}
  <span style="color: #636c76;">
    Page {{ page }} of {{ total_pages }}
    {% if total is not none %}({{ total }} total){% endif %}
  </span>
  {% if page < total_pages %}
    <a href="{{ url_for_page(page + 1) }}"
       style="padding: 6px 12px; border: 1px solid #d0d7de; border-radius: 6px;
              text-decoration: none; color: #0969da;">
      Next →
    </a>
  {% else %}
    <span aria-disabled="true"
          style="padding: 6px 12px; color: #636c76; border: 1px solid #d0d7de;
                 border-radius: 6px; cursor: default;">
      Next →
    </span>
  {% endif %}
</nav>
{% endmacro %}
```

**Usage example (in a blueprint template):**
```jinja2
{% from 'components/pagination.html' import render_pagination %}
{{ render_pagination(
    page=current_page,
    total=total_count,
    per_page=50,
    url_for_page=url_for_page_fn
) }}
```

**Route-level callable setup:**
```python
# In blueprint route:
from functools import partial
from flask import url_for

def transactions_list():
    # Build callable — url_for_page_fn(page_num) returns the URL
    url_for_page_fn = lambda p: url_for('transactions.transactions_list',
                                        page=p, **request.args)
    return render_template('transactions/list.html',
                           url_for_page=url_for_page_fn,
                           active_page='transactions')
```

**Test pattern for pagination:**
```python
def test_pagination_renders_next_link(self, app):
    with app.test_request_context('/'):
        url_fn = lambda p: f'/transactions/?page={p}'
        html = render_template_string(
            "{% from 'components/pagination.html' import render_pagination %}"
            "{{ render_pagination(page=1, total=100, per_page=10, url_for_page=url_fn) }}",
            url_fn=url_fn
        )
    assert 'page=2' in html
    assert '← Previous' in html  # Present but disabled (span, not <a>)
```

---

#### `app/templates/components/confirm_modal.html`
```jinja2
{% macro render_confirm_modal(modal_id, title, message, confirm_url,
                               confirm_text='Confirm', cancel_text='Cancel', method='POST') %}
<div id="{{ modal_id }}"
     class="fin-modal"
     role="dialog"
     aria-modal="true"
     aria-labelledby="{{ modal_id }}-title"
     style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5);
            z-index: 50; align-items: center; justify-content: center;">
  <div class="fin-modal-content"
       style="background: #fff; border-radius: 6px; padding: 24px; max-width: 420px;
              width: 90%; border: 1px solid #d0d7de; box-shadow: 0 8px 24px rgba(0,0,0,0.12);">
    <h2 id="{{ modal_id }}-title"
        style="font-size: 18px; font-weight: 600; margin: 0 0 12px 0; color: #1f2328;">
      {{ title }}
    </h2>
    <p style="color: #636c76; margin: 0 0 24px 0; font-size: 14px;">{{ message }}</p>
    <div style="display: flex; gap: 8px; justify-content: flex-end;">
      <button type="button"
              data-close-modal="{{ modal_id }}"
              style="padding: 8px 16px; border: 1px solid #d0d7de; border-radius: 6px;
                     background: #fff; cursor: pointer; font-size: 14px;">
        {{ cancel_text }}
      </button>
      <button type="button"
              data-confirm-url="{{ confirm_url }}"
              data-confirm-method="{{ method }}"
              style="padding: 8px 16px; background: #cf222e; color: #fff; border: none;
                     border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;">
        {{ confirm_text }}
      </button>
    </div>
  </div>
</div>
{% endmacro %}

{% macro render_confirm_trigger(modal_id, label, css_class='') %}
<button type="button"
        data-confirm-target="{{ modal_id }}"
        class="{{ css_class }}"
        style="cursor: pointer;">
  {{ label }}
</button>
{% endmacro %}
```

**⚠️ No `csrf_token()` in this component** — Story 2.4's `modal.js` is responsible for building the form and injecting the CSRF token before submitting. The `data-confirm-url` and `data-confirm-method` attributes are the contract between this component and `modal.js`.

**Usage example:**
```jinja2
{% from 'components/confirm_modal.html' import render_confirm_modal, render_confirm_trigger %}

{# Trigger button (opens the modal): #}
{{ render_confirm_trigger('delete-modal-1', 'Delete', css_class='btn-danger') }}

{# Modal (place at bottom of page): #}
{{ render_confirm_modal(
    modal_id='delete-modal-1',
    title='Delete Transaction',
    message='Are you sure? This cannot be undone.',
    confirm_url=url_for('transactions.transactions_delete', id=txn.id),
    confirm_text='Delete',
    cancel_text='Cancel'
) }}
```

---

#### `app/templates/components/form_row.html`
```jinja2
{% macro render_field(field, label_text=None) %}
<div class="fin-form-row" style="margin-bottom: 16px;">
  <label for="{{ field.id }}"
         style="display: block; font-size: 14px; font-weight: 500;
                color: #1f2328; margin-bottom: 4px;">
    {{ label_text if label_text else field.label.text }}
  </label>
  {{ field(style="display: block; width: 100%; padding: 6px 10px; border: 1px solid #d0d7de;
                  border-radius: 6px; font-size: 14px; box-sizing: border-box;") }}
  {% for error in field.errors %}
    <span class="fin-form-error"
          role="alert"
          style="display: block; color: #cf222e; font-size: 12px; margin-top: 4px;">
      {{ error }}
    </span>
  {% endfor %}
</div>
{% endmacro %}
```

**Important:** The `field()` call renders the input element via WTForms' field widget system (e.g., `TextInput`, `SelectField`). The `style=` kwarg is passed through to the rendered `<input>`. This pattern works with all WTForms field types.

**Usage example:**
```jinja2
{% from 'components/form_row.html' import render_field %}
<form method="POST">
  {{ form.hidden_tag() }}
  {{ render_field(form.merchant) }}
  {{ render_field(form.amount) }}
  {{ render_field(form.category) }}
  <button type="submit">Save</button>
</form>
```

**Test pattern:**
```python
from flask_wtf import FlaskForm
from wtforms import StringField

class MinimalForm(FlaskForm):
    username = StringField('Username')

def test_form_row_renders(self, app):
    with app.test_request_context('/'):
        form = MinimalForm()
        html = render_template_string(
            "{% from 'components/form_row.html' import render_field %}"
            "{{ render_field(field=form.username) }}",
            form=form
        )
    assert 'Username' in html
    assert 'input' in html
```

---

#### `app/templates/components/progress_bar.html`
```jinja2
{% macro render_progress_bar(value, label=None, state=None) %}
{% set computed_state = state if state else
    ('danger' if value >= 90 else ('warning' if value >= 70 else 'safe')) %}
{% set fill_color = '#cf222e' if computed_state == 'danger'
    else ('#9a6700' if computed_state == 'warning' else '#1a7f37') %}
{% set display_pct = [value, 100] | min %}
<div class="fin-progress-bar fin-progress-bar-{{ computed_state }}"
     {% if label %}aria-label="{{ label }}: {{ value }}%"{% endif %}
     style="margin-bottom: 12px;">
  {% if label %}
    <div style="display: flex; justify-content: space-between; font-size: 13px;
                color: #1f2328; margin-bottom: 4px;">
      <span>{{ label }}</span>
      <span style="color: {% if value > 100 %}#cf222e{% else %}#636c76{% endif %};">
        {{ value }}%
      </span>
    </div>
  {% endif %}
  <div role="progressbar"
       aria-valuenow="{{ value }}"
       aria-valuemin="0"
       aria-valuemax="100"
       style="height: 8px; background: #eaeef2; border-radius: 4px; overflow: hidden;
              {% if value > 100 %}border: 1px solid #cf222e;{% endif %}">
    <div style="height: 100%; width: {{ display_pct }}%; background: {{ fill_color }};
                border-radius: 4px; transition: width 0.3s ease;"></div>
  </div>
</div>
{% endmacro %}
```

**Note on `[value, 100] | min`:** Jinja2 does NOT have a built-in `min` filter for lists. Use `[value, 100] | min` only if a custom `min` filter is registered, otherwise compute inline:
```jinja2
{% set display_pct = value if value <= 100 else 100 %}
```

Use the safer form. The implementation MUST use:
```jinja2
{% set display_pct = value if value <= 100 else 100 %}
```

---

### ⚠️ Critical: Do NOT Modify These Files

| File | Why |
|------|-----|
| `app/templates/base.html` | Story 2.1 — complete |
| `app/templates/errors/404.html` | Story 2.1 — complete |
| `app/templates/errors/500.html` | Story 2.1 — complete |
| `app/blueprints/dashboard/templates/dashboard/index.html` | Story 2.1 — complete |
| `app/static/css/tokens.css` | Story 2.3 — add `--color-safe`, `--color-warning`, `--color-danger` there |
| `app/static/css/app.css` | Story 2.3 |
| `app/static/js/modal.js` | Story 2.4 — implements focus trap, Esc close, trigger convention |
| `app/static/js/loading.js` | Story 2.4 |
| `app/static/js/chart-defaults.js` | Story 2.4 |
| `app/models/` (all) | Stories 1.4 — complete |
| `tests/conftest.py` | Working — do not modify |

---

### Previous Story Learnings (Story 2.1)

**Key fix from Story 2.1:** Blueprint `__init__.py` MUST declare `template_folder='templates'` to make blueprint-local templates discoverable. Without it, Flask only looks in the app-level `templates/` directory.

**Story 2.2 is NOT affected** by this — all 8 component files live in `app/templates/components/` (app-level), not inside any blueprint's `templates/` folder. No blueprint configuration changes are needed for this story.

**`render_template_string` in tests:** The flash-injection pattern from Story 2.1 (`client.session_transaction()`) is not needed here — component tests use `render_template_string` directly.

---

### TDD Workflow for This Story

```
Step 1: Write ALL tests in test_components.py (Task 1)
Step 2: Run pytest — ALL 8+ component tests FAIL (red phase, expected)
Step 3: Implement components one by one (Tasks 2–9)
         Run pytest after each — watch tests turn green
Step 4: Run full suite (Task 10) — all tests pass
```

Expected test count after Task 10:
- 45 existing tests pass (from Stories 1.x and 2.1)
- 1 existing skip remains
- ~16 new component tests pass (approx 2 per component)
- **Total: ~61 passed, 1 skipped**

---

### CSS Architecture Notes

Story 2.2 uses **inline `style=` attributes** as placeholders for all components. This is intentional:
- Story 2.3 implements `tokens.css` and `app.css` with Primer CSS loaded
- When Story 2.3 lands, `base.html` and components can be updated to use `fin-` CSS classes instead of inline styles
- The `fin-` class names are already applied — they're just not styled yet (no `app.css` rules)

Do NOT add CSS rules to `tokens.css` or `app.css` in Story 2.2. Those files are Story 2.3.

---

### Architecture Compliance Checklist

- [x] Components use `{% macro %}`, NOT `{% extends %}` (AR-8)
- [x] Components live in `app/templates/components/` (AR-8)
- [x] All 8 components established before any blueprint view beyond the dashboard stub (AR-8)
- [x] `active_page` not applicable to components (they're fragments, not full pages)
- [x] No `db.session` access anywhere in templates
- [x] Flash categories not used in components — flash is `base.html`'s responsibility (AR-14)
- [x] `confirm_modal.html` defers JS behavior to `modal.js` (Story 2.4) — clean separation

---

### File List (Expected Changes)

| File | Status |
|------|--------|
| `app/templates/components/alert.html` | MODIFY (replace stub) |
| `app/templates/components/empty_state.html` | MODIFY (replace stub) |
| `app/templates/components/page_header.html` | MODIFY (replace stub) |
| `app/templates/components/stat_card.html` | MODIFY (replace stub) |
| `app/templates/components/pagination.html` | MODIFY (replace stub) |
| `app/templates/components/confirm_modal.html` | MODIFY (replace stub) |
| `app/templates/components/form_row.html` | MODIFY (replace stub) |
| `app/templates/components/progress_bar.html` | MODIFY (replace stub) |
| `tests/test_blueprints/test_components.py` | NEW |

---

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

No blocking issues encountered. One pre-existing note from story creation: Jinja2 has no built-in `min` filter for lists, so `progress_bar.html` uses `{% set display_pct = value if value <= 100 else 100 %}` rather than `[value, 100] | min`.

### Completion Notes List

- All 8 Jinja2 macro components implemented from exact specifications in Dev Notes
- 25 component tests written first (TDD RED), then all turned GREEN after implementation
- `confirm_modal.html` contains NO `csrf_token()` — deferred to `modal.js` (Story 2.4) per AR spec
- `pagination.html` accepts Python callable `url_for_page` as macro argument — works in Jinja2
- `progress_bar.html` state auto-computation: safe (<70), warning (70–89), danger (≥90); explicit `state` overrides
- Full suite result: **70 passed, 1 skipped** — 25 new component tests + 45 prior tests, zero regressions

### File List

| File | Status |
|------|--------|
| `app/templates/components/alert.html` | MODIFIED (stub → macro) |
| `app/templates/components/empty_state.html` | MODIFIED (stub → macro) |
| `app/templates/components/page_header.html` | MODIFIED (stub → macro) |
| `app/templates/components/stat_card.html` | MODIFIED (stub → macro) |
| `app/templates/components/pagination.html` | MODIFIED (stub → macro) |
| `app/templates/components/confirm_modal.html` | MODIFIED (stub → 2 macros) |
| `app/templates/components/form_row.html` | MODIFIED (stub → macro) |
| `app/templates/components/progress_bar.html` | MODIFIED (stub → macro) |
| `tests/test_blueprints/test_components.py` | NEW |

### Change Log

- 2026-05-27: Story 2.2 implemented — all 8 component macros created, 25 tests passing (70 total, 1 skipped)
