# Story 2.1: Base Template & Navigation Structure

Status: review

## Story

As a user,
I want a consistent navigation shell with active-page highlighting across the app,
So that I can move between all sections from any page without confusion.

## Acceptance Criteria

1. `app/templates/base.html` exists with: full HTML page shell, `<nav>` sidebar linking all 8 sections (Dashboard, Transactions, Budgets, Bills, Paydown, Import, Analytics, Settings), `{% block content %}`, and flash message rendering for all 4 categories (`success` / `error` / `warning` / `info`).
2. The nav marks the active link using the `active_page` context variable — convention documented in a comment at the top of `base.html`.
3. Every `render_template()` call passes `active_page='<blueprint_name>'` — enforced by smoke test in `tests/test_blueprints/test_base_template.py`.
4. `app/templates/errors/404.html` and `errors/500.html` both extend `base.html`.
5. `@app.errorhandler(404)` and `@app.errorhandler(500)` in `create_app()` render the template pages (not plain text).
6. `GET /` returns HTTP 200 with the full base template rendered (not plain text `"Financials App"`).
7. `pytest tests/` passes with all Stories 1.2–1.5 tests still green (37 passing + 1 skipped minimum).

## Tasks / Subtasks

- [x] **Task 1: Create `app/blueprints/dashboard/templates/dashboard/index.html`** (AC: 6)
  - [x] Minimal template that `{% extends 'base.html' %}`
  - [x] Sets `{% block title %}Dashboard — Financials{% endblock %}`
  - [x] `{% block content %}` with placeholder heading "Dashboard" (Epic 8 will implement fully)

- [x] **Task 2: Update `app/blueprints/dashboard/routes.py`** (AC: 3, 6)
  - [x] Import `render_template` from flask
  - [x] Replace `return 'Financials App', 200` with `return render_template('dashboard/index.html', active_page='dashboard')`

- [x] **Task 3: Implement `app/templates/base.html`** (AC: 1, 2)
  - [x] Full HTML5 shell: `<!DOCTYPE html>`, `<html lang="en">`, `<head>`, `<body>`
  - [x] `<head>`: charset, viewport, `{% block title %}Financials{% endblock %}`, Primer CSS link, tokens.css link, app.css link
  - [x] Skip-to-content link for accessibility: `<a href="#main-content" class="skip-link">Skip to main content</a>`
  - [x] Sidebar `<nav aria-label="Main navigation">` with 8 nav items using hardcoded URL paths (NOT `url_for()` for blueprints without routes yet — see Dev Notes)
  - [x] `active_page` convention documented in header comment block at top of file
  - [x] Nav active state: `aria-current="page"` on active item
  - [x] `<main id="main-content">` containing flash message block and `{% block content %}`
  - [x] Flash messages rendered via `get_flashed_messages(with_categories=True)` — all 4 category classes
  - [x] JS script tags for `chart-defaults.js`, `modal.js`, `loading.js` (stubs exist; implemented Story 2.4)

- [x] **Task 4: Implement `app/templates/errors/404.html`** (AC: 4)
  - [x] `{% extends 'base.html' %}`
  - [x] `{% block title %}404 Not Found — Financials{% endblock %}`
  - [x] `{% block content %}` with clear 404 message and "Go to Dashboard" link

- [x] **Task 5: Implement `app/templates/errors/500.html`** (AC: 4)
  - [x] `{% extends 'base.html' %}`
  - [x] `{% block title %}500 Server Error — Financials{% endblock %}`
  - [x] `{% block content %}` with user-friendly error message

- [x] **Task 6: Update `app/__init__.py` error handlers** (AC: 5)
  - [x] Add `render_template` to Flask import
  - [x] Replace `return 'Not Found', 404` with `return render_template('errors/404.html'), 404`
  - [x] Replace `return 'Internal Server Error', 500` with `return render_template('errors/500.html'), 500`

- [x] **Task 7: Write smoke tests in `tests/test_blueprints/test_base_template.py`** (AC: 2, 3, 5, 6)
  - [x] `test_index_returns_200_with_html` — `GET /` → 200, response contains `<html`
  - [x] `test_nav_contains_all_sections` — response contains links to all 8 blueprint URL paths
  - [x] `test_dashboard_active_page_marked` — `GET /` response contains `aria-current="page"` once
  - [x] `test_404_renders_html_not_plain_text` — `GET /nonexistent-path` → 404, contains `<html`
  - [x] `test_flash_success_renders` — inject flash 'success' message via session, check it appears in `GET /`
  - [x] `test_flash_error_renders` — inject 'error' flash
  - [x] `test_flash_warning_renders` — inject 'warning' flash
  - [x] `test_flash_info_renders` — inject 'info' flash

- [x] **Task 8: Run full test suite** (AC: 7)
  - [x] `pytest tests/ -v` → at least 37 passed, 1 skipped, 0 failures — **Result: 45 passed, 1 skipped**
  - [x] Confirm old tests in `test_models/`, `test_services/`, `test_utils/` still pass

## Dev Notes

### ⚠️ Critical: `url_for()` in Nav — Use Hardcoded Paths

**DO NOT use `url_for()` for blueprints that don't have named routes yet.**

In `base.html`, the nav links to all 8 sections. But most blueprints (transactions, budgets, bills, paydown, import_pdf, analytics, settings) currently only have `from app.blueprints.<name> import <name>_bp  # noqa: F401` in their `routes.py` — NO actual route functions. Calling `url_for('transactions.transactions_list')` when `transactions_list` doesn't exist raises `werkzeug.routing.exceptions.BuildError` and crashes the template render.

**Correct pattern for nav links in `base.html`:**
```html
<a href="/transactions/">Transactions</a>
```

**NOT:**
```html
<a href="{{ url_for('transactions.transactions_list') }}">Transactions</a>
```

Use `url_for('dashboard.index')` ONLY for the dashboard link — it has a real route.  
Use `url_for('static', filename='...')` for CSS/JS assets — always safe.  
All other nav links: hardcoded paths matching blueprint URL prefixes.

**URL path map:**
| Section | Path | Blueprint |
|---------|------|-----------|
| Dashboard | `{{ url_for('dashboard.index') }}` | `dashboard` (has route) |
| Transactions | `/transactions/` | `transactions` |
| Budgets | `/budgets/` | `budgets` |
| Bills | `/bills/` | `bills` |
| Paydown | `/paydown/` | `paydown` |
| Import | `/import/` | `import_pdf` |
| Analytics | `/analytics/` | `analytics` |
| Settings | `/settings/` | `settings` |

As each blueprint gains a real index route in later stories, their nav link can be converted to `url_for()`. Do NOT do this eagerly — only convert when the endpoint exists.

---

### Exact `app/templates/base.html` Implementation

```html
{#
  base.html — Application shell template.

  Active page convention (AR-8, architecture.md):
    EVERY render_template() call in every blueprint MUST pass active_page='<blueprint_name>'.
    Blueprint names to use:
      'dashboard', 'transactions', 'budgets', 'bills',
      'paydown', 'import_pdf', 'analytics', 'settings'
    The nav uses this variable to set aria-current="page" on the active link.
    Enforced by: tests/test_blueprints/test_base_template.py

  Flash message categories (AR-14):
    'success' → flash-success (green)
    'error'   → flash-error (red)
    'warning' → flash-warning (amber)
    'info'    → flash (blue, base class only)
    Use only these 4. Any other category will render unstyled.
#}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}Financials{% endblock %}</title>
  {# Primer CSS — local copy. File is downloaded in Story 2.3 (static/vendor/primer/primer.css).
     This link is correct; the file simply won't exist until Story 2.3 runs. #}
  <link rel="stylesheet" href="{{ url_for('static', filename='vendor/primer/primer.css') }}">
  {# Domain semantic tokens — stub in Story 2.1, fully implemented in Story 2.3 #}
  <link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
  {# App-specific styles — stub in Story 2.1, fully implemented in Story 2.3 #}
  <link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
  {# Skip to main content — accessibility (WCAG 2.1 AA) #}
  <a href="#main-content" class="skip-link"
     style="position: absolute; top: -40px; left: 0; background: #0969da; color: #fff; padding: 8px; z-index: 100; text-decoration: none;"
     onfocus="this.style.top='0'" onblur="this.style.top='-40px'">
    Skip to main content
  </a>

  <div style="display: flex; min-height: 100vh;">
    {# ── Sidebar Navigation (240px fixed) ────────────────────────────── #}
    <nav aria-label="Main navigation"
         style="width: 240px; flex-shrink: 0; border-right: 1px solid #d0d7de; padding: 16px 0;">
      <div style="padding: 12px 16px 16px;">
        <strong style="font-size: 16px;">Financials</strong>
      </div>
      <ul style="list-style: none; margin: 0; padding: 0;" role="list">
        <li>
          <a href="{{ url_for('dashboard.index') }}"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'dashboard' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'dashboard' %}aria-current="page"{% endif %}>
            Dashboard
          </a>
        </li>
        <li>
          <a href="/transactions/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'transactions' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'transactions' %}aria-current="page"{% endif %}>
            Transactions
          </a>
        </li>
        <li>
          <a href="/budgets/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'budgets' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'budgets' %}aria-current="page"{% endif %}>
            Budgets
          </a>
        </li>
        <li>
          <a href="/bills/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'bills' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'bills' %}aria-current="page"{% endif %}>
            Bills &amp; Debts
          </a>
        </li>
        <li>
          <a href="/paydown/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'paydown' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'paydown' %}aria-current="page"{% endif %}>
            Paydown Planner
          </a>
        </li>
        <li>
          <a href="/import/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'import_pdf' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'import_pdf' %}aria-current="page"{% endif %}>
            Import
          </a>
        </li>
        <li>
          <a href="/analytics/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'analytics' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'analytics' %}aria-current="page"{% endif %}>
            Analytics
          </a>
        </li>
        <li>
          <a href="/settings/"
             style="display: block; padding: 8px 16px; text-decoration: none; color: inherit;{% if active_page == 'settings' %} font-weight: 600; border-left: 3px solid #0969da;{% endif %}"
             {% if active_page == 'settings' %}aria-current="page"{% endif %}>
            Settings
          </a>
        </li>
      </ul>
    </nav>

    {# ── Main Content Area ─────────────────────────────────────────── #}
    <main id="main-content" style="flex: 1; padding: 24px; max-width: 960px;">

      {# Flash messages — AR-14: categories are success / error / warning / info only #}
      {% with messages = get_flashed_messages(with_categories=True) %}
        {% if messages %}
          {% for category, message in messages %}
            <div class="flash{% if category != 'info' %} flash-{{ category }}{% endif %}"
                 role="{% if category == 'error' %}alert{% else %}status{% endif %}"
                 style="margin-bottom: 16px; padding: 12px 16px; border-radius: 6px; border: 1px solid #d0d7de;">
              {{ message }}
            </div>
          {% endfor %}
        {% endif %}
      {% endwith %}

      {% block content %}{% endblock %}
    </main>
  </div>

  {# JS utilities — stubs in Story 2.1; fully implemented in Story 2.4 #}
  <script src="{{ url_for('static', filename='js/chart-defaults.js') }}"></script>
  <script src="{{ url_for('static', filename='js/modal.js') }}"></script>
  <script src="{{ url_for('static', filename='js/loading.js') }}"></script>
</body>
</html>
```

---

### Exact `app/templates/errors/404.html` Implementation

```html
{% extends 'base.html' %}
{% block title %}404 Not Found — Financials{% endblock %}
{% block content %}
<h1>404 — Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="{{ url_for('dashboard.index') }}">← Go to Dashboard</a>
{% endblock %}
```

---

### Exact `app/templates/errors/500.html` Implementation

```html
{% extends 'base.html' %}
{% block title %}500 Server Error — Financials{% endblock %}
{% block content %}
<h1>500 — Server Error</h1>
<p>Something went wrong on our end. Please try again.</p>
<a href="{{ url_for('dashboard.index') }}">← Go to Dashboard</a>
{% endblock %}
```

---

### Exact `app/blueprints/dashboard/templates/dashboard/index.html`

```html
{% extends 'base.html' %}
{% block title %}Dashboard — Financials{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p>Your financial overview will appear here. Full implementation in Epic 8.</p>
{% endblock %}
```

---

### Exact `app/blueprints/dashboard/routes.py` Update

```python
from flask import render_template
from app.blueprints.dashboard import dashboard_bp


@dashboard_bp.route('/')
def index():
    """Dashboard placeholder — Story 8 implements the full dashboard."""
    return render_template('dashboard/index.html', active_page='dashboard')
```

---

### Exact `app/__init__.py` Error Handler Update

In `create_app()`, replace the two error handlers at the bottom:

```python
# BEFORE (Story 1.5 stub):
@app.errorhandler(404)
def not_found(e):
    return 'Not Found', 404

@app.errorhandler(500)
def server_error(e):
    return 'Internal Server Error', 500
```

```python
# AFTER (Story 2.1):
@app.errorhandler(404)
def not_found(e):
    return render_template('errors/404.html'), 404

@app.errorhandler(500)
def server_error(e):
    return render_template('errors/500.html'), 500
```

Also add `render_template` to the Flask import at the top of `create_app()` (or import it inside the error handlers — either works; importing inside the function avoids polluting the module namespace):

```python
# Option A: add to module-level import (preferred)
from flask import Flask, render_template

# Option B: import inside factory function
def create_app(config=None):
    from flask import render_template
    ...
```

Actually the simplest is to import at the top of `create_app()`:
```python
def create_app(config=None):
    from flask import render_template
    app = Flask(__name__)
    ...
```

Or even simpler — since `render_template` is a top-level Flask import, add it to the existing `from flask import Flask` line:
```python
from flask import Flask, render_template
```

---

### Exact `tests/test_blueprints/test_base_template.py` Implementation

```python
"""
Smoke tests for base.html — AC 2, 3, 5, 6 (Story 2.1).

Tests that:
- GET / returns 200 with full HTML (not plain text)
- All 8 nav sections are linked in the response
- active_page='dashboard' marks the dashboard link with aria-current="page"
- 404 renders an HTML template (not plain "Not Found" text)
- All 4 flash categories render correctly
"""
import pytest


class TestBaseTemplate:
    def test_index_returns_200_with_html(self, client):
        resp = client.get('/')
        assert resp.status_code == 200
        assert b'<html' in resp.data

    def test_nav_contains_all_section_links(self, client):
        resp = client.get('/')
        data = resp.data
        assert b'/transactions/' in data
        assert b'/budgets/' in data
        assert b'/bills/' in data
        assert b'/paydown/' in data
        assert b'/import/' in data
        assert b'/analytics/' in data
        assert b'/settings/' in data

    def test_dashboard_active_page_aria_current(self, client):
        resp = client.get('/')
        assert b'aria-current="page"' in resp.data

    def test_404_returns_html_template(self, client):
        resp = client.get('/this-path-does-not-exist-anywhere')
        assert resp.status_code == 404
        assert b'<html' in resp.data
        # Should NOT be plain text "Not Found"
        assert resp.data.strip() != b'Not Found'

    def test_flash_success_renders(self, client, app):
        with client.session_transaction() as sess:
            sess['_flashes'] = [('success', 'It worked!')]
        resp = client.get('/')
        assert b'It worked!' in resp.data
        assert b'flash-success' in resp.data

    def test_flash_error_renders(self, client, app):
        with client.session_transaction() as sess:
            sess['_flashes'] = [('error', 'Something failed')]
        resp = client.get('/')
        assert b'Something failed' in resp.data
        assert b'flash-error' in resp.data

    def test_flash_warning_renders(self, client, app):
        with client.session_transaction() as sess:
            sess['_flashes'] = [('warning', 'Watch out')]
        resp = client.get('/')
        assert b'Watch out' in resp.data
        assert b'flash-warning' in resp.data

    def test_flash_info_renders(self, client, app):
        with client.session_transaction() as sess:
            sess['_flashes'] = [('info', 'FYI message')]
        resp = client.get('/')
        assert b'FYI message' in resp.data
        # info flash uses base 'flash' class (no modifier)
        assert b'FYI message' in resp.data
```

**Note on flash session injection**: `client.session_transaction()` modifies the Flask session cookie. The `_flashes` key is a list of `(category, message)` tuples — this is Flask's internal flash storage format. This is the standard test pattern for injecting flash messages.

---

### ⚠️ Critical: Static File References in Tests

The test client doesn't serve static files — `url_for('static', filename='...')` just generates URL strings in the rendered HTML. The CSS/JS files referenced in `base.html` (including the not-yet-existing `vendor/primer/primer.css`) do NOT need to exist for Flask template rendering. The tests pass as long as the template renders without Jinja2 errors.

This means:
- `vendor/primer/primer.css` referenced but not present → **OK** (tests still pass)
- `css/tokens.css` stub exists → **OK**
- `css/app.css` stub exists → **OK**
- `js/modal.js`, `js/loading.js`, `js/chart-defaults.js` stubs exist → **OK**

---

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

| File | Why |
|------|-----|
| `app/templates/components/alert.html` | Story 2.2 — currently a `{# ... #}` comment stub |
| `app/templates/components/empty_state.html` | Story 2.2 |
| `app/templates/components/page_header.html` | Story 2.2 |
| `app/templates/components/stat_card.html` | Story 2.2 |
| `app/templates/components/pagination.html` | Story 2.2 |
| `app/templates/components/confirm_modal.html` | Story 2.2 |
| `app/templates/components/form_row.html` | Story 2.2 |
| `app/templates/components/progress_bar.html` | Story 2.2 |
| `app/static/css/tokens.css` | Story 2.3 |
| `app/static/css/app.css` | Story 2.3 |
| `app/static/js/modal.js` | Story 2.4 |
| `app/static/js/loading.js` | Story 2.4 |
| `app/static/js/chart-defaults.js` | Story 2.4 |
| `app/models/` (all) | Stories 1.4 — complete; do not touch |
| `migrations/` | Story 1.4 — complete |
| `tests/conftest.py` | Working — do not modify |

---

### Existing Code State (What You're Modifying)

**`app/__init__.py` current error handlers (Story 1.5):**
```python
@app.errorhandler(404)
def not_found(e):
    return 'Not Found', 404

@app.errorhandler(500)
def server_error(e):
    return 'Internal Server Error', 500
```
→ Replace with `render_template()` calls (see above).

**`app/blueprints/dashboard/routes.py` current state:**
```python
from app.blueprints.dashboard import dashboard_bp

@dashboard_bp.route('/')
def index():
    """Dashboard placeholder — Story 8 implements the full dashboard."""
    return 'Financials App', 200
```
→ Replace with `render_template('dashboard/index.html', active_page='dashboard')`.

**`app/templates/base.html` current state:**
```
{# base.html — implemented in Story 2.1 #}
```
→ Replace entirely with the full implementation above.

**`app/templates/errors/404.html` and `500.html` current state:**
Both are single-line comment stubs → replace entirely.

---

### Previous Story Learnings (Story 1.5)

- `__import__('app.models')` pattern in `create_app()` — keep intact; do NOT add `import` statements that rebind local `app` variable
- `from flask import Flask` in `app/__init__.py` — update to `from flask import Flask, render_template` (add `render_template`)
- Blueprint stubs: 9 blueprints are registered at startup; their `routes.py` files currently import the blueprint object but define no route functions. This is intentional — later stories add routes.
- `tests/conftest.py` `app` fixture passes `WTF_CSRF_ENABLED=False` — keeps working as-is
- The `client` fixture wraps `app.test_client()` — standard, no changes needed

---

### Architecture References

- **AR-8** (`architecture.md`): All 8 template components established in `app/templates/components/` BEFORE any blueprint view is written. Story 2.2 implements them. Story 2.1 must NOT alter the component stub files.
- **AR-9**: `tokens.css` and `app.css` — created (as stubs) in Story 1.1; implemented in Story 2.3. Story 2.1 may reference but NOT modify them.
- **AR-10**: Primer CSS and Chart.js served from `static/vendor/` (local copies). Story 2.3 downloads them. `base.html` references the path; the files will exist after Story 2.3.
- **AR-14**: PRG pattern and flash categories. `flash()` categories: `'success'`, `'error'`, `'warning'`, `'info'` only.
- **Boundary 1**: `flash()`, `redirect()`, `render_template()` live in blueprints/routes. The `create_app()` error handlers are an exception (they're registered on the app, not a blueprint).

### UX Requirements for This Story

- Direction A layout: sidebar (240px fixed) + main content area (flexible)
- 8 nav items (from `ux-design-specification.md`): Dashboard, Transactions, Budgets, Bills & Debts, Paydown Planner, Import, Analytics, Settings
- Flash messages: positioned above `{% block content %}` in main area, auto-dismissible (JS in Story 2.4)
- `aria-current="page"` on active nav item (accessibility requirement)
- Skip-to-content link for keyboard navigation
- `<main id="main-content">` as landmark region

Note: The inline `style=` attributes used in this story's base.html implementation are intentional placeholders. Story 2.3 adds `tokens.css` / `app.css` with proper Primer CSS classes. Story 2.3 can refactor `base.html` to use proper Primer class names (e.g., `ActionList`, `d-flex`) once the CSS is available.

### Files Modified in This Story

| File | Status | Notes |
|------|--------|-------|
| `app/templates/base.html` | **MODIFY** | Replace stub with full implementation |
| `app/templates/errors/404.html` | **MODIFY** | Replace stub with base.html-extending template |
| `app/templates/errors/500.html` | **MODIFY** | Replace stub with base.html-extending template |
| `app/__init__.py` | **MODIFY** | Add `render_template` import; update 404/500 handlers |
| `app/blueprints/dashboard/routes.py` | **MODIFY** | Use render_template instead of plain text return |
| `app/blueprints/dashboard/templates/dashboard/index.html` | **NEW** | Minimal dashboard template extending base.html |
| `tests/test_blueprints/test_base_template.py` | **NEW** | Smoke tests for nav, active_page, error pages, flash |

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

- **DEBUG-001**: `TemplateNotFound: dashboard/index.html` during Task 8 test run. Root cause: `Blueprint('dashboard', __name__)` was missing `template_folder='templates'`. Fix: updated `app/blueprints/dashboard/__init__.py` to `Blueprint('dashboard', __name__, template_folder='templates')`. Blueprint-local templates require explicit `template_folder` declaration; without it Flask only searches the app-level `templates/` directory.

### Completion Notes List

- Implemented full HTML5 shell in `base.html` with sidebar nav (240px fixed), skip-to-main accessibility link, flash message block (all 4 categories: success/error/warning/info), `{% block content %}`, and JS stub script tags.
- Used hardcoded URL paths for 7 of 8 nav items (all except dashboard). Only `url_for('dashboard.index')` is safe because that is the only blueprint stub with a real named route function. Using `url_for()` for blueprints with no route functions raises `BuildError` at template render time.
- `active_page` convention documented in Jinja2 comment block at top of `base.html` per AR-8. Every `render_template()` call must pass `active_page='<blueprint_name>'`.
- Flash categories mapped to Primer CSS classes: `flash-success`, `flash-error`, `flash-warning`, and `flash` (base class only for 'info'). Role set to `alert` for errors, `status` for all others.
- Error handlers updated in `app/__init__.py` to use `render_template('errors/404.html')` and `render_template('errors/500.html')`.
- TDD: all 8 tests written and confirmed FAILING (red phase) before any template implementation. All 8 now PASS (green phase). No regressions — 45 passed, 1 skipped across full suite.
- Flash message injection in tests uses `client.session_transaction()` to set `sess['_flashes'] = [(category, message)]` — Flask's internal storage format.

### File List

- `app/blueprints/dashboard/templates/dashboard/index.html` — NEW
- `app/blueprints/dashboard/__init__.py` — MODIFIED (added `template_folder='templates'`)
- `app/blueprints/dashboard/routes.py` — MODIFIED (render_template + active_page)
- `app/templates/base.html` — MODIFIED (full implementation replacing stub)
- `app/templates/errors/404.html` — MODIFIED (full template replacing stub)
- `app/templates/errors/500.html` — MODIFIED (full template replacing stub)
- `app/__init__.py` — MODIFIED (render_template import + error handler templates)
- `tests/test_blueprints/test_base_template.py` — NEW

### Change Log

- 2026-05-27: Story 2.1 implemented — base.html shell, dashboard template, error pages, flash rendering, active_page convention, smoke tests. 45 passed, 1 skipped. Status → review.
