fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image

This commit is contained in:
2026-08-24 22:54:30 -04:00
parent 0adc9b5801
commit d7a4064616
16 changed files with 616 additions and 222 deletions
@@ -1,114 +0,0 @@
# Phase 22 — Animated Background: Make It Actually Animate
**Source:** `TODO.md` L5 — *"Fix background animation not working, just
blinking"*
**Story:** `.agent/user_stories/background-animation.md` (created by task 02)
**Context:** `frontend/assets/styles.css` — the phase-08 animated
background block (~lines 60–100): `body::before` (44px drifting grid,
1px lines at ~35% `--line` alpha, radial mask
`radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%)`,
`animation: bg-grid-drift 60s linear infinite`) and `body::after` (two
soft radial glows, `animation: bg-glow-breathe 14s ease-in-out infinite
alternate`, opacity 0.65↔1 + scale 1↔1.05). Both layers are
`position: fixed; inset: 0; z-index: -1; pointer-events: none`. `html`
owns the `var(--bg)` canvas and `body` is `background: transparent`
(~lines 42–54) — if any later rule occludes that, the layers vanish.
The **phase-08 design comments are the spec** for what "working" means.
## Objective
Owner report 2026-08-24: the background "just blinks" — i.e. the motion
the phase-08 design promised (a slow, seamless grid drift + a gentle
glow breathe) is not perceived; at most a flicker/blink is visible.
Diagnose which layer(s) actually fail in a real Chromium viewport, fix
the CSS, and leave a background that visibly and smoothly animates as
designed — no blink, no static frame, no jank.
## Owner-confirmed (2026-08-24, roadmap A3)
1. **Intended effect = the phase-08 design comments:** seamless 60s grid
drift (one cell per loop) + 14s ease glow breathing. The fix serves
that design, not a redesign.
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
assets, no `filter: blur` (perf note in the block).
## Design / diagnostic plan
The fix is found, not guessed — work through this checklist in a real
Chromium window (dev server, full page, ~15s of observation):
1. **Per-layer visibility:** toggle each pseudo-element (DevTools
generated-content / a temp outline) and screenshot — is the grid
visible at all? Is only the glow (the "blink" the user perceives)
alive?
2. **Grid layer:** sample `background-position` on `body::before` at two
timestamps — is it actually moving? Is the radial mask fading the
visible region so small that the 44px/60s drift is imperceptible?
(If the drift is real but too faint: raise the grid line alpha and/or
the mask's visible radius — smallest change that reads as "smooth
drift".)
3. **Glow layer:** is the 14s breathe reading as a *blink*? (If the
opacity swing 0.65↔1 is perceived as pulsing: lengthen the period
and/or narrow the opacity delta so it reads as breathing.)
4. **Occlusion check:** confirm nothing later in `styles.css` (or in
`html`/`body` rules) paints an opaque background over the
`z-index: -1` layers — the phase-08 comment at ~line 42 is the
contract.
5. **Apply the fix in `styles.css`** — document the found root cause in
the phase report (screenshot before/after in
`.agent/screenshots/22_background_animation/`).
## Dependencies
- `08_story_dark_tech_theme` (complete) — owns the layers, the palette,
and the "pure CSS, zero JS" anchor this phase must respect.
- `07_story_responsive_polish` (complete) — no new overflow at 360px
(both layers are `fixed; inset: 0` — keep it that way).
## Tasks
1. `01_fix_background_animation.md` — diagnosis + the CSS fix +
source-level unit pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_background_animation.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **Phase-08 anchor honored** — pure CSS, zero JS, no `filter: blur`,
WCAG AA palette untouched (background layers carry no text).
**A11 untouched** — no CDN, no new assets. **A16 honored** — one new
story E2E suite + adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_background_animation.py`,
repo source-pin pattern):** `styles.css` still defines
`@keyframes bg-grid-drift` and `@keyframes bg-glow-breathe`;
`body::before` animates `bg-grid-drift` with `linear infinite`;
`body::after` animates `bg-glow-breathe`; both layers remain
`position: fixed; z-index: -1; pointer-events: none`; `html` keeps
`background: var(--bg)` and `body` keeps `background: transparent`
(the no-occlusion contract). Pin the **final** values the fix lands
on (durations/opacities may move per the design plan).
- **Integration:** none (no `app/` changes).
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected.
- **E2E:** `tests/e2e/test_background_animation.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] In a real Chromium viewport, the background visibly and smoothly
animates (grid drift + glow breathe) — screenshot before/after in
the phase report; owner's "just blinking" perception gone.
- [ ] Root cause documented in `.agent/reports/22_background_animation/`.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_background_animation.py -v --no-cov`
green in isolation; regressions green in isolation:
`test_dark_tech_theme.py`, `test_responsive_polish.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): layers stay behind content
(`z-index: -1`, `pointer-events: none`), no text/contrast impact,
no 360px overflow.
- [ ] `.agent/user_stories/background-animation.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/22_background_animation/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
```
@@ -1,68 +0,0 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `22_background_animation` · **Source:** `TODO.md` L5
## Objective
The story gate: `tests/e2e/test_background_animation.py` proves both
background layers are actually running animations (not just declared),
plus regressions, story file, final validation, and the single atomic
commit.
## Work
1. `tests/e2e/test_background_animation.py` (new). The layers are CSS
pseudo-elements, so assert via computed style + the Web Animations
API (Chromium reports pseudo-element CSS animations through
`element.getAnimations()`):
1. `test_grid_layer_animation_running` —
`getComputedStyle(document.body, "::before").animationName` is the
grid-drift keyframe (final name from task 01), timing function
`linear`, iteration count `infinite`; and a matching entry in
`document.body.getAnimations()` with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `"::after"` with
the glow-breathe keyframe; `playState === "running"`.
3. `test_animations_advance` — sample `animation.currentTime` (or
the `getAnimations()` entry's `currentTime`) for both layers,
wait ~500ms (`page.wait_for_timeout`), assert both advanced —
the animations are truly running, not paused.
4. `test_background_layers_contracts` — both pseudo-elements:
`position: fixed`, `z-index: -1`, `pointer-events: none`;
`document.documentElement` computed `background-color` is the
palette bg (the canvas stays on `html`); `document.body` computed
`background-color` is `rgba(0, 0, 0, 0)` (no occlusion).
5. `test_no_horizontal_overflow_with_layers` (regression, 360px) —
viewport 360px: `document.documentElement.scrollWidth <=
clientWidth` (the phase-07 pin, replicated locally).
2. `.agent/user_stories/background-animation.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L5, the
owner-confirmed A3 decisions + the found root cause (from task 01's
report), E2E mapping table.
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_background_animation.py -v --no-cov`.
4. Regressions, in isolation, one command each:
- `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov`
- `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): layers stay behind
content, no text/contrast impact, no overflow at 360px.
7. Finish the phase report (`.agent/reports/22_background_animation/` —
E2E results + the task-01 screenshots).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ frontend/ tests/
git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
mv .agent/phases/todo/22_background_animation .agent/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; both regression suites green in
isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_background_animation.py` 5/5 in isolation.
- [ ] Regressions (dark tech theme, responsive polish) green in
isolation.
- [ ] Story file + phase report (with screenshots) exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,120 @@
# Phase 23 report — Containerfile: build the whole app image again
**Date:** 2026-08-24 · **Source:** `TODO.md` L6 ("Fix Containerfile build
not working") · **Story:** `.agent/user_stories/containerfile-build.md`
## Root cause (verified, not guessed — logs in this directory)
1. **Build failure:** phase 19's absolute `import … from
"/assets/header.js"` is resolved by esbuild as the *filesystem* path
`/assets/header.js` → `✘ [ERROR] Could not resolve "/assets/header.js"`,
reproduced with the exact pinned esbuild **0.25.5**
(`repro_esbuild_0.25.5.log`, `repro_podman_build_before_fix.log`).
2. **Under-shipped image:** stage 1 bundled only `app.js` +
`sources.js` and copied only `index.html` + `sources.html` —
`document.html` / `login.html` / `document.js` / `login.js` /
`markdown.js` were all missing from the image.
3. **Double-evaluation trap:** the four direct `<script
src="/assets/header.js">` tags would evaluate the header module
twice in the image (bundled copy + raw module) → duplicate sign-out
listener, double init.
4. **Second blocker (task 01):** a local `npm install` of esbuild left
the binary off the next `RUN`'s PATH (`esbuild: not found`) → the
stage installs it globally.
## Fix
| piece | after |
|---|---|
| page-script header import | `from "./header.js"` (relative — owner-confirmed over an esbuild alias) |
| direct header.js `<script>` tags | **removed from all four pages** (the hoisted import covers evaluation order) |
| esbuild install | `npm install --no-audit --no-fund -g esbuild@0.25.5` (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js`, `document.js`, `login.js` |
| stage-1 minifies | `styles.css` + `markdown.js` (no `--bundle` — classic script) |
| stage-1 copies | all four HTML pages into `/out` |
## Build gate — `podman build` (task 01, `podman_build_after_fix.log`)
```
[1/3] STEP 3/5: RUN npm install --no-audit --no-fund -g esbuild@0.25.5
[1/3] STEP 5/5: RUN mkdir -p /out/assets && esbuild ./assets/app.js --bundle … && … && cp ./index.html ./sources.html ./document.html ./login.html /out/
../out/assets/app.js 14.3kb ⚡ Done in 2ms
../out/assets/sources.js 2.3kb ⚡ Done in 2ms
../out/assets/document.js 2.4kb ⚡ Done in 2ms
../out/assets/login.js 1.5kb ⚡ Done in 2ms
[3/3] COMMIT brain-of-reese/app:phase23test
Successfully tagged localhost/brain-of-reese/app:phase23test
39f5563a1b101b274d81d86aa06f86a4d6d1bba453e8dba40d6f48f987537019
BUILD_EXIT=0
```
## Image smoke (task 01, `image_smoke.log`)
Throwaway Postgres 17 + pgvector (`bor-smoke-db`, isolated network),
production env, teardown done:
- app boots: migrations applied, uvicorn up, `/api/health` →
`{"status":"ok","db":"up",…}`
- pages: `GET /`, `/sources.html`, `/document.html`, `/login.html` → **200**
- assets: `app.js`, `sources.js`, `document.js`, `login.js`,
`markdown.js`, `styles.css` → **200**
- single-evaluation: raw `/assets/header.js` → **404** (expected — the
header code ships inside each page bundle); all four bundles are
minified and contain the header markers (`sign-out-btn`,
`/api/logout`)
- No CDN rule: `cdn_violations=0` across all four served pages
- `direct_header_script_tags=0`
- → **`SMOKE_PASS`**
## Task 02 — hermetic coverage pin + validation
`tests/integration/test_containerfile_assets.py` (6 tests, no podman /
no network / no DB — parses `Containerfile` + `frontend/` as text):
1. `test_every_html_page_is_copied_into_stage1` — cp set == pages on disk
2. `test_every_local_asset_reference_is_produced` — every `assets/`
src/href has a stage-1 producer
3. `test_page_module_scripts_are_bundled` — HTML module set == esbuild
`--bundle` input set
4. `test_header_module_is_imported_not_directly_loaded` — no direct
header.js tag anywhere; every page script imports `./header.js`
relatively
5. `test_markdown_js_is_a_produced_classic_script` — minified without
`--bundle`; no top-level import/export in the source
6. `test_esbuild_stays_pinned` — concrete `esbuild@X.Y.Z`, no floating
spec
Mutation-checked while authoring: removing `login.html` from the cp
line (→ test 1 fails), `esbuild@latest` (→ test 6 fails), deleting the
`markdown.js` line (→ tests 2+5 fail), deleting the `document.js`
bundle line (→ test 3 fails), re-adding a direct header.js tag (→
tests 3+4 fail). All pins fire.
### Test / lint / coverage results (exact commands)
| command | result |
|---|---|
| `uv run pytest tests/integration/test_containerfile_assets.py -v --no-cov` | 6 passed |
| `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolated) | **3 passed** (`e2e_smoke.log`) |
| `uv run pytest tests/e2e/test_shared_header.py -v --no-cov` (isolated) | **6 passed** (`e2e_shared_header.log`) |
| `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov` (isolated) | **4 passed** (`e2e_chat_persistence.log`) |
| `uv run pytest` | **335 passed** (329 → 335: the six new pins) |
| `uv run pytest --cov=app --cov-report=term-missing` | **335 passed; TOTAL 1109 stmts, 14 missed, 99%** (≥ today's 99%; >90% gate) |
| `uv run ruff check . && uv run pyright` | **All checks passed! / 0 errors, 0 warnings** |
Dev-server behavior unchanged: the tag removal is covered by the three
isolated E2E regressions above (browser-level header init, sign-out
binding exactly once, chat persistence across navigation).
## Decisions
- Relative imports over an esbuild alias (owner-confirmed A4-1).
- Direct header.js tags removed over shipping a raw header.js
(owner-confirmed A4-2 — single module evaluation).
- No Playwright suite for this phase (build/infrastructure): the gate
is the hermetic integration pin + the real `podman build` + image
smoke recorded above + the dev-server E2E regressions (A16 note in
the phase plan).
- No LOCKED anchor changed (A11 No-CDN and A16 honored).
- Build-evidence image `localhost/brain-of-reese/app:phase23test` kept
locally.
+131
View File
@@ -0,0 +1,131 @@
# Story: Containerfile Builds the Whole App Again
**Phase:** `23_containerfile_build` · **Gate:** hermetic integration
`tests/integration/test_containerfile_assets.py` + real `podman build`
+ image smoke (no Playwright suite — build/infrastructure phase, per
the phase plan's A16 note)
## Narrative
As **the owner**, I reported (2026-08-24, TODO.md L6) that the
Containerfile build "is not working". I want
`podman build -f Containerfile .` to succeed again — and, more
importantly, for the resulting image to serve the **whole app**: all
four pages (chat, sources, document viewer, login) with their bundled,
minified, local-only assets (No CDN rule, A11), the shared header
module evaluated exactly once per page, and a build contract that
fails loudly when a future page/script/asset outgrows the Containerfile.
- **Given** the 3-stage `Containerfile` (esbuild frontend bundle → uv
python deps → slim runtime serving `/app/static`)
- **When** `podman build -f Containerfile .` runs
- **Then** the build is green and the image serves `/`,
`/sources.html`, `/document.html`, `/login.html` with 200 and every
referenced asset local and minified — with `header.js` inlined into
each page bundle exactly once.
## Bug report (verbatim, TODO.md L6)
> Fix Containerfile build not working
## Verified diagnosis (2026-08-24, phase 23 — not a guess)
1. **Root cause of the build failure:** phase 19 switched the page
scripts to `import … from "/assets/header.js"` (an absolute URL).
esbuild resolves that as the *filesystem* path `/assets/header.js`
and the stage-1 bundle dies:
`✘ [ERROR] Could not resolve "/assets/header.js"`
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
copy of `frontend/` — `repro_esbuild_0.25.5.log`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundled only `app.js` + `sources.js` and copied only `index.html` +
`sources.html`. Missing from the image: `document.html` +
`login.html` (phases 10/16), `document.js` + `login.js`, and
`markdown.js` (classic script loaded by `index.html` +
`document.html`).
3. **Latent double-evaluation trap:** all four HTML pages also load
`<script type="module" src="/assets/header.js">` directly while the
page script imports it. In dev the browser dedupes (same module
URL) — but in the image the bundled page script already contains the
header code, so shipping a raw `header.js` too would evaluate the
module **twice** (duplicate sign-out listener, double init). The
direct tags are redundant: the page script's `import` is hoisted and
guarantees `header.js` evaluates before the page script's body calls
`initSharedHeader()`, in dev and in the bundle alike.
4. **Second build blocker (found in task 01):** a *local* `npm install`
of esbuild left the binary in `node_modules/.bin`, invisible to the
next `RUN` — `esbuild: not found`. The stage must install globally.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`from "./header.js"`) over an esbuild alias —
simpler, verified working, dev-server behavior unchanged (files are
side-by-side).
2. **Remove the four redundant direct `header.js` script tags** (the
design above) rather than ship a raw `header.js` into the image —
single module evaluation, no duplicate listeners.
3. The image must cover **all four pages + all local assets** they
reference — the integration test (task 02) enforces this coverage so
the gap cannot silently reappear.
## Fix
| piece | before | after |
|---|---|---|
| page-script header import | `from "/assets/header.js"` (absolute — esbuild fatal) | **`from "./header.js"`** (relative, bundles cleanly) |
| direct header.js `<script>` tags | 4 (one per page — double-eval trap) | **0** (the hoisted import covers evaluation order) |
| esbuild install | local (binary off the next RUN's PATH) | **`npm install -g esbuild@0.25.5`** (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js` | **`app.js`, `sources.js`, `document.js`, `login.js`** |
| stage-1 minifies | `styles.css` | **`styles.css`**, **`markdown.js`** (no `--bundle` — classic script) |
| stage-1 copies | `index.html`, `sources.html` | **all four pages** into `/out` |
## Acceptance criteria
1. Local esbuild 0.25.5 bundles all four page scripts cleanly.
2. `podman build -f Containerfile .` green (log excerpt in
`.agent/reports/23_containerfile_build/`).
3. Image smoke: container runs (throwaway Postgres 17 + pgvector);
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
`/assets/app.js` minified and contains the header code;
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
served page (No CDN rule).
4. Dev server unchanged in behavior: `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` green in
isolation.
5. `test_containerfile_assets.py` 6/6 within the full suite;
`uv run pytest` green; `app/` coverage ≥ today's number (>90% gate);
ruff + pyright clean.
## Test mapping table
**Test Scenario → `tests/integration/test_containerfile_assets.py`**
(hermetic — parses `Containerfile` + `frontend/` as text; no podman,
no network, no DB):
1. `test_every_html_page_is_copied_into_stage1` — every
`frontend/*.html` is copied by a stage-1 `cp` into `/out`, and the
copied set is exactly the pages on disk (new page without cp entry
→ fail; cp of a deleted page → fail).
2. `test_every_local_asset_reference_is_produced` — every local
`assets/`/`/assets/` `src=`/`href=` in the pages is produced by a
stage-1 line (esbuild `--outfile=/out/assets/<name>` or a `cp`) —
the missing-`markdown.js`-style gap cannot reappear.
3. `test_page_module_scripts_are_bundled` — the `type="module"` page
scripts referenced by the HTML (basenames) equal the esbuild
`--bundle` inputs in stage 1 (today: `app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
carries a direct `<script … src="…/header.js">` tag (single-evaluation
design pin, owner-confirmed A4-2) and every page script imports it
relatively (`from "./header.js"` — no absolute form).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js` has
a stage-1 minify line **without** `--bundle` and no top-level
`import`/`export` in the source (both sides of the classic-script
assumption pinned).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` (no `latest`/`^`/`~`/bare spec — the exact pinned
0.25.5 is what the diagnosis reproduced against).
Complementary source pins live in `tests/unit/test_shared_header.py::
test_header_module_loads_before_the_page_script` (unit) and the real
build + image smoke are recorded in
`.agent/reports/23_containerfile_build/` (task 01).