# Story 2.3: CSS Architecture & Vendor Assets

Status: review

## Story

As a developer,
I want `tokens.css`, `app.css`, and all vendor assets in place before any feature stylesheet work begins,
so that every blueprint uses consistent domain semantic tokens instead of hardcoded Primer class names, and no CDN calls are ever made.

## Acceptance Criteria

1. `app/static/css/tokens.css` defines `--color-safe`, `--color-warning`, `--color-danger` as CSS custom properties mapped to Primer utility class color values, with a comment block documenting the mapping convention.
2. `app/static/css/app.css` defines `fin-` prefixed utility classes, the `[data-loading="true"]` loading state pattern (`.fin-content` opacity 0.4 + pointer-events none; `.fin-spinner` display block), and flash message classes used by `base.html`.
3. Primer CSS is present at `app/static/vendor/primer/primer.css` (local copy — no CDN).
4. Chart.js bundle is present at `app/static/vendor/chartjs/chart.min.js` (local copy — no CDN).
5. `GET /static/vendor/chartjs/chart.min.js` returns HTTP 200.
6. `GET /static/vendor/primer/primer.css` returns HTTP 200.
7. A grep of `app/` HTML/CSS/JS files finds zero references to `cdn.`, `unpkg.`, `jsdelivr.`, or `cdnjs.` (NFR-6).
8. `pytest tests/` passes with all prior tests still green (no regressions).

## Tasks / Subtasks

- [ ] **Task 1: Write `tests/test_blueprints/test_assets.py`** (TDD RED — tests fail before implementation) (AC: 5, 6, 7)
  - [ ] Test `GET /static/vendor/primer/primer.css` → HTTP 200 (will fail: file missing)
  - [ ] Test `GET /static/vendor/chartjs/chart.min.js` → HTTP 200 (will fail: file missing)
  - [ ] Test `tokens.css` contains `--color-safe`, `--color-warning`, `--color-danger` (will fail: stub file)
  - [ ] Test `app.css` contains `[data-loading="true"] .fin-content` with opacity + pointer-events rules
  - [ ] Test `app.css` contains `[data-loading="true"] .fin-spinner` display rule
  - [ ] Test no CDN references in `app/` HTML/CSS/JS files (will pass immediately — no CDN refs exist yet)
  - [ ] Confirm static-serve tests fail (RED phase) before downloading vendor files

- [ ] **Task 2: Download Primer CSS to `app/static/vendor/primer/primer.css`** (AC: 3, 6)
  - [ ] Run download command (see Dev Notes for exact command)
  - [ ] Verify file is non-empty and contains valid CSS (starts with `/*` or `:root` or `.`)
  - [ ] Confirm `GET /static/vendor/primer/primer.css` now returns 200

- [ ] **Task 3: Download Chart.js to `app/static/vendor/chartjs/chart.min.js`** (AC: 4, 5)
  - [ ] Run download command (see Dev Notes for exact command)
  - [ ] Verify file is non-empty and contains valid JS (contains `Chart`)
  - [ ] Confirm `GET /static/vendor/chartjs/chart.min.js` now returns 200

- [ ] **Task 4: Implement `app/static/css/tokens.css`** (AC: 1)
  - [ ] Replace stub with full implementation (see Dev Notes for exact content)
  - [ ] `:root` block defines `--color-safe`, `--color-warning`, `--color-danger`
  - [ ] Comment block documents each token → Primer color mapping
  - [ ] Also define `--color-accent`, `--color-muted`, `--color-border`, `--color-canvas`, `--color-fg` aliases

- [ ] **Task 5: Implement `app/static/css/app.css`** (AC: 2)
  - [ ] Replace stub with full implementation (see Dev Notes for exact content)
  - [ ] Loading state pattern: `.fin-spinner { display: none }` + `[data-loading="true"]` rules
  - [ ] Flash message classes: `.flash`, `.flash-success`, `.flash-error`, `.flash-warning` (used by base.html)
  - [ ] Placeholder `fin-` component classes for all Story 2.2 components (semantic hooks for future Primer styling)
  - [ ] `.fin-form-error` sets `color: var(--color-danger, #cf222e)` (only structural rule needed now)

- [ ] **Task 6: Run full test suite and confirm GREEN** (AC: 8)
  - [ ] `pytest tests/ -v` → all asset tests pass, all prior 70 tests still pass
  - [ ] Minimum: 70 previously-passing tests + new asset tests, 1 skipped

## Dev Notes

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

| File | Reason |
|------|--------|
| `app/templates/base.html` | Already references correct paths (Story 2.1); do not change |
| `app/static/js/modal.js` | Story 2.4 — currently a stub `// modal.js — implemented in Story 2.4` |
| `app/static/js/loading.js` | Story 2.4 — currently a stub |
| `app/static/js/chart-defaults.js` | Story 2.4 — currently a stub |
| `app/templates/components/*.html` | Story 2.2 — complete |

### Vendor Asset Download Commands

`base.html` (Story 2.1) already references both paths — the files just need to exist.

**Primer CSS — download to local copy:**
```bash
curl -L "https://unpkg.com/@primer/css@21/dist/primer.css" \
     -o app/static/vendor/primer/primer.css
```
If `curl` is not available, use `wget`:
```bash
wget -O app/static/vendor/primer/primer.css \
     "https://unpkg.com/@primer/css@21/dist/primer.css"
```

**Chart.js — download UMD bundle, save as `chart.min.js`:**
```bash
curl -L "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" \
     -o app/static/vendor/chartjs/chart.min.js
```

> ⚠️ **CDN URLs are ONLY used in these download commands** — they do not appear anywhere in HTML/CSS/JS files served to the browser. This satisfies NFR-6. The downloaded files are served from `static/vendor/` locally.

**Verify downloads:**
```bash
# Should print file sizes (not 0 bytes)
ls -lh app/static/vendor/primer/primer.css
ls -lh app/static/vendor/chartjs/chart.min.js

# Quick smoke check
head -3 app/static/vendor/primer/primer.css   # should show CSS/comment
head -1 app/static/vendor/chartjs/chart.min.js  # should show minified JS
```

---

### Exact `tokens.css` Implementation

Replace the entire stub with this content:

```css
/*
 * tokens.css — Domain semantic state tokens
 *
 * MAPPING CONVENTION
 * ==================
 * Semantic token    → Primer color value   → Domain meaning
 * --color-safe      → Primer $green-600    → Budget/spend under threshold (< 70%)
 * --color-warning   → Primer $yellow-700   → Budget approaching limit (70–89%)
 * --color-danger    → Primer $red-600      → Budget at or over limit (≥ 90%)
 *
 * Usage: reference these tokens in templates and fin- classes rather than
 * hardcoding hex values. Changing a semantic color requires editing only
 * this one file.
 *
 * Template usage example:
 *   <span style="color: var(--color-safe);">↑ Under budget</span>
 *   <span style="color: var(--color-danger);">⚠ Over budget</span>
 *
 * fin- class usage:
 *   .fin-progress-bar-safe uses var(--color-safe) for fill
 */
:root {
  /* Budget/spending state colors (AR-9) */
  --color-safe:    #1a7f37;   /* Primer $green-600  — under budget threshold */
  --color-warning: #9a6700;   /* Primer $yellow-700 — approaching budget limit */
  --color-danger:  #cf222e;   /* Primer $red-600    — at or over budget limit */

  /* Primer semantic aliases — used throughout fin- components */
  --color-accent:  #0969da;   /* Primer $blue-600   — links, primary action buttons */
  --color-muted:   #636c76;   /* Primer $gray-500   — secondary / muted text */
  --color-border:  #d0d7de;   /* Primer $gray-300   — card and input borders */
  --color-canvas:  #f6f8fa;   /* Primer $gray-100   — card and section backgrounds */
  --color-fg:      #1f2328;   /* Primer $gray-900   — primary foreground text */
}
```

---

### Exact `app.css` Implementation

Replace the entire stub with this content:

```css
/*
 * app.css — Application-specific styles
 *
 * Conventions (AR-9):
 *   - All custom classes prefixed `fin-` (prevents Primer class name collision)
 *   - data-loading attribute pattern: JS sets element.dataset.loading = 'true'/'false'
 *     CSS attribute selectors handle visual feedback — no JS class manipulation needed
 *   - Primer CSS is loaded before this file; Primer is the base layer
 *
 * Load order in base.html:
 *   1. vendor/primer/primer.css  (foundation)
 *   2. css/tokens.css            (domain custom properties)
 *   3. css/app.css               (overrides and fin- classes)  ← this file
 */

/* ─── Loading state pattern (AR-16) ─────────────────────────────────── */
/*
 * JS (loading.js, Story 2.4): element.dataset.loading = 'true' to activate
 *
 * Required HTML structure:
 *   <div data-loading="false">
 *     <div class="fin-spinner"><!-- spinner markup --></div>
 *     <div class="fin-content"><!-- page content --></div>
 *   </div>
 *
 * Activation:  element.dataset.loading = 'true'   → content fades, spinner appears
 * Deactivation: element.dataset.loading = 'false'  → content normal, spinner hides
 */
.fin-spinner {
  display: none;
}

[data-loading="true"] .fin-content {
  opacity: 0.4;
  pointer-events: none;
}

[data-loading="true"] .fin-spinner {
  display: block;
}

/* ─── Flash messages (AR-14) ─────────────────────────────────────────── */
/*
 * Rendered by base.html for flash() messages.
 * Valid categories: success / error / warning / info (base class only, no modifier)
 * DO NOT add other categories — only these 4 are defined here and tested.
 */
.flash {
  margin-bottom: 16px;
  padding: 12px 16px;
  border-radius: 6px;
  border: 1px solid var(--color-border, #d0d7de);
}

.flash-success {
  border-color: var(--color-safe, #1a7f37);
  background: #dafbe1;
  color: var(--color-safe, #1a7f37);
}

.flash-error {
  border-color: var(--color-danger, #cf222e);
  background: #ffebe9;
  color: var(--color-danger, #cf222e);
}

.flash-warning {
  border-color: var(--color-warning, #9a6700);
  background: #fff8c5;
  color: var(--color-warning, #9a6700);
}

/* ─── Component semantic hooks (Story 2.2) ──────────────────────────── */
/*
 * fin- classes are applied by Jinja2 components in app/templates/components/.
 * Components use inline styles as visual placeholders (Story 2.2 intentional).
 * These empty rules serve as semantic hooks — future stories will add
 * Primer utility class equivalents here instead of inline styles.
 */
.fin-alert {}
.fin-alert-success {}
.fin-alert-error {}
.fin-alert-warning {}
.fin-alert-info {}

.fin-empty-state {}
.fin-page-header {}

.fin-stat-card {}
.fin-stat-label {}
.fin-stat-value {}
.fin-stat-delta {}

.fin-pagination {}

.fin-modal {}
.fin-modal-content {}

.fin-form-row {}
.fin-form-error {
  color: var(--color-danger, #cf222e);
}

.fin-progress-bar {}
.fin-progress-bar-safe {}
.fin-progress-bar-warning {}
.fin-progress-bar-danger {}
```

---

### Test File: `tests/test_blueprints/test_assets.py`

```python
"""
Static asset tests — Story 2.3 (AC: 5, 6, 7).

Tests verify:
 - Vendor files are served by Flask (primer.css, chart.min.js)
 - tokens.css defines all three domain color tokens
 - app.css implements the [data-loading] loading state pattern
 - No CDN references in app/ HTML/CSS/JS files (NFR-6)
"""
from pathlib import Path
import pytest

APP_DIR = Path(__file__).parent.parent / 'app'


class TestVendorAssets:
    def test_primer_css_serves_200(self, client):
        """Primer CSS local copy is present and served by Flask."""
        response = client.get('/static/vendor/primer/primer.css')
        assert response.status_code == 200

    def test_chartjs_serves_200(self, client):
        """Chart.js local copy is present and served by Flask."""
        response = client.get('/static/vendor/chartjs/chart.min.js')
        assert response.status_code == 200


class TestTokensCSS:
    def test_safe_color_token_defined(self):
        content = (APP_DIR / 'static' / 'css' / 'tokens.css').read_text()
        assert '--color-safe' in content

    def test_warning_color_token_defined(self):
        content = (APP_DIR / 'static' / 'css' / 'tokens.css').read_text()
        assert '--color-warning' in content

    def test_danger_color_token_defined(self):
        content = (APP_DIR / 'static' / 'css' / 'tokens.css').read_text()
        assert '--color-danger' in content


class TestAppCSS:
    def test_data_loading_content_rule(self):
        """[data-loading="true"] .fin-content must fade to 0.4 opacity."""
        content = (APP_DIR / 'static' / 'css' / 'app.css').read_text()
        assert '[data-loading="true"] .fin-content' in content
        assert 'opacity: 0.4' in content
        assert 'pointer-events: none' in content

    def test_data_loading_spinner_rule(self):
        """[data-loading="true"] .fin-spinner must become visible."""
        content = (APP_DIR / 'static' / 'css' / 'app.css').read_text()
        assert '[data-loading="true"] .fin-spinner' in content
        assert 'display: block' in content


def test_no_cdn_references_in_app_directory():
    """NFR-6: zero CDN references in app/ HTML/CSS/JS files."""
    cdn_patterns = ['cdn.', 'unpkg.', 'jsdelivr.', 'cdnjs.']
    violations = []
    for path in APP_DIR.rglob('*'):
        if path.suffix in ('.html', '.css', '.js'):
            content = path.read_text(errors='ignore')
            for pattern in cdn_patterns:
                if pattern in content:
                    violations.append(
                        f"{path.relative_to(APP_DIR)}: contains '{pattern}'"
                    )
    assert violations == [], 'CDN references found:\n' + '\n'.join(violations)
```

---

### How `base.html` Already Wires This Up

`base.html` (Story 2.1, complete — **do not modify**) already contains:
```html
<!-- In <head>: -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/primer/primer.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
```

Flask serves `static/` via the `url_for('static', filename=...)` helper — the URLs resolve to `/static/vendor/primer/primer.css` etc. Once the files exist on disk, they will be served by Flask's built-in static file handler.

**Important:** The `<link>` for `primer.css` will silently fail (404) until the file is downloaded. All existing tests still pass with a missing primer.css because test assertions don't check CSS load. Story 2.3 makes these 200s.

---

### Loading Order & CSS Cascade

```
vendor/primer/primer.css   → CSS foundation (reset, typography, utility classes)
css/tokens.css             → :root { --color-* custom properties }
css/app.css                → fin- classes, data-loading pattern, flash messages
```

`tokens.css` depends on nothing. `app.css` uses `var(--color-*)` with fallback hex values, so it degrades gracefully even if `tokens.css` fails to load.

---

### Previous Story Learnings (Stories 2.1 & 2.2)

- **Story 2.1**: `base.html` is complete — it already references all CSS/JS paths correctly. Do not touch it.
- **Story 2.2**: All 8 component macros use inline styles (intentional placeholder). `tokens.css` CSS custom properties are available for use in templates via `var(--color-safe)` etc., but Story 2.2 components won't be updated to use them — that's a future refinement.
- **Test pattern**: Static file tests use the `client` fixture from `conftest.py`. `client.get('/static/...')` works in Flask's test mode — the test client serves static files through Flask's static handler.

---

### Architecture Compliance

- **AR-9**: `tokens.css` and `app.css` must exist before any blueprint stylesheet work (this story satisfies that prerequisite).
- **AR-10**: Primer CSS and Chart.js served from `static/vendor/` (local copies). Zero CDN references in app code.
- **NFR-6**: Vendored assets only — no CDN dependencies at runtime.
- **CSS prefix**: All custom classes prefixed `fin-`. Primer classes (like `anim-`, `color-`, `text-`) never prefixed.

---

### File List (Expected Changes)

| File | Status |
|------|--------|
| `app/static/vendor/primer/primer.css` | CREATE (downloaded) |
| `app/static/vendor/chartjs/chart.min.js` | CREATE (downloaded) |
| `app/static/css/tokens.css` | MODIFY (replace stub) |
| `app/static/css/app.css` | MODIFY (replace stub) |
| `tests/test_blueprints/test_assets.py` | NEW |

---

## Dev Agent Record

### Agent Model Used
claude-sonnet-4-6

### Debug Log References
None

### Completion Notes List
- Fixed `APP_DIR` path in test file (story spec had `parent.parent`; correct path requires `parent.parent.parent` since test is two levels deep under `tests/`)
- 78 passed, 1 skipped (Epic 9 stub) — zero regressions against prior 70 tests

### File List
- `tests/test_blueprints/test_assets.py` — NEW
- `app/static/vendor/primer/primer.css` — CREATED (downloaded, 728K)
- `app/static/vendor/chartjs/chart.min.js` — CREATED (downloaded, 204K)
- `app/static/css/tokens.css` — REPLACED stub with full implementation
- `app/static/css/app.css` — REPLACED stub with full implementation

### Change Log
- 2026-05-28: Story implemented and moved to review status
