Compare commits

..
2 Commits
Author SHA1 Message Date
ducoterra 58e9d94cff phase: 100_page_width_consistency
Build and Push Containers / build-and-push-app (push) Successful in 4m3s
Build and Push Containers / build-and-push-db (push) Successful in 18s
**Phase 100 — final verification pass: all green.**

- Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only.
- Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after.
- Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors.
- E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8.
- Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted).
- Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged).
- Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
2026-09-12 13:37:19 -04:00
ducoterra a2ca2f905f chore(agent): phase 105 (hidden-folders toggle) from TODO.md, clear the list 2026-09-12 11:55:03 -04:00
42 changed files with 1863 additions and 428 deletions
@@ -0,0 +1,78 @@
# Phase 105 — Per-source hidden-folders toggle: dot-prefixed paths become indexable per input
**Source:** `TODO.md` L3 — "hidden dot folders aren't being indexed. There should be a toggle per input (next to the ignores button) to allow indexing hidden .folders."
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-14).
**Context:** The single filesystem walk choke point is `iter_importable_files` (`app/rag/importer.py` L161-194): it skips any path with a dot-prefixed component — hidden dirs (vendored caches like `.esphome/.espressif/**`) AND hidden files — plus the well-known `EXCLUDED_DIRS` (`.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`); the extension filter (`BOR_IMPORT_EXTENSIONS`, the A9 family) then admits the rest. `import_sources` (L196+) walks every root twice when `progress` is set (the phase-64 pre-walk for the `total` denominator uses the EXACT same rules), collects `(source, rel)` into `seen`, and `_prune` (`prune=True`) deletes every indexed document of the imported sources whose `(source, rel)` is not in `seen` — the same mechanism phase 89 uses so newly-ignored files leave the index. Both live import entry points build a per-root `ignore_by_root: dict[str, list[str]]` map from the `git_sources` rows in one loop — the Sync button (`app/api/sync.py::_run_sync` L233-263) and the CLI (`scripts/import_docs.py::_resolve_sources` L181-226, consumed at L269/L332-334); the map is keyed by `str(root)` — the root path string exactly as passed to `import_sources` — with extend-on-collision for shared roots. `scripts/load_test_kb.py` calls `import_sources` with defaults (untouched). The archive-upload background run no longer scans (phase 90), so no map is needed there. Sources are the `git_sources` rows (kind `git` / `local`, phase 35/38) managed on the Sources page (`/git-sources.html`, view module `frontend/assets/git-sources.js`, skeleton in `frontend/index.html` `#view-git-sources` L514+); the per-row "Ignore paths" button (`makeRow` L357+, `ignoreBtn` ~L393-408) sits in the actions cell LEFT of Remove, stored rows only (env-fallback rows, `id` null, get no control — phase 89 A3); non-empty lists render a `N ignored` text tag in the source cell (`.git-source-ignore-count`, `frontend/assets/styles.css` L2686, text + background, never color alone). The admin-only `PATCH /api/git-sources/{source_id}` (phase 89 A5, `app/api/git_sources.py` L374-393) today takes the REQUIRED `ignore_paths` replace list; the read shapes are `GitSourceRow` (`app/schemas.py` L508) and `GitSourceOut` (L490), the create body `GitSourceIn` (L450, optional `ignore_paths`). Alembic head is `0018`. The KB tree/catalog (phase 97) and the agent tools read the DB, so newly indexed hidden documents appear in them automatically — no change needed.
## Objective
Each stored source carries an **index-hidden-folders flag**, toggled by a per-row checkbox next to the "Ignore paths" button on the Sources page. When ON for a source, the walk no longer skips dot-prefixed components for that source — files inside hidden folders (and hidden files with an importable extension) are indexed, embedded, and summarized exactly like visible files; when OFF (the default for every existing row), behavior is byte-identical to today. `EXCLUDED_DIRS` stays excluded in both states, the extension filter always applies, and the flag — like the ignore list — takes effect on the next sync, with previously indexed hidden files pruned when it is switched off. All entry points (Sync button, CLI) honor the flag; the API stays admin-only.
## Dependencies
- `104_chip_sizing_question_cap` (todo) — pipeline predecessor (execution order) only; no code dependency (this phase touches the importer, the git-sources API, the sync/CLI pipelines, and the Sources view — none of which phase 104's pins reach; its suites must stay green unchanged).
- `89_source_ignore_paths` (complete) — the per-root map, the actions-cell control idiom, the count-tag idiom, and the `PATCH` route this phase extends.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Flag semantics (locked, A1).** `include_hidden=True` lifts ONLY the dot-prefixed-component skip in `iter_importable_files`: the existing check `any(part.startswith(".") or part in excluded for part in rel.parts)` becomes dot-aware only when the flag is False — e.g. `any((not include_hidden and part.startswith(".")) or part in excluded for part in rel.parts)`. Consequences, all deliberate:
- Files INSIDE hidden dirs become importable (`.esphome/esp.md` indexed when ON).
- Hidden files with an importable extension also become importable (`.notes.md` — the dot check covers components, not "the folder of the file", so one rule covers both; the extension filter is the real content gate, and a secret-flavoured file like `.env` has no A9 extension and is never indexed).
- `EXCLUDED_DIRS` (`.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`) are skipped in BOTH states — caches/VCS internals are never content.
- The `ignore` tuple (phase 89) composes additively with the flag: an ignored prefix still skips a file when `include_hidden=True`.
- **Storage (task 01).** `git_sources.include_hidden` — BOOLEAN NOT NULL, server default `false`, `Mapped[bool]` (the `documents.is_summary` Boolean precedent, `app/models.py` L136). Alembic `0019_git_source_include_hidden.py` (revises `0018`): `op.add_column("git_sources", sa.Column("include_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False))`; downgrade drops the column. Existing rows read `False` (A4).
- **Importer signature (task 02).**
- `iter_importable_files(root, extensions, excluded=EXCLUDED_DIRS, ignore=(), include_hidden: bool = False)` — default `False` keeps every existing caller byte-identical; the docstring's skip sentence gains the flag clause.
- `import_sources(sources, llm, *, prune=False, limit=None, session=None, progress=None, ignore_by_root=None, include_hidden_by_root: dict[str, bool] | None = None)` — the map is keyed by **`str(root)`** with the SAME keying convention as `ignore_by_root`; an internal `_include_hidden_for_root(root, include_hidden_by_root) -> bool` (default `False`) is the single read point, used by BOTH the phase-64 progress pre-walk and the processing loop, so `files_total` never disagrees with the walk. `seen` is untouched in shape → `_prune` prunes hidden documents automatically when the flag flips OFF (A2 — the A9/phase-89 precedent). Module docstring "Scope" paragraph updated.
- **API contract (task 03).**
- Schemas (`app/schemas.py`): `GitSourceIn.include_hidden: bool | None = Field(default=None)` (create-time, optional — absent → stored `False`); `GitSourceOut.include_hidden: bool`; `GitSourceRow.include_hidden: bool` (env-fallback rows report `False` — no DB row to store a flag on).
- The PATCH body model is RENAMED `GitSourceIgnoreIn` → `GitSourcePatchIn` (grep-verified: referenced only in `app/schemas.py` and `app/api/git_sources.py` — import L119 + `patch_git_source` L376) and gains:
- `ignore_paths: list[str] | None = Field(default=None)` — **absent/None = the row's list is unchanged; PRESENT = replace semantics exactly as phase 89 A5** (normalization + the A4 fixed-detail 422s run only when present). Every existing client always sends the list, so their behavior is byte-identical; the toggle's PATCH sends only the bool.
- `include_hidden: bool | None = Field(default=None)` — absent/None = unchanged; present = set.
- Both absent → 200 no-op (row untouched).
- `GET /api/git-sources` — DB rows report the stored flag; env rows `False`. `POST /api/git-sources` — both kinds accept `include_hidden`; stored `bool(payload.include_hidden)`.
- `PATCH /api/git-sources/{source_id}` (existing route, still behind `require_admin`) — applies each PRESENT field independently (404 unknown id unchanged); 200 → `GitSourceOut` (id, url, added_at, ignore_paths, include_hidden).
- **Callers (task 04).**
- `app/api/sync.py::_run_sync` — in the existing per-row loop that builds `ignore_by_root` (L233-252), build `include_hidden_by_root: dict[str, bool]` with the SAME `str(root)` keying: `include_hidden_by_root[str(root)] = include_hidden_by_root.get(str(root), False) or bool(row.include_hidden)` (collision → OR — the mirror of the ignore-map union: if either row says "index hidden", the shared root does). Pass `include_hidden_by_root=…` to `import_sources` (L263). Module docstring (L36-40) updated.
- `scripts/import_docs.py` — `_resolve_sources` returns the 3-tuple `(sources, ignore_by_root, include_hidden_by_root)` (manual `--source` → `(sources, {}, {})` — manual dirs have no row; env-fallback rows have no flags); the OR-collision rule is the same; `main` unpacks (L269) and passes the map (L332-334); docstrings updated (module + `_resolve_sources` L181).
- `scripts/load_test_kb.py` — untouched (defaults).
- **UI (task 05).** Sources page = the `git-sources` view. Per **stored** row (`s.id` truthy) in `makeRow`: a **native labeled checkbox** in the actions cell, DOM order **Hidden · Ignore paths · Remove** (the toggle sits next to — left of — the "Ignore paths" button, per the owner's wording; Remove stays last):
- `<label class="git-source-hidden">` wrapping `<input type="checkbox" class="git-source-hidden-box">` + visible text "Hidden"; the checkbox's `aria-label` is `Index hidden folders for ${kindLabel} source: ${value}` (setAttribute — never innerHTML; `value` is the git URL or local path, credential-safety discipline), `checked = s.include_hidden === true`; a `title` on the label explains in plain words ("When checked, files inside hidden (dot) folders are indexed on the next sync. Caches (.git, node_modules, .venv, …) stay excluded.").
- When `s.include_hidden === true`, the source cell also shows a **"hidden on" text tag** (`.git-source-hidden-count` — the `.git-source-ignore-count` idiom: text + background, never color alone, WCAG 1.4.1), next to the `N ignored` tag.
- **§7.4 never-stale lifecycle** — `toggleHidden(s, box)`: on `change`, the box disables immediately (no double-flip); `PATCH /api/git-sources/${s.id}` with `{"include_hidden": box.checked}`; on 200 → clear the error line, `await loadSources()` (the row re-renders from the server), THEN `announce("Hidden folders enabled|disabled for <value>.")` (the phase-89 last-announce order — the confirmation lands after the reload's "N sources listed."); on non-2xx or network failure → the server detail (or the canned "Could not reach the server — the setting was not changed.") into the new page-level `role="alert"` line, and the box **reverts to the server state** (`box.checked = s.include_hidden === true`) and re-enables — the UI never claims a state the server didn't save.
- `frontend/index.html` — one new element after the `#git-sources-table-wrap` region: `<p class="git-source-error" id="git-sources-hidden-error" role="alert" hidden></p>` (reuses the existing `.git-source-error` styling). `frontend/assets/styles.css` — near the phase-89 block (~L2657-2695): `.git-source-hidden` (inline-flex, ~44px hit height matching the action buttons, visible label), `.git-source-hidden input[type="checkbox"]` (sized, `accent-color` on the brand pair — verify + record the AA ratio in the comment, house style), `:disabled` (opacity + `cursor: wait` — the `.git-source-remove:disabled` idiom), focus ring via the GLOBAL `:focus-visible` rule (L146 — no per-control rule needed), and `.git-source-hidden-count` (copy of the `.git-source-ignore-count` rule, provenance comment citing phase 105).
- Env-fallback rows (`id` null) get **no** checkbox — the existing "from .env" tag stays (A3).
- **NOT touched:** the RAG view (`sources.js`), `app/rag/retriever.py`, the chunker, the KB tree/catalog + agent tools (DB-driven — they pick up newly indexed hidden docs for free), `app/rag/git_sources.py` (clone/pull only, no walk), the upload run (phase 90 — no scan), `AGENTS.md`, `.agents/PLAN.md`, any completed phase.
## Tasks
1. `01_include_hidden_column.md` — `git_sources.include_hidden` BOOLEAN column (model + alembic `0019`) + default/round-trip tests.
2. `02_importer_include_hidden.md` — `iter_importable_files`/`import_sources` flag support (walk + progress pre-walk + prune interaction + ignore composition) + unit & integration tests.
3. `03_include_hidden_api.md` — schemas (`In`/`Out`/`Row` + the `GitSourcePatchIn` rename with optional fields) + GET/POST/PATCH wiring + integration tests.
4. `04_include_hidden_pipelines.md` — wire the per-row flag into `_run_sync` and `scripts/import_docs.py` + integration tests.
5. `05_hidden_toggle_sources_ui.md` — the per-row "Hidden" checkbox on the Sources page (tag + §7.4 lifecycle + error line + a11y) + source-level unit pins.
6. `06_e2e_hidden_folders_toggle.md` — dedicated Playwright suite `tests/e2e/test_hidden_folders_toggle.py` (run in isolation), regressions, full gate, atomic commit.
## Testing & Quality
- Unit — `tests/unit/test_importer_include_hidden.py` (new, task 02): `iter_importable_files` on a tmp fixture tree — default OFF pins today's behavior byte-identically (hidden dir + hidden file skipped), ON admits both, `EXCLUDED_DIRS` skipped in BOTH states, `ignore` tuple still bites when ON, extension filter unchanged (`.env` never indexed); the `str(root)` keying + default-False for unlisted roots; `tests/unit/test_hidden_folders_toggle.py` (new, task 05): source-level pins for the JS/HTML/CSS wiring (house pattern — read the assets as text), incl. the single-source cross-file check that the aria-label template names the source value; task 01's pins extend the existing model/migration test surfaces (a fresh row reads `include_hidden is False`; an explicit `True` round-trips).
- Integration — `tests/integration/test_importer_include_hidden.py` (new, task 02): `import_sources` against a fixture dir — hidden file produces NO `Document`/`Chunk` rows by default; WITH the map it is embedded + summarized normally; previously indexed hidden file + flag OFF → pruned on the next run; progress `total` agrees with the walk in both states; sources not in the map behave exactly as before. `tests/integration/test_git_sources_api.py` (extended, task 03): GET reports `False` default / stored `True`; POST create round-trip; PATCH bool-only, list-only, both, neither (no-op 200), the phase-89 422s unchanged for present lists, 404, anonymous 403. `tests/integration/test_sync_api.py` (extended, task 04): a local row with a hidden dir — flag False syncs zero hidden docs, True syncs them; `tests/integration/test_import_docs_git.py` (extended, task 04): the CLI DB-row path with the flag set.
- E2E (mandatory, A16) — `tests/e2e/test_hidden_folders_toggle.py` (task 06), run in isolation with the DB up: `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov`.
- Coverage: **>90%** on `app/` (the validate.sh gate — the importer/API/pipeline additions are fully unit+integration covered).
## Completion Criteria
- [ ] A stored source (git, local, or uploaded) has a "Hidden" checkbox next to its "Ignore paths" button: flipping it on persists (`PATCH` 200, the source cell shows the "hidden on" tag, `GET /api/git-sources` round-trips `include_hidden: true`); the failure path reverts the box and announces the error in a `role="alert"` line.
- [ ] A sync (button or CLI) with the flag OFF indexes nothing with a dot-prefixed component (no `documents`/`chunks` rows — the byte-identical default); with the flag ON, `.hidden/note.md` is indexed, embedded, and summarized like any visible file and shows up in the KB catalog; `EXCLUDED_DIRS` content is excluded in both states.
- [ ] A2: a previously indexed hidden file is PRUNED from the KB on the next sync after the flag flips OFF (`detail.pruned` increments; the catalog no longer lists it).
- [ ] API contracts hold: PATCH bool-only / list-only / both / neither; the phase-89 fixed-detail 422s unchanged for present lists; 404 unknown id; anonymous 403 on the route.
- [ ] Env-fallback rows render no checkbox (the "from .env" tag stays); the Sources page stays WCAG-clean (visible label, focus-visible, text never color alone).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov` green in isolation (DB up); regression suites `test_source_ignore_paths.py`, `test_git_sources_admin.py`, `test_local_directory_sources.py`, `test_sync_button.py`, `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate.
## Locked decisions
- **A1 — what "on" means (owner-confirmed 2026-09-14).** The flag lifts the dot-prefixed-component skip for that source — files inside hidden folders AND hidden files with an importable extension become indexable; `EXCLUDED_DIRS` (`.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`) stay excluded regardless; the extension filter always applies.
- **A2 — toggling off prunes (owner-confirmed).** Previously indexed hidden files leave the KB on the next sync (the `seen`-set prune — the phase-89 A2 / A9 precedent), exactly like newly-ignored files.
- **A3 — per stored row only (owner-confirmed).** Every stored `git_sources` row (git, local, uploaded) gets the toggle; env-fallback rows have no DB row and get no control (the phase-89 A3 precedent).
- **A4 — default off (owner-confirmed).** `include_hidden` defaults to `false` for all existing rows — byte-identical behavior until the owner flips it; the flag takes effect on the NEXT sync (no auto-sync, same as the ignore list).
- **A5 — control idiom (owner-confirmed).** A native labeled checkbox ("Hidden") in the actions cell, left of the "Ignore paths" button (Remove stays last), with a "hidden on" text tag in the source cell when enabled — not a styled switch.
## Commit
```bash
git add app/ alembic/versions/0019_git_source_include_hidden.py scripts/ frontend/ tests/ TODO.md .agents/phases/ && git commit --no-gpg-sign -m "feat(sources): per-source hidden-folders toggle — dot-prefixed paths are indexable per input"
```
@@ -0,0 +1,45 @@
# Task 01 — `git_sources.include_hidden` BOOLEAN column (model + alembic `0019`)
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — "…There should be a toggle per input (next to the ignores button) to allow indexing hidden .folders."
## Objective
Persist the per-source hidden-folders flag: one additive, reversible BOOLEAN column on `git_sources`, server-defaulted to `false` so every pre-phase-105 row imports byte-identically (A4).
## Work
1. `app/models.py` — the `GitSource` class (L220-259): add the column directly AFTER `ignore_paths` (L248-251), mirroring its docstring/provenance style (`Boolean` is already imported, L78):
```python
#: Index hidden (dot-prefixed) paths from this source (phase 105,
#: A1): True → the walk (app.rag.importer.iter_importable_files)
#: does not skip dot-prefixed components — files inside hidden
#: folders AND hidden files with an importable extension are
#: indexed; ``EXCLUDED_DIRS`` (``.venv``, ``node_modules``,
#: ``.git``, …) are excluded in BOTH states, and the extension
#: filter always applies. Takes effect on the next sync (no
#: auto-sync — the ignore-list precedent, phase 89). Server
#: default False: every pre-phase-105 row imports exactly as
#: before (A4).
include_hidden: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false"), nullable=False
)
```
(Extend the class docstring's one-line field inventory if it names `ignore_paths` — the module header L19 does: add `include_hidden` (phase 105) to the parenthetical.)
2. `alembic/versions/0019_git_source_include_hidden.py` (NEW — the house format of `0013_git_source_ignore_paths.py`, one additive reversible column):
- `revision = "0019"`, `down_revision = "0018"`.
- `upgrade()`: `op.add_column("git_sources", sa.Column("include_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False))`.
- `downgrade()`: `op.drop_column("git_sources", "include_hidden")`.
- Module docstring: the phase-89-0013 provenance style (what the flag is, A1/A4, one additive reversible column).
3. Tests — extend the existing model/migration test surfaces (find the current `GitSource` default-pinning tests — the phase-89 column tests live in the `git_sources` unit/integration suites; add alongside them):
- A freshly inserted `GitSource` row (no `include_hidden` passed) reads `include_hidden is False` (the Python `default=False` AND the server default agree).
- An explicit `include_hidden=True` round-trips through the DB (`session.add` → `commit` → fresh session → `True`).
- The migration applies on the test DB from head (`alembic upgrade head` is part of the standard test-db fixture setup — if the suite asserts the column set of `git_sources`, add `include_hidden` to the expected set).
4. Run `uv run pytest tests/unit/ -q && uv run alembic upgrade head` (against the dev/test DB per the house quick reference) — green.
## Testing & Quality
- Unit/integration: the default + round-trip pins above ARE this task's layer (no importer behavior yet — that is task 02).
- Coverage: **>90%** on `app/` (model-only change; the validate.sh gate).
## Completion Criteria
- [ ] `GitSource.include_hidden` exists with `server_default=text("false")`, `nullable=False`, and the A1/A4 provenance comment
- [ ] `alembic/versions/0019_git_source_include_hidden.py` upgrades from `0018` and downgrades cleanly; the dev/test DB is at head
- [ ] Fresh-row-default-False and explicit-True round-trip tests pass; existing `git_sources` suites stay green
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,70 @@
# Task 02 — Importer flag support: `iter_importable_files` / `import_sources` honor `include_hidden` per root
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — "hidden dot folders aren't being indexed. …to allow indexing hidden .folders."
## Objective
Make the walk choke point flag-aware: `iter_importable_files` gains `include_hidden` (default `False` — every existing caller byte-identical) and `import_sources` gains `include_hidden_by_root` (same `str(root)` keying as phase 89's `ignore_by_root`), used by BOTH the phase-64 progress pre-walk and the processing loop. Pruning falls out for free through the untouched `seen` set (A2).
## Work
1. `app/rag/importer.py` — `iter_importable_files` (L161-194):
- Signature: `def iter_importable_files(root: Path, extensions: frozenset[str], excluded: frozenset[str] = EXCLUDED_DIRS, ignore: tuple[str, ...] = (), include_hidden: bool = False) -> list[Path]:`
- The skip check (L184) becomes flag-aware — ONE expression, byte-identical when the flag is False:
```python
if any(
(not include_hidden and part.startswith(".")) or part in excluded
for part in rel.parts
):
continue
```
- Docstring: the "Skips:" sentence gains — "…when ``include_hidden`` is False (the default): any path with a dot-prefixed component; when True, dot-prefixed components are ADMITTED (files inside hidden folders, and hidden files) and only *excluded* is consulted (A1 — caches/VCS internals are never content). The *ignore* tuple composes additively in both states."
2. `app/rag/importer.py` — `import_sources` (L196+):
- Keyword-only param after `ignore_by_root`: `include_hidden_by_root: dict[str, bool] | None = None`.
- New private helper next to `_ignore_for_root` (L145-160), same style:
```python
def _include_hidden_for_root(
root: Path, include_hidden_by_root: dict[str, bool] | None
) -> bool:
"""The per-root hidden-folders flag (phase 105, A1).
Keyed by ``str(root)`` — the root string exactly as the caller
passed it in ``sources`` (the ``_ignore_for_root`` convention,
phase 89): ``True`` only for roots the caller lists as True;
unlisted/``None`` roots are ``False`` — every existing caller
behaves byte-identically (A4).
"""
return bool((include_hidden_by_root or {}).get(str(root), False))
```
- The progress pre-walk (the `if progress is not None:` block, ~L244-252) passes `include_hidden=_include_hidden_for_root(root, include_hidden_by_root)` to its `iter_importable_files` call — `files_total` must agree with the walk in both states.
- The processing loop: alongside `ignore = _ignore_for_root(root, ignore_by_root)` (~L272), add `include_hidden = _include_hidden_for_root(root, include_hidden_by_root)` and pass both into the `iter_importable_files` call (~L273-275).
- `import_sources` docstring: after the `ignore_by_root` paragraph, the mirror paragraph: "``include_hidden_by_root`` (phase 105, A1) maps ``str(root)`` to the stored flag: ``True`` admits dot-prefixed components for that root (``EXCLUDED_DIRS`` and the extension filter still apply; the ignore tuple composes additively). Unlisted/``None`` roots are ``False`` — byte-identical to pre-phase-105. A file that was indexed with the flag ON and is walked again with it OFF simply never enters ``seen``, so the next ``prune=True`` run deletes its row automatically (A2 — the A9/phase-89 precedent)."
- Module docstring "Scope" paragraph (L20-22): append the flag clause ("…skipped, plus the well-known exclusion list — UNLESS the source's phase-105 hidden-folders flag admits dot-prefixed paths; the exclusion list always applies").
3. `tests/unit/test_importer_include_hidden.py` (NEW) — `iter_importable_files` on a `tmp_path` tree (extensions `frozenset({".md"})`), fixture layout:
```
visible.md .hidden/note.md .notes.md
.venv/junk.md node_modules/x.md .hidden/.deep.md
keep/ok.md
```
- **Default (flag False) — today's behavior pinned byte-identically:** result == `[keep/ok.md, visible.md]` (sorted); hidden dir, hidden file, `.venv`, `node_modules` all absent.
- **Flag True (A1):** result contains `.hidden/note.md`, `.notes.md`, `.hidden/.deep.md`, `visible.md`, `keep/ok.md`; STILL excludes `.venv/junk.md` and `node_modules/x.md` (EXCLUDED_DIRS in both states).
- **Composition with `ignore`:** flag True + `ignore=(".hidden",)` → `.hidden/*` gone, `.notes.md` present (additive).
- **Extension filter unchanged:** flag True with `.env`-like file `.env` (no A9 extension) → never listed.
- `_include_hidden_for_root` unit pins: `None` map → False; unlisted root → False; listed `True` → True; listed `False` → False; keying is `str(root)` (two `Path` objects, equal strings, same answer).
4. `tests/integration/test_importer_include_hidden.py` (NEW) — `import_sources` against a fixture dir (the `tests/integration/test_importer_ignore.py` harness — fake `Embedder` from `tests/fakes.py` or the module's own fake, real test DB):
- Layout: `visible.md`, `.hidden/note.md` (non-markdown `.hidden/data.yaml` too, to prove the summary path runs for admitted hidden files), `.venv/junk.md`.
- **Run 1 (default, no map):** `Document` rows exist for `visible.md` ONLY — no row for `.hidden/note.md` (A4 byte-identical); `summary` stats unchanged.
- **Run 2 (`include_hidden_by_root={str(root): True}`):** `.hidden/note.md` + `.hidden/data.yaml` get `Document` + `Chunk` rows (embedded via the fake), the yaml gets a `summary` row (the phase-30 path), `.venv/junk.md` still absent (A1).
- **A2 prune:** with the rows from run 2 committed, run 3 with `prune=True` and NO map (flag off) → `.hidden/*` rows deleted (`summary.pruned >= 2`), `visible.md` untouched.
- **Progress agreement:** with `progress=…` set, `total` equals the visible-only count in run 1's rules and the extended count in run 2's rules (the pre-walk uses the same flag).
- **Unlisted roots untouched:** two roots, map lists only one as True — the other root's hidden file stays out (per-root, not global).
5. Run `uv run pytest tests/unit/test_importer_include_hidden.py tests/integration/test_importer_include_hidden.py tests/unit/test_importer_ignore.py tests/integration/test_importer_ignore.py -q` — green (the phase-89 suites are the byte-identical regression proof).
## Testing & Quality
- Unit + integration as above are this task's layer; API/pipeline/UI come later.
- Coverage: **>90%** on `app/` (the new helper + both call sites are fully covered — the validate.sh gate).
## Completion Criteria
- [ ] `iter_importable_files(..., include_hidden=False)` is the default and its default-state result is byte-identical to pre-task (phase-89 importer suites green)
- [ ] `include_hidden=True` admits dot-prefixed components, keeps `EXCLUDED_DIRS` + extension filter + `ignore` tuple in force (A1)
- [ ] `import_sources(include_hidden_by_root=…)` drives BOTH the pre-walk and the loop through `_include_hidden_for_root`; unlisted/`None` → False
- [ ] Flag-off re-run prunes previously indexed hidden docs (A2); progress `total` agrees with the walk in both states
- [ ] `uv run pytest tests/unit/ -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,95 @@
# Task 03 — API: `include_hidden` on GET/POST/PATCH (the `GitSourcePatchIn` rename)
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — "There should be a toggle per input (next to the ignores button)…"
## Objective
Expose the flag through the admin API: `GET`/`POST` carry it, and the existing `PATCH /api/git-sources/{id}` becomes the single per-row edit endpoint for BOTH the ignore list and the toggle — each field optional, present-field-wins, so the toggle's PATCH sends only `{"include_hidden": …}` and the phase-89 dialog's PATCH (which always sends the list) keeps byte-identical semantics.
## Work
1. `app/schemas.py`:
- `GitSourceIn` (L450-477): add after `ignore_paths`:
```python
include_hidden: bool | None = Field(default=None)
```
+ docstring clause: "``include_hidden`` (phase 105) is optional at create time (absent → stored ``False`` — A4)."
- `GitSourceOut` (L490-506): add `include_hidden: bool` (after `ignore_paths`) + docstring clause (the stored flag; `False` for a row created without it).
- `GitSourceRow` (L508-528): add `include_hidden: bool` + docstring clause (**env-fallback rows report `False`** — no DB row to store a flag on, the `ignore_paths: []` precedent).
- **RENAME** `GitSourceIgnoreIn` (L530-543) → `GitSourcePatchIn` and rework its body (grep-verified references: only `app/schemas.py` + `app/api/git_sources.py` L119/L376 — no tests import it):
```python
class GitSourcePatchIn(BaseModel):
"""``PATCH /api/git-sources/{source_id}`` body (phase 89 A5;
extended phase 105).
Each field is independent and OPTIONAL: absent/None leaves the
row's value unchanged; PRESENT applies. ``ignore_paths`` when
present keeps the phase-89 A5 REPLACE semantics (the body list,
normalized + A4-validated, becomes the row's whole list — empty
list clears all; every pre-phase-105 client always sends the
list, so their behavior is byte-identical). ``include_hidden``
(phase 105) when present sets the stored flag. Both absent →
200 no-op (the row is untouched).
"""
ignore_paths: list[str] | None = Field(default=None)
include_hidden: bool | None = Field(default=None)
```
2. `app/api/git_sources.py`:
- Import L119: `GitSourceIgnoreIn` → `GitSourcePatchIn`.
- `list_git_sources` (L202-245): DB row construction (~L226-233) gains `include_hidden=row.include_hidden`; the env-fallback row (~L240) gains `include_hidden=False`; the endpoint docstring's field list mentions it.
- `create_git_source` (L249-280): response construction (L278) gains `include_hidden=row.include_hidden`.
- `_create_git_row` (L315, row construction ~L326-335) and `_create_local_row` (L340, ~L358-368): both gain `include_hidden=bool(payload.include_hidden)` (absent → `False`, A4).
- `patch_git_source` (L374-393) — new body:
```python
def patch_git_source(
source_id: uuid.UUID,
payload: GitSourcePatchIn,
db: Session = Depends(get_db), # noqa: B008
) -> GitSourceOut:
"""Edit one source's ignore list and/or hidden-folders flag.
Phase 89 A5 (ignore list) + phase 105 (the flag): 404 unknown
id; each PRESENT body field applies independently —
``ignore_paths`` REPLACES the list (normalized + A4-validated,
fixed 422 details); ``include_hidden`` sets the flag. Both
absent → 200 no-op. Returns the updated row's public shape
(id, url, added_at, ignore_paths, include_hidden).
"""
row = db.get(GitSource, source_id)
if row is None:
raise HTTPException(status_code=404, detail="git source not found")
if payload.ignore_paths is not None:
row.ignore_paths = _validate_ignore_paths(payload.ignore_paths)
if payload.include_hidden is not None:
row.include_hidden = payload.include_hidden
db.commit()
db.refresh(row)
return GitSourceOut(
id=row.id,
url=row.url,
added_at=row.added_at,
ignore_paths=row.ignore_paths,
include_hidden=row.include_hidden,
)
```
- Router module docstring (L1-30ish — the contract list): the PATCH line extends to "the ignore list (replace) and/or the hidden-folders flag (phase 105) — each optional, present-wins".
3. `tests/integration/test_git_sources_api.py` — extend the phase-89 PATCH section (the suite already pins 404 + anonymous 403 + the A4 fixed-detail 422s):
- `GET` — a fresh stored row reports `include_hidden: false`; an env-fallback row (table empty + `BOR_GIT_SOURCES` monkeypatched, the suite's existing pattern) reports `include_hidden: false`.
- `POST` — `kind="local"` create with `include_hidden: true` → 201 body `include_hidden: true`; without the field → `false` (A4).
- `PATCH` matrix (one stored row, list `["a/b"]`, flag `false`):
- `{"include_hidden": true}` → 200, flag `true`, list UNCHANGED `["a/b"]` (the toggle's exact payload).
- `{"ignore_paths": ["c/d"]}` → 200, list REPLACED, flag UNCHANGED `false` (the dialog's exact payload — byte-identical to phase 89).
- `{"ignore_paths": [], "include_hidden": true}` → both applied.
- `{}` → 200 no-op (list + flag unchanged).
- the phase-89 422s still fire for a PRESENT bad list (>200 entries / empty-after-normalization / >500-char entry — the fixed details), and a bad list does NOT half-apply the flag (assert the flag is untouched after a 422 with both fields present).
- 404 unknown id; anonymous `PATCH` 403 (the existing pin already covers the route — extend it to include a bool-only body so the pin proves the toggle path is gated too).
4. Run `uv run pytest tests/integration/test_git_sources_api.py -q` — green.
## Testing & Quality
- Integration as above is this task's layer (the unit schema pins ride on the integration 422/shape checks, house pattern for this router).
- Coverage: **>90%** on `app/` (every new/changed branch — both field-present/absent combos — is exercised — the validate.sh gate).
## Completion Criteria
- [ ] `GET` reports the flag for DB rows and `false` for env rows; `POST` create round-trips it (absent → `false`)
- [ ] `GitSourcePatchIn` exists with two optional fields; `GitSourceIgnoreIn` is gone repo-wide (grep-verified)
- [ ] PATCH: bool-only, list-only, both, neither (no-op 200); the phase-89 A4 fixed-detail 422s unchanged for present lists; a 422 never half-applies the other field; 404 + anonymous 403 hold
- [ ] `uv run pytest tests/integration/test_git_sources_api.py -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,48 @@
# Task 04 — Pipelines: the Sync button + the CLI honor the per-row flag
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — "hidden dot folders aren't being indexed. …to allow indexing hidden .folders."
## Objective
Build the `include_hidden_by_root` map at the two live import entry points — the in-app Sync (`app/api/sync.py::_run_sync`) and the CLI (`scripts/import_docs.py::_resolve_sources`) — with the SAME `str(root)` keying and collision rule as the phase-89 `ignore_by_root`, and pass it to `import_sources`. The upload run needs nothing (phase 90 — no scan) and `scripts/load_test_kb.py` keeps its defaults.
## Work
1. `app/api/sync.py` — `_run_sync` (the per-row loop L233-252 + the `import_sources` call L262-264):
- After `ignore_by_root: dict[str, list[str]] = {}` (L233): `include_hidden_by_root: dict[str, bool] = {}`
- Inside the loop, next to the phase-89 `ignore_by_root.setdefault(...)` (L251-252) — the comment cites phase 105 + the same sibling/repo-name collision note:
```python
# Phase 105 (A1/A4): the row's hidden-folders flag, keyed by
# the SAME root string the importer sees; a shared-root
# collision ORs — if EITHER row says "index hidden", the
# root does (the ignore-map union's boolean mirror).
include_hidden_by_root[str(root)] = (
include_hidden_by_root.get(str(root), False)
or bool(row.include_hidden)
)
```
- The call (L262-264): add the kwarg — `summary: ImportSummary = await import_sources(sources, llm, prune=True, progress=_hook, ignore_by_root=ignore_by_root, include_hidden_by_root=include_hidden_by_root)`
- Module docstring (L30-45, the pipeline list that names "honoring each row's ``ignore_paths`` (phase 89…)"): extend the clause with "and its ``include_hidden`` flag (phase 105 — the per-root hidden-folders map, same per-row construction)".
2. `scripts/import_docs.py`:
- `_resolve_sources` docstring (L181-185): "Returns ``(sources, ignore_by_root)``" → "Returns ``(sources, ignore_by_root, include_hidden_by_root)`` (phase 89; phase 105 adds the per-root flag map — the flag is stored per row, manual ``--source`` dirs and the legacy fallback have no rows and import with the empty map: hidden paths skipped, A4)."
- All THREE return paths become 3-tuples:
- the `cli_sources` early return (~L190): `return [path.expanduser() for path in cli_sources], {}, {}`
- the rows branch: after the `ignore_by_root` dict init (L206), add `include_hidden_by_root: dict[str, bool] = {}`; inside the loop next to the phase-89 extend (L224-225), the same OR assignment as sync.py (comment: phase 105); the return (L226): `return sources, ignore_by_root, include_hidden_by_root`
- the legacy fallback return (~L227): `return [path.expanduser() for path in DEFAULT_SOURCES], {}, {}`
- `main` (L269): `sources, ignore_by_root, include_hidden_by_root = _resolve_sources(args.source, settings)` (the comment above it, L258, extends to name the flag map).
- The `import_sources` call (L332-335): add `include_hidden_by_root=include_hidden_by_root,`.
- Module docstring: the phase-89 line about the per-root ignore map gains the flag clause.
3. Tests:
- `tests/integration/test_sync_api.py` — extend with the flag (the suite's local-row + fixture-dir pattern; the fixture dir gains `.hidden/note.md` + a visible file):
- Row with `include_hidden=False` (default) + `POST /api/sync` → terminal status `success`, the hidden file has NO `documents` row (A4).
- Same row flipped to `True` (task-03 PATCH or direct model set, the suite's idiom) + sync → the hidden file IS indexed (`documents` row present; `detail.files` counts it).
- `tests/integration/test_import_docs_git.py` (the CLI DB-row suite) — one test: a local row with a hidden file + `include_hidden=True` → running the CLI main path (the suite's existing invocation harness) indexes the hidden file; `False` → not (the CLI's map is built, not lost — the regression this phase most plausibly breaks is here).
4. Run `uv run pytest tests/integration/test_sync_api.py tests/integration/test_import_docs_git.py -q` — green.
## Testing & Quality
- Integration as above is this task's layer; the importer itself is task 02's, the UI task 05's.
- Coverage: **>90%** on `app/` (the sync map-building branch is exercised; `scripts/` is outside the `--cov=app` gate — the CLI test is the behavioral pin, house pattern).
## Completion Criteria
- [ ] `_run_sync` builds `include_hidden_by_root` in the existing per-row loop (OR on collision) and passes it to `import_sources`
- [ ] `_resolve_sources` returns the 3-tuple on ALL three return paths (manual, rows, legacy fallback) and `main` passes the map through
- [ ] Sync with the flag off indexes no hidden docs; with it on, does — E2E-provable through `detail` + `documents` rows; the CLI behaves the same
- [ ] `uv run pytest tests/integration/ -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,177 @@
# Task 05 — The per-row "Hidden" toggle on the Sources page (checkbox + tag + §7.4 lifecycle + a11y)
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — "There should be a toggle per input (next to the ignores button) to allow indexing hidden .folders."
## Objective
The owner-visible half of the feature: every stored row on the Sources page gets a labeled **Hidden** checkbox in the actions cell, left of its "Ignore paths" button (DOM order Hidden · Ignore paths · Remove — A5); when on, the source cell shows a "hidden on" text tag; the flip PATCHes the flag with the §7.4 never-stale lifecycle and reverts to the server state on failure.
## Work
1. `frontend/index.html` — inside `#view-git-sources`, directly AFTER the `#git-sources-table-wrap` region closes (~L632), the page-level error line for the toggle (the ignore dialog carries its own error INSIDE the modal; the checkbox lives in the table, so its error lives at page level — reuses the existing `.git-source-error` styling):
```html
<!-- Phase 105: the per-row "Hidden" toggle's error line — the
checkbox is a table-cell control (no dialog of its own), so
its failure announces here (role=alert; git-sources.js
showHiddenError). Hidden until a PATCH fails. -->
<p class="git-source-error" id="git-sources-hidden-error" role="alert" hidden></p>
```
2. `frontend/assets/git-sources.js` — `makeRow` (L357+):
- **State tag** — in the `urlTd` block, right after the phase-89 `N ignored` count-tag block (~L374-382), the same idiom:
```js
/* Phase 105 (A5): the "hidden on" state tag — the
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1), so the flag is readable at a
glance without hovering the checkbox. */
if (s.id && s.include_hidden === true) {
const hiddenTag = document.createElement("span");
hiddenTag.className = "git-source-hidden-count";
hiddenTag.textContent = "hidden on";
urlTd.append(hiddenTag);
}
```
- **The checkbox** — in the `if (s.id) {` actions-cell branch (~L390), BEFORE the `ignoreBtn` construction (~L399), so DOM order is Hidden · Ignore paths · Remove:
```js
/* Phase 105 (A5): the per-row hidden-folders toggle — a native
labeled checkbox (the WCAG focus/label idiom) LEFT of the
"Ignore paths" button; Remove stays last. Stored rows only
(A3 — env-fallback rows fall through to the "from .env"
tag). The aria-label is the ONLY place `value` appears
(setAttribute — never innerHTML). Checked state comes from
the SERVER row (s.include_hidden), never from a prior local
flip (§7.4 — makeRow only ever renders server state). */
const hiddenLabel = document.createElement("label");
hiddenLabel.className = "git-source-hidden";
hiddenLabel.title =
"When checked, files inside hidden (dot) folders are indexed on the next sync. Caches (.git, node_modules, .venv, …) stay excluded.";
const hiddenBox = document.createElement("input");
hiddenBox.type = "checkbox";
hiddenBox.className = "git-source-hidden-box";
hiddenBox.checked = s.include_hidden === true;
hiddenBox.setAttribute(
"aria-label",
`Index hidden folders for ${kindLabel} source: ${value}`,
);
hiddenLabel.append(hiddenBox, document.createTextNode("Hidden"));
hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox));
actTd.appendChild(hiddenLabel);
```
- **`toggleHidden(s, box)`** — new function next to `saveIgnorePaths` (the phase-89 §7.4 section, ~L593+), same announce/reload discipline:
```js
/* Phase 105: the hidden-folders toggle — PATCH { include_hidden }
only (the row's list is untouched — the PATCH body's optional
fields, task 03). §7.4 never-stale: the box disables at once
(no double-flip while the PATCH is out); on 200 the row
re-renders from the server (loadSources) and the
CONFIRMATION is the LAST announcement (the reload's "N
sources listed." lands first — the phase-89 order); on
failure the box REVERTS to the server state and the detail
lands in #git-sources-hidden-error (role=alert). */
function toggleHidden(s, box) {
const value = s.kind === "local" ? (s.path ?? s.url) : s.url;
const wanted = box.checked;
box.disabled = true; // a PATCH is out — the box must not flip twice
fetch(`/api/git-sources/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ include_hidden: wanted }),
})
.then(async (r) => {
if (r.ok) {
hideHiddenError();
await loadSources();
announce(`Hidden folders ${wanted ? "enabled" : "disabled"} for ${value}.`);
return;
}
const detail = await apiDetail(
r, `Could not update the hidden-folders setting (${r.status}).`,
);
showHiddenError(detail);
box.checked = s.include_hidden === true; // revert to server state
box.disabled = false;
})
.catch(() => {
showHiddenError("Could not reach the server — the setting was not changed.");
box.checked = s.include_hidden === true;
box.disabled = false;
});
}
function showHiddenError(message) {
if (hiddenErrorEl) hiddenErrorEl.textContent = message;
if (hiddenErrorEl) hiddenErrorEl.hidden = false;
}
function hideHiddenError() {
if (hiddenErrorEl) hiddenErrorEl.textContent = "";
if (hiddenErrorEl) hiddenErrorEl.hidden = true;
}
```
(Also call `hideHiddenError()` at the top of `loadSources`'s success path (~L315, after `hideLoadError()`) so a healed list clears the stale line — the phase-89 "happy path heals the error state" precedent.)
- Element grabber next to the other page-local grabs (~L245-255): `const hiddenErrorEl = root.querySelector("#git-sources-hidden-error");`
- Module header comment block (L1-177ish, the contract list): add the phase-105 entry — "the per-row Hidden checkbox (makeRow) → PATCH {include_hidden} (task 03's optional field) → loadSources + announce; failure reverts the box + #git-sources-hidden-error (role=alert); env-fallback rows get no checkbox (A3)".
3. `frontend/assets/styles.css` — after the phase-89 block (the `.git-source-ignore-count` rule ends ~L2695):
```css
/* Phase 105 (A5): the per-row "Hidden" checkbox — the actions
cell, LEFT of the "Ignore paths" button (JS builds it,
git-sources.js makeRow). A native labeled checkbox: the
visible "Hidden" text + the checkbox's own aria-label
(full source value). ~44px hit height matches the action
buttons; :focus-visible via the GLOBAL rule (L146) — no
per-control ring needed. */
.git-source-hidden {
display: inline-flex;
align-items: center;
gap: 0.4rem;
height: 44px;
padding: 0 0.5rem;
font-size: 0.8rem;
color: var(--ink);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
/* --brand checkbox fill: the native check renders in --bg on
--brand (the house 5.2:1 brand pairing); [executor: verify the
checked-state contrast of the native widget in both themes +
record the ratio here — house style]. */
.git-source-hidden input[type="checkbox"] {
width: 1.05rem;
height: 1.05rem;
margin: 0;
accent-color: var(--brand);
cursor: pointer;
}
.git-source-hidden:disabled { opacity: 0.5; cursor: wait; }
/* Phase 105 (A5): the "hidden on" state tag — a copy of the
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). */
.git-source-hidden-count {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--ink);
background: var(--bg);
white-space: nowrap;
}
```
4. `tests/unit/test_hidden_folders_toggle.py` (NEW) — source-level pins (the house pattern: read the asset files as text; mirror `tests/unit/test_source_ignore_paths.py`'s structure if it exists, else the closest JS-pinning suite):
- `git-sources.js`: `makeRow` contains the checkbox construction — class `git-source-hidden-box`, `type` checkbox, the aria-label template `` `Index hidden folders for ${kindLabel} source: ${value}` ``; `checked = s.include_hidden === true`; the label is appended to `actTd` BEFORE the ignore button (pin the slice order: the hidden-label block precedes the `ignoreBtn` construction); `toggleHidden` defined with `PATCH` + `body: JSON.stringify({ include_hidden: wanted })`; the failure branch reverts (`box.checked = s.include_hidden === true`) AND re-enables; the success branch announces AFTER `await loadSources()` (pin the order in the source slice).
- **Cross-file pin (single source of truth for the field name):** the JS body `include_hidden` key and the Python `GitSourcePatchIn.include_hidden` field (regex-parsed from `app/schemas.py`) are the SAME string — a rename on either side breaks the wire contract and this test.
- `index.html`: `#git-sources-hidden-error` exists, carries `role="alert"`, `hidden`, class `git-source-error`, and sits INSIDE `#view-git-sources` after `#git-sources-table-wrap` (source order).
- `styles.css`: the `.git-source-hidden`, `.git-source-hidden input[type="checkbox"]`, `.git-source-hidden:disabled`, `.git-source-hidden-count` rules exist; the checkbox rule sets `accent-color`.
- The `N ignored`-style tag text pin: `hidden on` literal present in the JS (the tag copy is the state — text, never color alone).
5. Run `uv run pytest tests/unit/test_hidden_folders_toggle.py -q` — green.
## Testing & Quality
- Unit (source-level pins) is this task's layer; the behavioral E2E is task 06.
- Coverage: **>90%** on `app/` (no `app/` changes in this task — the gate is unaffected; keep it green).
## Completion Criteria
- [ ] Every stored row renders Hidden · Ignore paths · Remove in the actions cell; env-fallback rows render no checkbox
- [ ] `checked` comes only from server state; a "hidden on" tag appears in the source cell iff the flag is on
- [ ] Flip → box disables → `PATCH {"include_hidden": …}` → 200: error cleared, list reloaded, confirmation announced LAST; failure: box reverts + re-enables, detail in the `role="alert"` line; the line heals on a successful load
- [ ] WCAG: visible label + full-value `aria-label` on the checkbox, global `:focus-visible` ring, tag is text (never color alone), checkbox checked-state contrast verified + recorded in the CSS comment
- [ ] The JS field-name pin matches `app/schemas.py` (cross-file test); `uv run pytest tests/unit/ -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,55 @@
# Task 06 — E2E: `tests/e2e/test_hidden_folders_toggle.py` (isolation) + regressions + full gate + commit
**Phase:** `105_hidden_folders_toggle` · **Source:** `TODO.md` L3 — the whole item, proven end to end.
## Objective
One dedicated Playwright suite proving the TODO item through the REAL page + REAL API + REAL sync pipeline (mock LLM, no git, no network — a `kind="local"` row over a fixture dir, the `test_source_ignore_paths.py` module pattern verbatim): default-off byte-identity, toggle-on indexes hidden folders, toggle-off prunes them (A2), env rows have no control, a11y + the error line. Then the phase's full gate and the single atomic commit.
## Work
1. `tests/e2e/test_hidden_folders_toggle.py` (NEW) — copy the module scaffolding of `tests/e2e/test_source_ignore_paths.py` (the module-env `BOR_GIT_SOURCES` URL that is NEVER synced/cloned; `e2e/auth_helpers.login`; `e2e/conftest.py` `ADMIN_PASSWORD`/`SESSION_SECRET`/`USE_REAL_LLM`/`_wait_http`; the sync helper: `POST /api/sync` → poll `GET /api/sync/status` to terminal; the `source_dir` fixture built under `tmp_path_factory`) with the fixture tree:
```
visible.md
.hidden/note.md <- the phase-105 subject
.venv/junk.md <- EXCLUDED_DIRS: never indexed, both states (A1)
```
Contract under test (docstring):
- anonymous: the `#git-sources-gate` sign-in gate, the manager hidden, NO `/api/git-sources` call on load, 403 on `GET`/`POST /api/git-sources` AND `PATCH /api/git-sources/{id}` with a bool-only body (the phase-89 anonymous pin extended to the toggle payload);
- A4: with the default row, a sync indexes `visible.md` ONLY — `detail.files` counts one, `.hidden/note.md` has no `documents` row, the checkbox renders UNCHECKED and no "hidden on" tag;
- A1: flipping the checkbox on (the real click) → the PATCH 200 lands (the "hidden on" tag appears, the announcer `role=status` fires the confirmation AFTER the reload line) → sync → `.hidden/note.md` IS indexed (`documents` row present; the KB catalog lists it — the tree/catalog is DB-driven, no extra surface); `.venv/junk.md` STILL absent (EXCLUDED_DIRS in both states); the checkbox re-renders CHECKED (server state);
- A2: flipping it OFF (real click) → sync → `detail.pruned` includes the hidden doc, the catalog no longer lists it, the tag is gone;
- A3: the env-fallback view (the table-empty state) renders the "from .env" tag with NO Hidden checkbox and NO "Ignore paths" button;
- a11y + error surface: the checkbox has a full accessible name containing the source location (`Index hidden folders for local source: …`), is keyboard-focusable (Tab reaches it, the global `:focus-visible` ring applies), the tag text is "hidden on" (never color alone); `#git-sources-hidden-error` exists with `role="alert"` and stays `hidden` through the happy path.
Test → contract mapping (one test per bullet, the phase-89 suite's shape):
1. `test_anonymous_gate_and_403s`
2. `test_hidden_off_by_default`
3. `test_toggle_on_indexes_hidden_folders`
4. `test_toggle_off_prunes_hidden`
5. `test_env_fallback_rows_have_no_toggle`
6. `test_toggle_a11y_and_error_surface`
2. **Regressions** — each in isolation (DB up), all green:
- `uv run pytest tests/e2e/test_source_ignore_paths.py -v --no-cov` (the phase-89 suite — the PATCH body rename + the actions-cell order must not break it; its dialog still sends the list and gets byte-identical replace semantics)
- `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov`
- `uv run pytest tests/e2e/test_local_directory_sources.py -v --no-cov`
- `uv run pytest tests/e2e/test_sync_button.py -v --no-cov`
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov`
3. **Full gate** (AGENTS.md rule 9 — non-negotiable):
- `uv run pytest` (unit + integration) green
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **>90%**
- `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov` green in isolation
- `uv run ruff check . && uv run pyright` clean
4. **Commit** — one atomic Conventional-Commits commit, `--no-gpg-sign` (AGENTS.md rule 8), per the phase overview's Commit block:
```bash
git add app/ alembic/versions/0019_git_source_include_hidden.py scripts/ frontend/ tests/ TODO.md .agents/phases/ && git commit --no-gpg-sign -m "feat(sources): per-source hidden-folders toggle — dot-prefixed paths are indexable per input"
```
(`TODO.md` is cleared to the bare `# TODO` by the conversion step BEFORE this commit lands — the items now live in this phase; if the pipeline commits per task instead, fold `TODO.md` into this phase's final commit and move the phase dir to `.agents/phases/complete/105_hidden_folders_toggle/` as the last action, per the pipeline gate.)
## Testing & Quality
- This task IS the phase's E2E + gate + commit; no new application code (the E2E may reveal a one-line fix in an earlier task's work — fix it IN that task's file, keep the suite's contract as written).
- Coverage: **>90%** on `app/` (the validate.sh gate — enforced here, not assumed).
## Completion Criteria
- [ ] `tests/e2e/test_hidden_folders_toggle.py` exists, maps 1:1 to the six contract bullets, and passes in isolation (`--no-cov`, DB up)
- [ ] The five regression E2E suites pass in isolation (the phase-89 suite first — it is the rename's canary)
- [ ] The full gate is green: unit+integration, TOTAL coverage >90%, ruff + pyright clean
- [ ] One `--no-gpg-sign` commit contains the whole phase (app + alembic 0019 + scripts + frontend + tests + the cleared `TODO.md` + the phase files)
- [ ] The TODO item is done: the owner can flip "Hidden" per input next to its Ignore paths button, and the next sync indexes (or prunes, when off) the dot-prefixed paths of that source — with `.venv`/`node_modules`/`.git`/… always excluded
@@ -0,0 +1,9 @@
**Phase 100 — final verification pass: all green.**
- Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only.
- Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after.
- Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors.
- E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8.
- Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted).
- Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged).
- Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 306 0 100%
--------------------------------------------------
TOTAL 3824 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
Task 01 complete. All gates verified.
**Implemented** (`frontend/assets/styles.css` only — D3, no HTML/JS/app change):
- `:root { --chat-column: 72rem }` — now equals the `.container` cap; provenance comment cites the owner instruction 2026-09-12 and supersedes the 2026-08-31 instruction
- Entire `@media (min-width: 1500px)` block deleted (92rem doubling + `.doc-shell` cap lift); `prefers-reduced-motion` block kept
- `.tuning-shell` / `.theme-shell` / `.doc-edit-shell`: `max-width: 46rem` + `margin-inline: auto` removed — now structurally match `.sources-shell` (flex column, no cap)
- `.remove-confirm-panel` / `.ignore-editor-panel`: `min(46rem, …)` → `min(var(--chat-column), …)` (the task's hard grep gate required removing the literals; the phase context lists both pin files for task 02's sweep)
- Comment sweep: all stale 46rem/92rem/1500px width claims rewritten to the 72rem contract (0 added lines carry old literals; 36 removed)
**Verification:**
- Width grep clean: `grep -nE "(^|[^.0-9])46rem|(^|[^.0-9])92rem|1500px|…"` → nothing. The task's exact grep only matches 7 `font-size: 0.92rem;` lines — font sizes are non-width CSS, explicitly "do NOT touch"
- Playwright sanity (mock LLM + real app): chat/RAG/tuning/theme all measure **1152px (72rem), centered, at both 1920px and 1280px** — identical to the RAG reference; doc modal panel unchanged at **1100px** (`.doc-md` 1058px, panel binds per D2); standalone doc page 1152px shell / 1112px column; shared page 1152px; **360px: zero horizontal overflow** (chat/tuning/shared scrollWidth = 360). DB restored byte-exact after the check
- `uv run pytest` → 2043 passed, **10 failed — exactly the width pins task 02 rewrites** (`test_wide_column_css.py` ×7, `test_doc_edit_screen.py`, `test_remove_confirm_modal.py`, `test_source_ignore_paths.py`; the other named files carry fixture/docstring text only and stay green)
- `uv run pytest --cov=app` → **99% on `app/`** (>90% gate); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
**Notable decisions:** the two 46rem dialog panels ride the token (only literal-free reading that satisfies the grep gate); `0.92rem` font sizes left untouched per the do-not-touch rule.
**Next pending task:** `02_width_unit_pins.md`
@@ -0,0 +1,295 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 21%]
.....F.................................................................. [ 24%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................F............... [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 63%]
F....................................................................... [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
..................FFF.F.FFF............................................. [ 77%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 98%]
..................................... [100%]
=================================== FAILURES ===================================
______________ test_doc_edit_shell_is_the_hardcoded_46rem_column _______________
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
form column, not a reading column — it must NOT ride
--chat-column, so phase 58's wide-desktop doubling never stretches
the form), centered, a flex column on the container frame."""
css = _css()
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .doc-edit-shell"
body = block.group(1)
> assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
E AssertionError: the 46rem base column (hard-coded)
E assert 'max-width: 46rem' in '\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n flex: 1;'
tests/unit/test_doc_edit_screen.py:467: AssertionError
__________________ test_modal_css_targets_and_contrast_pairs ___________________
def test_modal_css_targets_and_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both buttons >=44px; the
destructive button rides the err token family (err-ink on err-bg
9.3:1, the err-line border — the .tuning-delete / .steering-delete
convention; the hover inverts to --bg on --err-line, 5.2:1);
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
error line is the err pair; the panel caps at the 46rem
chat-column width or the viewport."""
css = _css()
btn = css[css.find(".remove-confirm-btn {"):]
btn = btn[: btn.find("\n}")]
assert "min-height: 44px" in btn and "min-width: 44px" in btn
remove = css[css.find(".remove-confirm-remove {"):]
remove = remove[: remove.find("\n}")]
for prop in ("var(--err-bg)", "var(--err-ink)", "var(--err-line)"):
assert prop in remove, f"the destructive pair must keep {prop}"
hover = css[css.find(".remove-confirm-remove:hover:not(:disabled) {"):]
hover = hover[: hover.find("\n}")]
assert "var(--err-line)" in hover and "var(--bg)" in hover, (
"the hover inversion: dark --bg on --err-line (5.2:1)"
)
cancel = css[css.find(".remove-confirm-cancel {"):]
cancel = cancel[: cancel.find("\n}")]
assert "var(--ink-soft)" in cancel and "transparent" in cancel
err = css[css.find(".remove-confirm-error {"):]
err = err[: err.find("\n}")]
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = css[css.find(".remove-confirm-panel {"):]
panel = panel[: panel.find("\n}")]
> assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
E AssertionError: the 46rem chat-column cap (or the viewport)
E assert 'min(46rem' in '.remove-confirm-panel {\n /* position:relative lifts the panel above the fixed backdrop\n (positioned elements p...d: var(--surface);\n border: 1px solid var(--line);\n border-radius: var(--radius);\n box-shadow: var(--shadow-lg);'
tests/unit/test_remove_confirm_modal.py:476: AssertionError
_________________ test_dialog_button_and_target_contrast_pairs _________________
def test_dialog_button_and_target_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both dialog buttons >=44px;
Cancel is the ghost ink-soft family (5.1:1 on --surface) with the
brand-soft hover (12.4:1); Save is the solid brand family (--bg
text on --brand 5.2:1, the .new-chat-btn convention) with the
lightened hover; the error line is the err pair; the panel caps
at the 46rem chat-column width or the viewport; the visible
label is ink-soft (5.1:1) — never a label-less textarea."""
css = _css()
btn = _css_rule(css, ".ignore-editor-btn")
assert "min-height: 44px" in btn and "min-width: 44px" in btn
cancel = _css_rule(css, ".ignore-editor-cancel")
assert "var(--ink-soft)" in cancel and "transparent" in cancel
cancel_hover = _css_rule(css, ".ignore-editor-cancel:hover:not(:disabled)")
assert "var(--brand-soft)" in cancel_hover and "var(--brand-ink)" in cancel_hover
save = _css_rule(css, ".ignore-editor-save")
assert "var(--brand)" in save and "var(--bg)" in save, (
"Save: the solid brand family (--bg text on --brand, 5.2:1)"
)
save_hover = _css_rule(css, ".ignore-editor-save:hover:not(:disabled)")
assert "background" in save_hover, "the hover lightens the fill"
err = _css_rule(css, ".ignore-editor-error")
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = _css_rule(css, ".ignore-editor-panel")
> assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
E AssertionError: the 46rem chat-column cap (or the viewport)
E assert 'min(46rem' in '.ignore-editor-panel {\n \n position: relative;\n z-index: 1;\n margin: auto;\n width: min(var(--chat-column), c...var(--surface);\n border: 1px solid var(--line);\n border-radius: var(--radius);\n box-shadow: var(--shadow-lg);\n}'
tests/unit/test_source_ignore_paths.py:520: AssertionError
__________________ test_root_declares_chat_column_46rem_base ___________________
def test_root_declares_chat_column_46rem_base() -> None:
""":root declares --chat-column: 46rem (the PLAN §7 base) with the
owner-provenance comment (instruction 2026-08-31, TODO L5)."""
css = _css()
root = _rule_block(css, ":root")
> assert "--chat-column: 46rem" in root, (
":root must declare the --chat-column base (46rem)"
)
E AssertionError: :root must declare the --chat-column base (46rem)
E assert '--chat-column: 46rem' in '{\n /* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */\n --bg: #0f0a0a; /* page: ink ... — supersedes the 2026-08-31 instruction, and\n the wide-desktop doubling with it). */\n --chat-column: 72rem;\n}'
tests/unit/test_wide_column_css.py:66: AssertionError
___________________ test_wide_media_block_doubles_the_token ____________________
def test_wide_media_block_doubles_the_token() -> None:
"""A @media (min-width: 1500px) block sets --chat-column: 92rem on
:root — the single wide override (2x the base)."""
css = _css()
m = re.search(r"@media \(min-width: 1500px\) \{", css)
> assert m, "styles.css must carry the @media (min-width: 1500px) block"
E AssertionError: styles.css must carry the @media (min-width: 1500px) block
E assert None
tests/unit/test_wide_column_css.py:82: AssertionError
____________ test_wide_block_lives_in_the_bottom_responsive_region _____________
def test_wide_block_lives_in_the_bottom_responsive_region() -> None:
"""The min-width sibling sits alongside the max-width responsive
blocks at the bottom of the file (after the <=640px block)."""
css = _css()
> wide = css.index("@media (min-width: 1500px)")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E ValueError: substring not found
tests/unit/test_wide_column_css.py:108: ValueError
_____________ test_shared_shell_keeps_the_centered_column_comment ______________
def test_shared_shell_keeps_the_centered_column_comment() -> None:
""".shared-shell's inline comment keeps the "centered chat column"
wording and notes the wide override (task 01 work item)."""
css = _css()
rule = css[css.index(".shared-shell {") : css.index(".shared-shell {") + 400]
> assert "the PLAN §7 centered chat column" in rule
E assert 'the PLAN §7 centered chat column' in '.shared-shell {\n width: 100%;\n max-width: var(--chat-column); /* the centered chat column — 72rem,\n the .con...\'s title; the static\n fallback is "Shared conversation") — the page-head h1 size. */\n#shared-title { margin: 0 0 '
tests/unit/test_wide_column_css.py:143: AssertionError
___________________ test_tuning_shell_stays_hardcoded_46rem ____________________
def test_tuning_shell_stays_hardcoded_46rem() -> None:
""".tuning-shell (the form column, out of scope) keeps its
hard-coded max-width: 46rem at every width — it never widens."""
css = _css()
tuning = _rule_block(css, ".tuning-shell")
> assert "max-width: 46rem" in tuning, (
".tuning-shell must stay hard-coded 46rem (negative pin)"
)
E AssertionError: .tuning-shell must stay hard-coded 46rem (negative pin)
E assert 'max-width: 46rem' in '{\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n flex: 1;\n}'
tests/unit/test_wide_column_css.py:161: AssertionError
__________________ test_no_other_hardcoded_46rem_rule_remains __________________
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27),
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form), and .theme-shell (phase 91 task 04 — the
admin Theme editor is a form column too: the palette grid +
fieldsets must never ride the wide-desktop doubling). Every
reading column rides the token (the --chat-column base
declaration is the other non-rule occurrence of 46rem)."""
css = _css()
> assert css.count("max-width: 46rem") == 3, (
"only the form columns (.tuning-shell, .doc-edit-shell, "
".theme-shell) may keep a literal max-width: 46rem"
)
E AssertionError: only the form columns (.tuning-shell, .doc-edit-shell, .theme-shell) may keep a literal max-width: 46rem
E assert 0 == 3
E + where 0 = <built-in method count of str object at 0x2673a210>('max-width: 46rem')
E + where <built-in method count of str object at 0x2673a210> = '/* ==========================================================================\n Brain of Reese — design system (no ...ill animate. */\n@media (prefers-reduced-motion: reduce) {\n .app-nav,\n .app-nav.is-open { transition: none; }\n}\n'.count
tests/unit/test_wide_column_css.py:181: AssertionError
_____________ test_comments_cite_the_wide_override_with_provenance _____________
def test_comments_cite_the_wide_override_with_provenance() -> None:
"""The block comments that claimed the "46rem column contract" now
name base 46rem + the 2x wide override, with the owner
instruction (2026-08-31, TODO L5) as the provenance at the token
and the media block."""
css = _css()
# The stale "≤46rem" contract claims are gone from the file.
assert "≤46rem" not in css, (
"the stale '≤46rem' contract wording must be updated"
)
# Provenance at the two authoritative spots (token + wide block).
> token_idx = css.index("--chat-column: 46rem")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E ValueError: substring not found
tests/unit/test_wide_column_css.py:201: ValueError
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 306 0 100%
--------------------------------------------------
TOTAL 3824 15 99%
=========================== short test summary info ============================
FAILED tests/unit/test_doc_edit_screen.py::test_doc_edit_shell_is_the_hardcoded_46rem_column
FAILED tests/unit/test_remove_confirm_modal.py::test_modal_css_targets_and_contrast_pairs
FAILED tests/unit/test_source_ignore_paths.py::test_dialog_button_and_target_contrast_pairs
FAILED tests/unit/test_wide_column_css.py::test_root_declares_chat_column_46rem_base
FAILED tests/unit/test_wide_column_css.py::test_wide_media_block_doubles_the_token
FAILED tests/unit/test_wide_column_css.py::test_wide_block_lives_in_the_bottom_responsive_region
FAILED tests/unit/test_wide_column_css.py::test_shared_shell_keeps_the_centered_column_comment
FAILED tests/unit/test_wide_column_css.py::test_tuning_shell_stays_hardcoded_46rem
FAILED tests/unit/test_wide_column_css.py::test_no_other_hardcoded_46rem_rule_remains
FAILED tests/unit/test_wide_column_css.py::test_comments_cite_the_wide_override_with_provenance
tests FAILED: unit + integration suite did not pass
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1,13 @@
Everything is green. Final report:
**Fixed:** the harness gate requires a fully green suite, so the old-contract width pins (which asserted the retired 46rem/92rem contract) were updated to the new 72rem contract — `test_wide_column_css.py` rewritten (token = 72rem = `.container` cap with 2026-09-12 provenance; no `min-width:1500px` block; flipped negative pin: zero literal `max-width: 46rem`; the three form shells structurally match `.sources-shell`; both dialogs pinned at `min(var(--chat-column), …)`; no stale width comments), plus the single pin + docstrings in `test_doc_edit_shell` (no cap, rides the container), `test_remove_confirm_modal` and `test_source_ignore_paths` (`min(var(--chat-column)`). `styles.css` from the prior attempt is unchanged (already verified: 1152px columns on every page at 1280/1920, modal panel unchanged 1100px, no 360px overflow).
**Results (exact commands):**
- `uv run pytest` → **2052 passed, 0 failed**
- `uv run pytest --cov=app --cov-report=term` → **app/ 99%** (>90% gate)
- `uv run ruff check .` → **All checks passed!**
- `uv run pyright` → **0 errors, 0 warnings**
**Notable decisions:** the harness rejection overrode the task file's "expected breakage" clause — the pin updates (task 02's unit-pin scope) landed here to satisfy the green gate; task 02's remaining work is only the docstring sweep of `test_markdown_tables` / `test_shared_page` / `test_pinned_composer` (fixture text, per pin judgment). No HTML/JS/app change (D3); DB left as found.
**Next pending task:** `02_width_unit_pins.md` (unit-pin scope now largely satisfied — residual docstring sweep remains)
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 306 0 100%
--------------------------------------------------
TOTAL 3824 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
**Task 02 — unit pins for the 72rem width contract — complete.**
**Implemented**
- `test_wide_column_css.py` rewritten to the phase-100 contract (kept the balanced-brace `_rule_block` helpers): 72rem token + 2026-09-12 provenance + token-equals-`.container` pin; no `@media (min-width: 1500px)` block and no `92rem`; the four reading-column selectors use the token (`count == 4`); flipped negative pin — zero literal `max-width: 46rem`; `.tuning-shell`/`.theme-shell`/`.doc-edit-shell` cap-free, token-free, `margin-inline`-free with the `.sources-shell` shape; dialog panels ride `min(var(--chat-column), calc(100vw - 2rem))`; stale comment claims gone, 72rem named at chat/shared shells.
- Grep sweep (`46rem|92rem|chat-column` under `tests/unit/`) judged per pin: `test_doc_edit_screen.py` (shell cap-free pin), `test_remove_confirm_modal.py` + `test_source_ignore_paths.py` (dialog `min(var(--chat-column)` pins), `test_markdown_tables.py`, `test_shared_page.py` (docstrings + token pin kept; its "no 46rem" negative pin still valid), `test_pinned_composer.py` (docstring) all updated to the new contract; no fixture text touched. Every remaining reference is a new-contract pin or description.
**Results**
- `uv run pytest tests/unit/ -q` → green (exit 0; 8/8 in the rewritten file)
- `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, 1 warning; `app/` coverage **99%** (>90% gate)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
**Decisions** — dialog panels (remove-confirm, ignore-editor) were moved onto the token by task 01, so their unit pins now assert the token form (behavior change is task 01's, already validated); no `app/`/HTML/JS touched. No defects found in prior work.
**Next pending task:** `.agents/phases/todo/100_page_width_consistency/03_e2e_width_sweep.md`
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 306 0 100%
--------------------------------------------------
TOTAL 3824 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
**Phase 100, task 03 complete — all green.**
**Implemented**
- Rewrote `tests/e2e/test_wide_desktop_column.py` to the measured phase-100 contract: chat == tuning == theme == RAG shells (±4px) at 1280×800 AND 1920×1080 (each ≈1152px, the 72rem container); modal unchanged (panel ~1100px, inner `.doc-md` ~1058px); shared `.shared-shell` ≈1152px; standalone `document.html` `.doc-md` ≈1112px; 360px overflow-free, 900px full-width — seeding helpers kept. Verified pins are discriminating (temp 46rem token → all 3 fail; CSS restored).
- Updated in place: `test_document_viewer.py` (standalone `.doc-md` ≤736px pin → ≈1112px), `test_responsive_polish.py` (92rem/46rem pins + constants → 72rem container); comment-only fixes in `test_save_share_ux.py`, `test_sticky_navbar.py`, `test_markdown_tables.py`, `mock_llm.py`.
**Test / lint / coverage (exact commands)**
- `uv run pytest tests/e2e/test_wide_desktop_column.py -v --no-cov` → 3 passed (in isolation)
- Updated suites in isolation: document_viewer 7, save_share_ux 5, sticky_navbar 3, markdown_tables 6, responsive_polish 7 — all passed
- Theme no-op UNCHANGED: `test_ui_customization.py` 4, `test_admin_theme_tab.py` 5 — all passed
- Regression: `test_chat_rag.py` 3, `test_theme_semantic_completion.py` 8 — all passed
- `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/` (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
**Decisions**
- Per harness protocol I did NOT move the phase dir or commit — all changes left in the working tree for the harness's single atomic phase commit (task-file step 5's commit/move handled by harness).
**Next pending task:** phase 100 is fully done; next in numeric order is `101_tokens_page_overhaul` (task 01).
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 21%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 306 0 100%
--------------------------------------------------
TOTAL 3824 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+71 -78
View File
@@ -45,10 +45,13 @@
--header-h: 64px;
/* Reading column: 46rem base (PLAN §7 lineage); 2x on wide desktops
(owner instruction 2026-08-31, TODO L5 / D2 — chat + shared +
document view). */
--chat-column: 46rem;
/* Reading column: 72rem — EQUAL to the .container's 72rem cap, so
the reading columns (chat, shared, the document viewer's .doc-md
+ its summary panel) ride the same width as every other page
(owner instruction 2026-09-12: "match the width of the RAG page
for all other pages" — supersedes the 2026-08-31 instruction, and
the wide-desktop doubling with it). */
--chat-column: 72rem;
}
* { box-sizing: border-box; }
@@ -389,8 +392,11 @@ body::before {
/* Chat is a vertical conversation: a centered, capped column is the
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
from collapsing into a hairline on wide screens. The cap is the
--chat-column token: 46rem base, 2x (92rem) at >=1500px wide
desktops (owner instruction 2026-08-31, TODO.md L5 / D2). */
--chat-column token: 72rem — equal to the .container's cap, so the
chat column reads at the RAG page's full container width at every
viewport (owner instruction 2026-09-12: "match the width of the
RAG page for all other pages" — the wide-desktop doubling was
retired with it). */
.chat-shell {
max-width: var(--chat-column);
margin-inline: auto;
@@ -409,8 +415,8 @@ body::before {
flips this to a vertical stack (flex-direction: column +
align-items: stretch — full-width pills, New chat above Share; the
existing ≤640px pill rules apply to the stacked pills unchanged).
The reading-column contract is untouched (--chat-column: 46rem base,
92rem at >=1500px — PLAN §7 lineage).
The reading-column contract is untouched (--chat-column: 72rem —
the .container width at every viewport).
Phase 65 (task 03, 2026-09-01, `TODO.md` L3, owner-locked A2): the
row is the TOP member of the pinned .chat-bottom cluster (task
@@ -504,8 +510,9 @@ body::before {
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
renderer wraps every table in .md-table-wrap — the horizontal
scroller, so a wide table scrolls inside the bubble instead of
breaking the reading column (--chat-column: 46rem base, 92rem at
>=1500px) — around a semantic <table class="md-table">
breaking the reading column (--chat-column: 72rem — the
.container width at every viewport) — around a semantic
<table class="md-table">
(escape-first cells; alignment colons render left, owner decision).
Phase-08 tokens only: --line hairline borders and the thead tinted
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
@@ -603,8 +610,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark)
theme. Inline rows only: appending lines never shifts the chat
column (46rem base; 92rem at >=1500px — no new container), and the
rows are not interactive — no focus targets. */
column (72rem — the .container width at every viewport; no new
container), and the rows are not interactive — no focus targets. */
.tool-calls {
display: flex;
flex-direction: column;
@@ -960,15 +967,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat
conversation. Same width discipline as the chat column — a centered,
capped column on the 72rem frame; the form and the note list span its
FULL width (no skinny lists). Every interactive target is >=44px;
text pairs reuse the Phase-08 AA palette (dark ink on brand 5.2:1,
brand-ink/brand-soft 6.9:1, ok 10.6:1, err 9.1:1, ink-soft >=6.9:1).
No filter: blur, no CDN, system font stack. */
conversation. Full-container width (owner instruction 2026-09-12:
"match the width of the RAG page for all other pages" — the
phase-27 capped-column discipline is superseded): the shell rides
the .container's 72rem frame like the RAG page, and the form and
the note list span its FULL width (no skinny lists). Every
interactive target is >=44px; text pairs reuse the Phase-08 AA
palette (dark ink on brand 5.2:1, brand-ink/brand-soft 6.9:1,
ok 10.6:1, err 9.1:1, ink-soft >=6.9:1). No filter: blur, no CDN,
system font stack. */
.tuning-shell {
max-width: 46rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -1241,8 +1249,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Composer ---------- */
/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the
viewport bottom. The page scrolls at the document level and
`.chat-shell` (the centered reading column — 46rem base, 92rem at
>=1500px) is the composer's sticky
`.chat-shell` (the centered reading column — 72rem, the
.container width at every viewport) is the composer's sticky
containing block, so the box sticks to the bottom edge of the
viewport at every scroll position and settles back into its normal
flow position (above the footer) once the document bottom is
@@ -2265,8 +2273,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
a fixed full-viewport dim backdrop + a centered panel (z-index
1000, above the sticky header (20) + skip-link (100); NO blur —
the phase-08 no-blur perf anchor), scaled to a compact dialog:
the 46rem chat-column width or the viewport, whichever is
narrower. Phase-08 tokens only; system fonts; no CDN.
the chat-column width (the --chat-column token — 72rem, the
.container width) or the viewport, whichever is narrower.
Phase-08 tokens only; system fonts; no CDN.
AA pairs: title/copy are --ink on --surface (13.8:1); the source
value is --ink on --bg (16.7:1); the error line is the err pair
@@ -2302,7 +2311,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: relative;
z-index: 1;
margin: auto;
width: min(46rem, calc(100vw - 2rem));
width: min(var(--chat-column), calc(100vw - 2rem));
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
@@ -2405,7 +2414,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#remove-confirm-dialog overlay contract (phase 69): a fixed
full-viewport dim backdrop + a centered panel (z-index 1000, above
the sticky header (20) + skip-link (100); NO blur — the phase-08
no-blur perf anchor), scaled to the 46rem chat-column width or the
no-blur perf anchor), scaled to the chat-column width (the
--chat-column token — 72rem, the .container width) or the
viewport, whichever is narrower. The box: a VISIBLE block label
(WCAG — never aria-label-only) over a mono textarea (the box is a
data entry, not prose — the var(--mono) stack); the error line is
@@ -2442,7 +2452,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: relative;
z-index: 1;
margin: auto;
width: min(46rem, calc(100vw - 2rem));
width: min(var(--chat-column), calc(100vw - 2rem));
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
@@ -3168,8 +3178,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
The shell's seventh view (#view-theme): the admin palette + branding
editor (task 05). The centered 46rem column (the .tuning-shell
language — this is a form view, the tuning-view width pattern), the
editor (task 05). The full 72rem container width (owner
instruction 2026-09-12 — the phase-91 form-column cap is
superseded), the
form card (the #tune-form language: surface fill, --line hairline,
radius, shadow), the fieldset groups (Branding / Palette) with the
.theme-note sub-copy, the 3-column (desktop) / 1-column (mobile)
@@ -3184,8 +3195,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible
via the global 3px outline rule. No CDN, system fonts. */
.theme-shell {
max-width: 46rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -3347,12 +3356,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
chat column (--chat-column: 46rem base, 92rem at >=1500px) — the
conversation reads exactly like the chat
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
unchanged) with NO composer, so the column contract holds for a
guest. Zero interactive controls (owner-locked): the chips are
2026-08-29, TODO.md L6). The shared page reads exactly like the
chat page — both at the 72rem container width (--chat-column, now
equal to the .container cap — owner instruction 2026-09-12; the
wide-desktop doubling was retired) — and the .msg/.bubble/.thinking/
.tool-calls/.msg-meta rules apply unchanged, with NO composer, so
the column contract holds for a guest. Zero interactive controls
(owner-locked): the chips are
plain text, so the pill families' pointer treatments are switched
off IN THIS SCOPE ONLY — the chat page's interactive chips keep
their styles untouched. Every pair reuses the Phase-08 AA palette
@@ -3361,8 +3371,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
fonts. */
.shared-shell {
width: 100%;
max-width: var(--chat-column); /* the PLAN §7 centered chat column
(46rem base; 92rem at >=1500px — phase 58) */
max-width: var(--chat-column); /* the centered chat column — 72rem,
the .container width at every viewport */
margin-inline: auto;
display: flex;
flex-direction: column;
@@ -3525,9 +3535,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#doc-content { display: flex; flex-direction: column; }
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* Markdown: the centered reading column — 46rem base, 2x (92rem) at
>=1500px (PLAN §7.1 lineage; owner instruction 2026-08-31, TODO.md
L5 / D2). */
/* Markdown: the centered reading column — 72rem, the .container width
at every viewport (owner instruction 2026-09-12: "match the width
of the RAG page for all other pages" — the wide-desktop doubling
was retired). */
.doc-md {
width: 100%;
max-width: var(--chat-column);
@@ -3576,8 +3587,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
overflow-wrap: anywhere;
}
/* md/markdown: the panel matches the .doc-md centered reading column
(46rem base; 92rem at >=1500px — the same --chat-column token) —
it is the column's label. Raw formats stay full width (the
(72rem — the same --chat-column token, the .container width at
every viewport) — it is the column's label. Raw formats stay full width (the
.doc-raw default above), matching the full-width pre; in engines
without :has() the panel degrades to that full-width default. */
.doc-summary:has(+ .doc-md) {
@@ -3743,8 +3754,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Document modal (phase 26) ----------
"New documents should open in an almost-fullscreen modal, not in a new
page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta
badge classes, the .doc-md reading column (46rem base; 92rem at
>=1500px), and the .doc-raw
badge classes, the .doc-md reading column (72rem — the .container
width; inside the modal the 1100px panel stays its effective
ceiling), and the .doc-raw
pre — this block only adds the chrome (backdrop, panel, header,
actions, scroll container). Phase-08 tokens only; NO blur (the
phase-08 no-blur perf anchor); no new assets; system fonts.
@@ -3886,9 +3898,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
/* The scroll container: vertical scroll lives HERE, never the viewport.
.doc-md keeps its centered reading column inside (46rem base;
92rem at >=1500px, capped there by the 1100px panel); .doc-raw keeps
its own overflow-x. tabindex="-1" in the markup is the JS focus target. */
.doc-md keeps its centered reading column inside (72rem — wider
than the 1100px panel's inner width, which stays its effective
ceiling); .doc-raw keeps its own overflow-x. tabindex="-1" in the
markup is the JS focus target. */
.doc-modal-content {
flex: 1;
min-height: 0;
@@ -3906,16 +3919,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/doc-edit.html: the admin-gated edit screen for a doc draft (title,
in-repo path, markdown body) — a FLOW page, not one of the app's
pages, so the header is SLIM (brand + "← Back to chat" only). The
46rem base column is HARD-CODED: a form column, not a reading
column — it does not ride --chat-column, so phase 58's wide-desktop
doubling never stretches the form. The .sources-gate gate is reused
verbatim (phases 16/35/50). Every pair reuses the Phase-08 AA
palette; touch targets >=44px; :focus-visible via the global 3px
outline rule. No CDN, system fonts. */
shell rides the full 72rem container like every other page (owner
instruction 2026-09-12 — the phase-59 form-column cap is
superseded). The .sources-gate gate is reused verbatim
(phases 16/35/50). Every pair reuses the Phase-08 AA palette;
touch targets >=44px; :focus-visible via the global 3px outline
rule. No CDN, system fonts. */
.doc-edit-shell {
width: 100%;
max-width: 46rem; /* the 46rem base column (hard-coded — see above) */
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -4385,9 +4396,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.theme-reset { width: 100%; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell keeps its base 46rem column (the >=1500px 92rem override
never applies here — it is already the narrowest box on the
page) and .msg-body's 92% override above applies. */
shell is full-width here (the 72rem cap never binds on a phone —
the container is already 100%) and .msg-body's 92% override
above applies. */
#shared-title { font-size: 1.35rem; }
.shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
@@ -4418,24 +4429,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
}
/* Phase 58: wide desktops (viewport >=1500px) read the 2x column —
--chat-column doubles to 92rem for the four reading shells (chat,
shared, the document viewer's .doc-md and its summary panel). The
chat and shared shells ARE their .container (the token max-width
overrides the .container's 72rem cap — same specificity, later in
the file), but the document page's .container.doc-shell WRAPS the
column, so the 72rem cap would bind first and pin .doc-md at
~1112px: the wide block lifts the shell's cap to the column plus
the container's two 1.25rem gutters, letting .doc-md's own 92rem
cap bind (1472px at the 16px root). Everything below 1500px
renders exactly as before, and .tuning-shell (a form, not a
reading surface) keeps its hard-coded 46rem at every width
(owner instruction 2026-08-31, TODO.md L5 / D2). */
@media (min-width: 1500px) {
:root { --chat-column: 92rem; }
.doc-shell { max-width: calc(var(--chat-column) + 2 * 1.25rem); }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
180ms slide+fade; open/close snaps (the visibility/opacity flip
applies instantly) and stays correct. BOTH states are named: the
+6 -3
View File
@@ -327,7 +327,8 @@ Implements just enough of the aipi surface:
a deliberately wide 5-column table — byte-stable, so the story E2E
can assert the rendered ``<table class="md-table">`` shape, the
escaped XSS line, and the wrapper's horizontal scroll inside the
46rem column. Checked BEFORE the ``DEFLECT_MODE`` branch (a
72rem container column (phase 100). Checked BEFORE the
``DEFLECT_MODE`` branch (a
deflection prompt never carries the marker, same reasoning as
``SUMMARY_MODE``), so a marker question always gets the table
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
@@ -584,7 +585,8 @@ TABLE_TRIGGER = "show me a table"
#: The fixed table answer (phase 44) — byte-stable on purpose: the story
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
#: line (the XSS payload must survive the mock byte-for-byte), and the
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
#: wide table's ``scrollWidth > clientWidth`` inside the 72rem container
#: column (phase 100).
#: Phase 74 (chat history, TODO L4): a user message containing this
#: substring (case-insensitive) gets the deterministic HISTORY ECHO
#: (``_history_echo`` below — see the module docstring): the prior-turn
@@ -1653,7 +1655,8 @@ def compose_answer(body: dict[str, Any]) -> str:
# <img onerror> XSS probe line (it must survive the mock
# byte-for-byte so the E2E can prove the renderer neutralizes
# it), and a wide 5-column table (guarantees scrollWidth >
# clientWidth inside the 46rem column). Byte-stable. Checked
# clientWidth inside the 72rem container column, phase 100).
# Byte-stable. Checked
# BEFORE the DEFLECT_MODE branch: a deflection prompt never
# carries the marker (it lives in the user message, same
# reasoning as SUMMARY_MODE), so a marker question always gets
+12 -3
View File
@@ -24,7 +24,9 @@ Test → story mapping (Playwright Mapping Rule):
renders as escaped text; no dialog fires.
6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
page keeps its phase-10 contract (title/content/badges, not-found,
dark theme, no-CDN, a11y frame, ≤736px md column).
dark theme, no-CDN, a11y frame, the 72rem-container md column —
phase 100: ≈1112px at the 1280px fixture viewport, the container's
inner content).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
"""
@@ -406,9 +408,16 @@ def test_standalone_page_still_works(
expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite")
assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main"
# Markdown column centered and capped at 46rem (736px at 16px root).
# Markdown column: the 72rem container's inner content (phase 100 —
# the 46rem cap and its wide-desktop doubling are retired): at the
# 1280px fixture viewport the container is 1152px border-box, so
# .doc-md (width:100% inside it) measures 1152 − 2×1.25rem = 1112px.
box = page.locator("#doc-content .doc-md").bounding_box()
assert box is not None and box["width"] <= 736 + 1
assert box is not None, "the standalone .doc-md column is not rendered"
assert abs(box["width"] - 1112) <= 4, (
f"the standalone .doc-md column is {box['width']:.0f}px, "
f"want 1112px (the 72rem container's inner content) ±4px"
)
assert errors == [], f"console crashes: {errors}"
+4 -3
View File
@@ -22,7 +22,8 @@ Test → story mapping (Playwright Mapping Rule):
NOT deflected (the honesty-gate interplay is part of the contract).
2. ``test_wide_table_scrolls`` — the wide table's wrapper has
``scrollWidth > clientWidth`` and horizontal scroll moves it; the
page itself has no horizontal overflow (the 46rem column holds).
page itself has no horizontal overflow (the 72rem container column
holds — phase 100).
3. ``test_table_xss_safe`` — the ``<img onerror>`` line renders as
visible, escaped text: zero injected ``<img>`` nodes, no dialog.
4. ``test_viewer_table_renders`` — the fixture's pipe table opens from
@@ -219,8 +220,8 @@ def test_wide_table_scrolls(
after = wrap.evaluate("el => el.scrollLeft")
assert after > before, "the wrapper must scroll horizontally"
# The 46rem chat column must not break the page: no horizontal
# document overflow (PLAN §7.1).
# The 72rem container column must not break the page: no horizontal
# document overflow (PLAN §7.1, as revised by phase 100).
page_scroll, page_client = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
+25 -18
View File
@@ -12,12 +12,13 @@ Test → story mapping:
1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on
both pages: ``documentElement.scrollWidth <= clientWidth``.
2. ``test_chat_column_capped_and_centered`` — the reading column rides
--chat-column (46rem base; 92rem at >=1500px, phase 58 / owner
instruction 2026-08-31 TODO L5): at 1600px (a wide desktop) the
``.chat-shell`` is 92rem (1472px, ±2%) and horizontally centered
(±2%); at 1280px (below the wide breakpoint) it stays ≤ 46rem
(736px, +2%); at 768px the column uses most of the width (no
mid-column dead zones).
--chat-column (72rem — EQUAL to the .container's cap at every
viewport, phase 100 / owner instruction 2026-09-12: the 46rem base
+ the >=1500px 92rem doubling are retired): at 1600px (a wide
desktop) the ``.chat-shell`` is the 72rem container (1152px, ±2%)
and horizontally centered (±2%); at 1280px the same 1152px (the
container cap binds, not the viewport); at 768px the column is
full-width (no mid-column dead zones).
3. ``test_sources_table_full_width`` — at 1280px ``.table-wrap`` ≥ 80% of
the container; below 640px the table keeps its 640px min-width and the
wrapper scrolls horizontally instead of squeezing.
@@ -55,8 +56,11 @@ REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
VIEWPORTS = ((360, 740), (375, 812), (768, 1024), (1280, 800), (1600, 900))
CHAT_SHELL_CAP_PX = 46 * 16 # 736px — the --chat-column base (PLAN §7.1 lineage)
CHAT_SHELL_WIDE_PX = 92 * 16 # 1472px — the 2x wide override (phase 58, >=1500px)
# Phase 100: the ONE width — the 72rem container, border-box (the
# 2×1.25rem gutters are inside the measured box). The 46rem base and
# the 92rem wide-desktop doubling are retired (owner instruction
# 2026-09-12: "match the width of the RAG page for all other pages").
CHAT_SHELL_CONTAINER_PX = 72 * 16 # 1152px — the --chat-column = container cap
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
SLOW_QUESTION = "pretend to think slowly, please"
@@ -225,18 +229,19 @@ def test_no_horizontal_overflow_at_viewports(
def test_chat_column_capped_and_centered(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC1 (phase 58 contract): the reading column doubles to 92rem on
wide desktops (>=1500px — 1600px here), stays at the 46rem base
below the breakpoint (1280px), and still uses most of the width on
tablets (no mid-column dead zones)."""
"""AC1 (phase 100 contract): the reading column is the 72rem
container at every viewport — 1600px here (the cap binds, not the
viewport) and 1280px alike (1152px, ±2%, centered), and still
full-width on tablets (no mid-column dead zones). The phase-58
46rem/92rem contract it used to pin is retired."""
page = browser.new_page(viewport={"width": 1600, "height": 900})
try:
page.goto(f"{app_url}/")
box = page.locator(".chat-shell").bounding_box()
assert box is not None
assert CHAT_SHELL_WIDE_PX * 0.98 <= box["width"] <= CHAT_SHELL_WIDE_PX * 1.02, (
f"at 1600px (>=1500px) the chat column is {box['width']:.0f}px, "
f"not the 92rem wide override (±2%)"
assert CHAT_SHELL_CONTAINER_PX * 0.98 <= box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
f"at 1600px the chat column is {box['width']:.0f}px, "
f"not the 72rem container (1152px, ±2%)"
)
center = box["x"] + box["width"] / 2
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
@@ -250,9 +255,11 @@ def test_chat_column_capped_and_centered(
narrow.goto(f"{app_url}/")
box = narrow.locator(".chat-shell").bounding_box()
assert box is not None
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
f"at 1280px (<1500px) the chat column {box['width']:.0f}px exceeds "
f"the 46rem base cap (+2%)"
# 1280 > 1152: the container cap binds — the SAME 72rem width as
# at 1600px (the cap no longer depends on the viewport at all).
assert box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
f"at 1280px the chat column {box['width']:.0f}px exceeds "
f"the 72rem container cap (+2%)"
)
finally:
narrow.close()
+3 -2
View File
@@ -41,7 +41,8 @@ unshare, layout) are unchanged.
* **Layout (L6 / A5)** — desktop 1280×800: ``#new-chat-btn`` and
``#share-chat-btn`` share one horizontal row inside ``.chat-actions``
(overlapping y bands, Share's x beyond New chat's x + width, each
pill at intrinsic width — never the full 46rem column); mobile
pill at intrinsic width — never the full 72rem container column);
mobile
390×844: stacked vertically (Share below New chat, full-width);
360px wide: no horizontal page overflow;
* **Admin still works (A1 sanity)** — the same auto-save machinery
@@ -502,7 +503,7 @@ def test_action_row_layout(page: Page, app_url: str, mock_llm: int, db_ready: No
# Desktop (1280×800): one horizontal row — overlapping y bands,
# Share to the right of New chat, each pill at its INTRINSIC width
# (never the full 46rem chat column).
# (never the full 72rem container column — phase 100).
nb = new_btn.bounding_box()
sb = share_btn.bounding_box()
assert nb is not None and sb is not None
+1 -1
View File
@@ -265,7 +265,7 @@ def test_doc_header_stuck_at_top_on_long_document(
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
# The doc must actually scroll (the ~40,000-char body fills many
# viewports in the 46rem column).
# viewports in the 72rem container column — phase 100).
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > VIEWPORT_H, (
f"the long document must make the page scroll "
+5 -4
View File
@@ -979,10 +979,11 @@ def test_reset_removes_tag_byte_identical(page: Page, app_url: str, db_ready: No
# The tag is GONE from the live document (theme.js reconciles the
# #bor-theme tag's DOM text to the settled, now-default values —
# the no-op case removes the tag and clears the <html>
# overrides)…
assert 'id="bor-theme"' not in page.content(), (
"the reset document must be tag-free"
)
# overrides). The removal lands in the post-PUT refetch's
# reconcile (the result line above only signals the PUT landed),
# so WAIT for the settled tag-free state rather than racing the
# refetch round-trip…
expect(page.locator("#bor-theme")).to_have_count(0, timeout=30_000)
# …and a FRESH load serves NO tag at all (the all-NULL row is the
# no-op injection).
r = httpx.get(app_url + "/", timeout=10)
+227 -154
View File
@@ -1,16 +1,33 @@
"""Phase 58 E2E (Playwright): the 2x reading column on wide desktops —
measured bounding-box widths, not CSS pins.
"""Phase 100 E2E (Playwright): every page matches the RAG page's width —
the full 72rem container at every viewport, measured bounding boxes.
TODO.md L5 (owner instruction 2026-08-31, roadmap confirmation D2 +
expansion): "The chat response needs to be 2x wider on wide desktops.
there's a lot of unused space." — extended to the document view. The
owner-locked contract: viewport >=1500px doubles ``--chat-column`` to
92rem (1472px at the 16px root) for the four reading shells —
``.chat-shell``, ``.shared-shell``, ``.doc-md`` and
``.doc-summary:has(+ .doc-md)`` — while everything below the breakpoint
renders exactly as before (46rem / 736px) and ``.tuning-shell`` (a
form, not a reading surface) never widens (CSS-pinned by the unit
suite, task 01 — the browser proof of the MEASURED width is this file).
Owner request (chat, 2026-09-12): "The theme, tuning, and chat pages
are still pretty narrow, I want you to match the width of the RAG page
for all other pages to keep things consistent." — SUPERSEDES the
phase-58 2026-08-31 46rem/92rem reading-column contract (recorded per
the phase-94/96/97 convention in `.agents/phases/todo/
100_page_width_consistency/00_phase.md`, decisions D1–D4). The phase-58
suite this file used to be updates IN PLACE to the new contract (the
phase-97 task-07/08 precedent: a completed phase's suite follows its
changed contract; its seeding helpers are kept).
The measured contract (the 16px root, the ±4px tolerance is the task
spec):
* the container is ``min(100%, 72rem)`` border-box with the 2×1.25rem
gutters INSIDE the box — at any viewport ≥ ~1200px every shell
(``.container.*-shell``) measures **1152px** (72rem), identical on
the chat page, Tuning, Theme, and the RAG page;
* the standalone ``document.html`` page's ``.doc-md`` is the container's
inner content — **1112px** (1152 − 2×1.25rem);
* the same-page document MODAL is untouched (D2): the panel stays
**~1100px** and the panel binds the ``.doc-md`` inside it —
**1058px** (1100 − 2×1px borders − 2×1.25rem of
``.doc-modal-content`` padding), NOT 1152;
* below the container cap everything is full-width as before: at 360px
no horizontal overflow and the shell is the viewport width; at 900px
chat/tuning/theme all measure 900px (equal — the cap never bound
below 72rem anyway).
Run in isolation (DB must be up: ``podman compose up -d db``):
@@ -18,33 +35,30 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
Test → contract mapping:
1. ``test_chat_column_wide_vs_base`` — ``/`` at 1920×1080: the
``.chat-shell`` bounding box is 1472px (92rem, ±4px) and centered;
at 1280×800 (below the wide breakpoint) it is back to 736px
(46rem, ±4px).
2. ``test_document_column_wide_vs_base`` — a seeded markdown document
that carries a summary: at 1920 both ``.doc-md`` and the adjacent
``.doc-summary:has(+ .doc-md)`` panel are 1472px; at 1280
``.doc-md`` is 736px.
3. ``test_shared_column_wide`` — a real auto-saved conversation shared
by token: ``/shared/<token>`` at 1920 renders ``.shared-shell`` at
1472px in a fresh anonymous context.
4. ``test_narrow_unchanged`` — 360px: no horizontal overflow and the
``.chat-shell`` is the existing mobile rule — full-bleed at the
viewport width (the shell IS its .container; the 0.9rem mobile
gutters live in its own padding, inside the measured box), 900px:
the shell holds the 46rem base (736px, not the 1472px wide rule —
at 900px a leaked wide override would pin the shell to the 860px
content box instead, so 736px is the discriminator) — the wide
rule does not leak below 1500px.
1. ``test_all_columns_match_the_rag_page`` — the core pin: at
1280×800 the ``.chat-shell`` (``/``), ``.tuning-shell``
(``/tuning.html``), ``.theme-shell`` (``/theme.html``) and the RAG
page's ``.sources-shell`` (``/sources.html``) bounding boxes are ALL
EQUAL (±4px) — the owner's "match the width of the RAG page" as one
assertion; at 1920×1080 the same four are still equal to each other
and each measures ≈1152px (72rem, ±4px).
2. ``test_reader_columns_wide`` — the token's consumers at 1920×1080:
the modal panel is still ~1100px (D2: the modal is UNCHANGED) with
its ``.doc-md`` at ~1058px; ``/shared/<token>``'s ``.shared-shell``
is ≈1152px; the standalone ``document.html`` page's ``.doc-md`` is
≈1112px (the 72rem container's inner content).
3. ``test_narrow_unchanged`` — the no-regression leg: at 360×800 no
horizontal overflow and ``.chat-shell`` is the viewport width
(full-bleed — the mobile gutters sit in the container's padding,
inside the measured box); at 900×600 chat/tuning/theme are all
900px wide, equal to each other.
DB isolation: the shared-chat row is deleted in a ``finally`` (admin
cookie — the house pattern of test_share_chat.py, whose distinctive
question text keeps the auto-title unique); the fixture document rows
follow the story-fixture convention of the sibling suites (truncate +
re-import; the summary document is a direct row insert, the
test_document_viewer.py XSS-fixture pattern — the viewer is
database-only).
re-import; the viewer fixture is a direct row insert — the viewer is
database-only, the test_document_viewer.py XSS-fixture pattern).
"""
from __future__ import annotations
@@ -70,14 +84,22 @@ from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# The two proof viewports (task spec): wide desktop vs just below the
# 1500px breakpoint, at the 16px root the phase-58 rem contract:
# 92rem = 1472px, 46rem = 736px. ±4px tolerance (task spec).
WIDE_PX = 92 * 16 # 1472px — the 2x wide override (>=1500px)
BASE_PX = 46 * 16 # 736px — the --chat-column base
TOL_PX = 4
WIDE_VIEWPORT: ViewportSize = {"width": 1920, "height": 1080}
BASE_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
# The phase-100 measured contract (16px root, the task spec):
# * CONTAINER_PX — 72rem border-box, the 2×1.25rem gutters INSIDE the
# box; every .container.*-shell measures this at any viewport
# >= ~1200px (the RAG page's width — the owner's ask).
# * DOC_MD_STANDALONE_PX — the standalone document page's .doc-md: the
# container's inner content (CONTAINER_PX minus the gutters).
# * MODAL_PANEL_PX / DOC_MD_MODAL_PX — the SAME-PAGE modal is untouched
# (D2): the 1100px border-box panel (minus its 1px borders) and
# .doc-modal-content's 2×1.25rem padding bind the .doc-md inside it.
CONTAINER_PX = 72 * 16 # 1152px — the 72rem container (border-box)
DOC_MD_STANDALONE_PX = CONTAINER_PX - 2 * 20 # 1112px — inner content
MODAL_PANEL_PX = 1100 # the phase-26 panel ceiling (D2: unchanged)
DOC_MD_MODAL_PX = MODAL_PANEL_PX - 2 - 2 * 20 # 1058px — panel-inner
TOL_PX = 4 # the task spec's ±4px tolerance
VIEWPORT_1280: ViewportSize = {"width": 1280, "height": 800}
VIEWPORT_1920: ViewportSize = {"width": 1920, "height": 1080}
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
@@ -139,11 +161,11 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
def _seed_summary_doc() -> None:
"""One markdown document carrying a stored summary — the ONLY shape
that renders both reading-column pins on one page: ``.doc-summary``
(the phase-36 panel) directly above ``.doc-md`` (the ``:has(
+ .doc-md)`` sibling match). Direct row insert — the viewer is
database-only (the test_document_viewer.py XSS-fixture pattern)."""
"""One markdown document carrying a stored summary — the viewer
fixture the phase-100 suite opens in BOTH the same-page modal and
the standalone document page (the test_document_viewer.py
XSS-fixture pattern: a direct row insert, the viewer is
database-only)."""
with SessionLocal() as db:
db.add(
Document(
@@ -153,11 +175,11 @@ def _seed_summary_doc() -> None:
title=FIXTURE_TITLE,
content=(
"# Wide Column Fixture\n\n"
"Phase-58 width pin: a markdown document with a "
"summary, so the viewer renders the .doc-summary "
"panel directly above the .doc-md column."
"Phase-100 width pin: a markdown document with a "
"summary, opened in the same-page modal and the "
"standalone document page."
),
summary="A phase-58 fixture summary for the wide-column pin.",
summary="A phase-100 fixture summary for the width pin.",
content_hash="w" * 64,
indexed_at=datetime.now(UTC),
)
@@ -170,14 +192,29 @@ def _seed_summary_doc() -> None:
# ---------------------------------------------------------------------------
def _drill(page: Page, *names: str) -> None:
"""Phase 97: the catalog is the drill-down tree — click through the
source/folder rows (exact name match, one per name) to the level
that holds the asserted file."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _box_width(page: Page, selector: str, label: str) -> float:
"""The element's bounding-box width (the measured rendered width,
not the computed style) — None means the element is not rendered,
which is a test error, not a measurement."""
box = page.locator(selector).first.bounding_box()
assert box is not None, f"{selector} not rendered ({label})"
return box["width"]
def _assert_width(page: Page, selector: str, expected_px: int, label: str) -> None:
"""The element's bounding-box width is ``expected_px`` ±4px (task
spec) — the measured rendered width, not the computed style."""
box = page.locator(selector).first.bounding_box()
assert box is not None, f"{selector} not rendered ({label})"
assert abs(box["width"] - expected_px) <= TOL_PX, (
f"{label}: {selector} is {box['width']:.1f}px, "
f"want {expected_px}px ±{TOL_PX}px"
width = _box_width(page, selector, label)
assert abs(width - expected_px) <= TOL_PX, (
f"{label}: {selector} is {width:.1f}px, want {expected_px}px ±{TOL_PX}px"
)
@@ -265,109 +302,132 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _measure_shells(
browser: Browser, app_url: str, viewport: ViewportSize
) -> dict[str, float]:
"""One signed-in page at ``viewport``; measure the bounding-box
width of every shell under test after its view is shown:
* ``.chat-shell`` on ``/`` (the chat view — the div IS
``.container.chat-shell``);
* ``.tuning-shell`` on ``/tuning.html``, ``.theme-shell`` on
``/theme.html``, and the RAG page's ``.sources-shell`` on
``/sources.html`` (admin deep-links — A11's one shell document).
"""
page = browser.new_page(viewport=viewport)
widths: dict[str, float] = {}
try:
login(page, app_url, next="/")
page.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
widths[".chat-shell"] = _box_width(page, ".chat-shell", "chat")
for path, selector in (
("/tuning.html", ".tuning-shell"),
("/theme.html", ".theme-shell"),
("/sources.html", ".sources-shell"),
):
page.goto(app_url + path)
page.locator(selector).wait_for(state="visible", timeout=10_000)
widths[selector] = _box_width(page, selector, path)
finally:
page.close()
return widths
# ---------------------------------------------------------------------------
# 1. Chat: 1472px at 1920, back to 736px at 1280 (centered both ways)
# 1. The core pin: chat == tuning == theme == RAG page (±4px), at both
# proof viewports; each ≈1152px (the 72rem container) at 1920
# ---------------------------------------------------------------------------
def test_chat_column_wide_vs_base(
def test_all_columns_match_the_rag_page(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The chat page's .chat-shell doubles at the 1500px breakpoint:
92rem (1472px, ±4px) at 1920×1080, centered; 46rem (736px, ±4px)
at 1280×800 — the base below the breakpoint."""
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
login(wide, app_url, next="/") # phase 79: the chips need a session
wide.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
"""The owner's "match the width of the RAG page" as ONE assertion:
at 1280×800 the four shells are all EQUAL (±4px); at 1920×1080
they are still equal to each other and each measures ≈1152px
(72rem at the 16px root, ±4px — the box includes the container's
2×1.25rem padding, border-box)."""
# --- 1280×800 (just above the container cap — the owner's report) ---
widths = _measure_shells(browser, app_url, VIEWPORT_1280)
ref_selector, ref = next(iter(widths.items()))
for selector, width in widths.items():
assert abs(width - ref) <= TOL_PX, (
f"@1280px {selector} is {width:.1f}px but {ref_selector} is "
f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)"
)
_assert_width(wide, ".chat-shell", WIDE_PX, "chat @ 1920px")
_assert_centered(wide, ".chat-shell", 1920, "chat @ 1920px")
finally:
wide.close()
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
login(base, app_url, next="/") # phase 79: the chips need a session
base.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
# --- 1920×1080: equal AND each the 72rem container (≈1152px) ------
widths = _measure_shells(browser, app_url, VIEWPORT_1920)
ref_selector, ref = next(iter(widths.items()))
for selector, width in widths.items():
assert abs(width - ref) <= TOL_PX, (
f"@1920px {selector} is {width:.1f}px but {ref_selector} is "
f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)"
)
assert abs(width - CONTAINER_PX) <= TOL_PX, (
f"@1920px {selector} is {width:.1f}px, want "
f"{CONTAINER_PX}px (the 72rem container) ±{TOL_PX}px"
)
_assert_width(base, ".chat-shell", BASE_PX, "chat @ 1280px")
_assert_centered(base, ".chat-shell", 1280, "chat @ 1280px")
finally:
base.close()
# ---------------------------------------------------------------------------
# 2. Document viewer: .doc-md (and its .doc-summary panel) at both
# widths — the owner-expanded surface
# 2. The token's consumers at 1920×1080: the modal (unchanged, D2), the
# shared page, and the standalone document page
# ---------------------------------------------------------------------------
def test_document_column_wide_vs_base(
def test_reader_columns_wide(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""A seeded md document (with a summary) renders .doc-md at 1472px
at 1920 and 736px at 1280 — and the adjacent .doc-summary panel
matches the column at 1920 (.doc-summary:has(+ .doc-md))."""
"""At 1920×1080: the same-page document MODAL is untouched (D2 —
the panel is still ~1100px and IT binds the ``.doc-md`` inside,
~1058px, not 1152); ``/shared/<token>``'s ``.shared-shell`` is
≈1152px; the standalone ``document.html`` page's ``.doc-md`` is
≈1112px (the 72rem container's inner content)."""
_reset_db(mock_llm, seed=True)
_seed_summary_doc()
wide = browser.new_page(viewport=WIDE_VIEWPORT)
page = browser.new_page(viewport=VIEWPORT_1920)
try:
login(wide, app_url, next="/") # phase 79: the viewer content is gated
wide.goto(app_url + FIXTURE_DOC_URL)
expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
expect(
wide.locator(".doc-summary:has(+ .doc-md)")
).to_have_count(1, timeout=15_000)
_assert_width(wide, "#doc-content .doc-md", WIDE_PX, "document @ 1920px")
# (a) the same-page modal — the 1100px panel stays its effective
# ceiling (D2: pin the PANEL, not the 1152 the column would be).
login(page, app_url, next="/sources.html")
page.locator("#folders-tbody .folder-link").first.wait_for(
state="visible", timeout=15_000
)
_drill(page, FIXTURE_SOURCE, "notes")
row = page.locator("#docs-tbody tr", has_text="wide-column-fixture.md")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text(FIXTURE_TITLE)
_assert_width(page, "#doc-modal-panel", MODAL_PANEL_PX, "modal panel @ 1920px")
_assert_width(
wide, ".doc-summary:has(+ .doc-md)", WIDE_PX, "summary panel @ 1920px"
page,
"#doc-modal-content .doc-md",
DOC_MD_MODAL_PX,
"modal .doc-md @ 1920px",
)
finally:
wide.close()
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
login(base, app_url, next="/") # phase 79: the viewer content is gated
base.goto(app_url + FIXTURE_DOC_URL)
expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
_assert_width(base, "#doc-content .doc-md", BASE_PX, "document @ 1280px")
finally:
base.close()
# ---------------------------------------------------------------------------
# 3. Shared page: .shared-shell at 1472px for a guest at 1920
# ---------------------------------------------------------------------------
def test_shared_column_wide(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
"""A real auto-saved conversation, shared by token, renders its
.shared-shell at 1472px (±4px) at 1920 in a FRESH anonymous
context (no cookies — the guest's only credential is the token)."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# (b) the standalone document page: the 72rem container's inner
# content (1152 − 2×1.25rem gutters).
page.goto(app_url + FIXTURE_DOC_URL)
expect(page.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(page.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
_assert_width(
page,
"#doc-content .doc-md",
DOC_MD_STANDALONE_PX,
"standalone .doc-md @ 1920px",
)
# (c) the shared page: .shared-shell at the full 72rem container.
page.goto(app_url + "/")
q = "How is my Kubernetes cluster set up? (wide-column)"
_ask(page, q)
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = row["id"]
saved_row = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = saved_row["id"]
anon_ctx = None
try:
# Public since phase 55 — the share endpoint takes no session.
@@ -376,7 +436,7 @@ def test_shared_column_wide(
share_url = r.json()["share_url"]
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
anon_ctx = browser.new_context(viewport=WIDE_VIEWPORT)
anon_ctx = browser.new_context(viewport=VIEWPORT_1920)
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
@@ -386,49 +446,62 @@ def test_shared_column_wide(
expect(anon.locator(".msg.brain .bubble")).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
_assert_width(anon, ".shared-shell", WIDE_PX, "shared @ 1920px")
_assert_width(anon, ".shared-shell", CONTAINER_PX, "shared @ 1920px")
_assert_centered(anon, ".shared-shell", 1920, "shared @ 1920px")
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
finally:
page.close()
# ---------------------------------------------------------------------------
# 4. Below the breakpoint: 360px and 900px are byte-for-byte the old
# rules — no overflow, no leaked wide column
# 3. Below the container cap: full-width as today — 360px overflow-free,
# 900px chat == tuning == theme (the cap never bound below 72rem)
# ---------------------------------------------------------------------------
def test_narrow_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The min-width:1500px override must not leak below the
breakpoint: at 360px no horizontal overflow and the shell is the
existing mobile rule — full-bleed at the viewport width (the shell
IS its .container, so the 0.9rem mobile gutters sit in its own
padding, inside the measured box); at 900px the shell holds the
46rem base (736px — a leaked 92rem rule would pin it to the 860px
content box instead, so 736px is the discriminator)."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
"""The mobile/tablet layouts are UNCHANGED (D3): at 360×800 no
horizontal overflow and ``.chat-shell`` is the viewport width
(full-bleed — the shell IS its .container, so the mobile gutters sit
in its padding, inside the measured box); at 900×600 the
chat/tuning/theme shells are ALL 900px wide, equal to each other
(the cap never bound below 72rem anyway)."""
phone = browser.new_page(viewport={"width": 360, "height": 800})
try:
login(phone, app_url, next="/") # phase 79: the chips need a session
phone.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
login(phone, app_url, next="/")
phone.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(phone, "chat @ 360px")
_assert_width(phone, ".chat-shell", 360, "chat @ 360px")
finally:
phone.close()
tablet = browser.new_page(viewport={"width": 900, "height": 800})
tablet = browser.new_page(viewport={"width": 900, "height": 600})
try:
login(tablet, app_url, next="/") # phase 79: the chips need a session
tablet.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
login(tablet, app_url, next="/")
tablet.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(tablet, "chat @ 900px")
_assert_width(tablet, ".chat-shell", BASE_PX, "chat @ 900px")
_assert_centered(tablet, ".chat-shell", 900, "chat @ 900px")
widths = {".chat-shell": _box_width(tablet, ".chat-shell", "chat @ 900px")}
for path, selector in (
("/tuning.html", ".tuning-shell"),
("/theme.html", ".theme-shell"),
):
tablet.goto(app_url + path)
tablet.locator(selector).wait_for(state="visible", timeout=10_000)
widths[selector] = _box_width(tablet, selector, f"{path} @ 900px")
ref_selector, ref = next(iter(widths.items()))
for selector, width in widths.items():
assert abs(width - ref) <= TOL_PX, (
f"@900px {selector} is {width:.1f}px but {ref_selector} is "
f"{ref:.1f}px — the cap must not bind below the container cap"
)
assert abs(width - 900) <= TOL_PX, (
f"@900px {selector} is {width:.1f}px, want the full 900px "
f"viewport width ±{TOL_PX}px"
)
finally:
tablet.close()
+16 -11
View File
@@ -10,8 +10,9 @@ without a browser:
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
minimal-flow-page lineage): skip-link, the SLIM header (brand +
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
column, NOT ``--chat-column``), the ``container`` frame;
"Back to chat" — no nav), the full 72rem container (the phase-59
form-column cap is retired — owner instruction 2026-09-12), the
``container`` frame;
* the form contract: ``#draft-title`` / ``#draft-path`` /
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
"Push to docs branch" copy) + the back link, ``#push-status``
@@ -455,21 +456,25 @@ def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
# ---------- styles.css: the new classes ----------
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
form column, not a reading column — it must NOT ride
--chat-column, so phase 58's wide-desktop doubling never stretches
the form), centered, a flex column on the container frame."""
def test_doc_edit_shell_rides_the_full_container() -> None:
""".doc-edit-shell: the full 72rem container (owner instruction
2026-09-12 — the phase-59 form-column cap is superseded): no
max-width, no margin-inline (the .container ancestor centers), a
flex column on the container frame."""
css = _css()
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .doc-edit-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
assert "max-width" not in body, (
"no cap — the shell rides the .container's 72rem frame"
)
assert "--chat-column" not in body, (
"the form column does not ride --chat-column (phase 58 must "
"not stretch it)"
"the shell rides the .container frame, not the reading-column "
"token"
)
assert "margin-inline" not in body, (
"the .container ancestor centers (no shell-level centering)"
)
assert "margin-inline: auto" in body
assert "flex-direction: column" in body
+2 -1
View File
@@ -206,7 +206,8 @@ def test_header_comment_notes_the_table_pass() -> None:
def test_table_wrap_is_the_horizontal_scroller() -> None:
""".md-table-wrap { overflow-x: auto } — the wrapper (not the
table) is the scroller, so a wide table scrolls inside the bubble
instead of breaking the 46rem column (story AC5)."""
instead of breaking the 72rem column (story AC5 — the width
contract is now the 72rem container, phase 100)."""
css = _text(STYLES_CSS)
block = re.search(r"\.md-table-wrap\s*\{([^}]*)\}", css)
assert block, "styles.css must define .md-table-wrap"
+2 -2
View File
@@ -6,8 +6,8 @@ be at the bottom of the screen on an EMPTY chat too, which sticky alone
cannot do).
The chat page scrolls at the DOCUMENT level and `.chat-shell` (the
centered 46rem column, PLAN §7.1) is the composer's sticky containing
block. The pin is therefore TWO rules, and both are pinned here:
centered 72rem column — the .container width, phase 100) is the
composer's sticky containing block. The pin is therefore TWO rules, and both are pinned here:
* `.messages { flex: 1 1 auto }` absorbs the free space of a short page,
so the composer's RESTING (in-flow) position is already the bottom of
+6 -3
View File
@@ -450,8 +450,9 @@ def test_modal_css_targets_and_contrast_pairs() -> None:
9.3:1, the err-line border — the .tuning-delete / .steering-delete
convention; the hover inverts to --bg on --err-line, 5.2:1);
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
error line is the err pair; the panel caps at the 46rem
chat-column width or the viewport."""
error line is the err pair; the panel caps at the chat-column
width (the --chat-column token — 72rem, owner instruction
2026-09-12) or the viewport."""
css = _css()
btn = css[css.find(".remove-confirm-btn {"):]
btn = btn[: btn.find("\n}")]
@@ -473,4 +474,6 @@ def test_modal_css_targets_and_contrast_pairs() -> None:
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = css[css.find(".remove-confirm-panel {"):]
panel = panel[: panel.find("\n}")]
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
assert "min(var(--chat-column)" in panel, (
"the chat-column cap via the token (or the viewport)"
)
+9 -8
View File
@@ -19,8 +19,8 @@ silent regression is caught without a browser:
chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips
carry no ``href``;
* the shared shell's reading-column mapping (--chat-column token,
phase 58) + the static-chip and
* the shared shell's reading-column mapping (--chat-column token —
the 72rem container width, phase 100) + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
@@ -384,17 +384,18 @@ def test_brand_note_resolves_at_call_time() -> None:
def test_shared_shell_maps_to_the_reading_column() -> None:
"""The PLAN §7 column contract (phase 58): .shared-shell is the
centered chat column riding the --chat-column token (46rem base,
92rem at >=1500px wide desktops — owner instruction 2026-08-31,
TODO L5 / D2). The conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged."""
"""The 72rem-everywhere contract (phase 100, owner instruction
2026-09-12): .shared-shell is the centered chat column riding the
--chat-column token — equal to the .container's cap, so the shared
page reads at the RAG page's width. The conversation reads exactly
like the chat page's, so the existing .msg/.bubble CSS applies
unchanged."""
css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell"
body = block.group(1)
assert "max-width: var(--chat-column)" in body, (
"the PLAN §7 centered chat column (phase 58 token)"
"the centered chat column riding the token (the 72rem width)"
)
assert "46rem" not in body.split("/*")[0], (
"no hard-coded cap — the width rides the token"
+5 -2
View File
@@ -499,7 +499,8 @@ def test_dialog_button_and_target_contrast_pairs() -> None:
brand-soft hover (12.4:1); Save is the solid brand family (--bg
text on --brand 5.2:1, the .new-chat-btn convention) with the
lightened hover; the error line is the err pair; the panel caps
at the 46rem chat-column width or the viewport; the visible
at the chat-column width (the --chat-column token — 72rem, owner
instruction 2026-09-12) or the viewport; the visible
label is ink-soft (5.1:1) — never a label-less textarea."""
css = _css()
btn = _css_rule(css, ".ignore-editor-btn")
@@ -517,7 +518,9 @@ def test_dialog_button_and_target_contrast_pairs() -> None:
err = _css_rule(css, ".ignore-editor-error")
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = _css_rule(css, ".ignore-editor-panel")
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
assert "min(var(--chat-column)" in panel, (
"the chat-column cap via the token (or the viewport)"
)
label = _css_rule(css, ".ignore-editor-label")
assert "display: block" in label and "var(--ink-soft)" in label, (
"a visible block label (WCAG — never aria-label-only)"
+122 -107
View File
@@ -1,29 +1,33 @@
"""Unit: the 2x reading column on wide desktops (phase 58, task 01).
"""Unit: every page matches the RAG page's 72rem width (phase 100).
The measured-width browser proof is E2E-gated by the phase-58 story
suite (task 02); like the other frontend-adjacent unit files (the
The measured-width browser proof is E2E-gated by the phase's width
suite (task 03); like the other frontend-adjacent unit files (the
test_save_chat_ui.py pattern), this module pins the styles.css markers
the wide-column contract depends on, so a silent regression is caught
the width contract depends on, so a silent regression is caught
without a browser:
* the ``--chat-column`` custom property in ``:root`` — 46rem base
(the PLAN §7 column lineage) with the provenance comment (owner
instruction 2026-08-31, TODO L5 / D2);
* the ``@media (min-width: 1500px)`` block at the bottom of the
responsive region — the SINGLE place that doubles the token to
92rem (2x);
* the ``--chat-column`` custom property in ``:root`` — 72rem, EQUAL to
the ``.container``'s 72rem cap (owner instruction 2026-09-12:
"match the width of the RAG page for all other pages" — supersedes
the 2026-08-31 instruction), with the provenance comment;
* NO ``@media (min-width: 1500px)`` block anywhere in the file — the
phase-58 wide-desktop doubling is deleted in full (the token is
72rem at every viewport);
* the four reading-column selectors — ``.chat-shell``,
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token (exactly four rules);
* the negative pin — the form columns (``.tuning-shell``; and from
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
surfaces) are the only literal ``max-width: 46rem`` rules left in
the file, kept hard-coded so the wide-desktop doubling never
stretches a form;
* the "46rem column contract" block comments were updated to name the
base value + the wide override (the stale "≤46rem" contract claims
are gone from the reading-column comments).
in the file uses the token for a max-width (exactly four rules);
* the flipped negative pin — ZERO literal ``max-width: 46rem`` rules
remain (the phase-27/59/91 form-column caps are retired): the form
shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``)
carry no cap, no token, and no ``margin-inline`` — structurally
``.sources-shell``;
* the two source-page dialogs (phase 69 remove-confirm, phase 89
ignore-editor) cap at the chat-column width THROUGH THE TOKEN —
``min(var(--chat-column), calc(100vw - 2rem))`` — or the viewport;
* the stale width comments are gone — no "46rem base" / "92rem at
>=1500px" / "1500px" width claims remain (the "46rem column
contract" wording was rewritten to the 72rem contract, 2026-09-12).
"""
from __future__ import annotations
@@ -58,57 +62,52 @@ def _rule_block(css: str, selector: str) -> str:
# ---------- the --chat-column token ----------
def test_root_declares_chat_column_46rem_base() -> None:
""":root declares --chat-column: 46rem (the PLAN §7 base) with the
owner-provenance comment (instruction 2026-08-31, TODO L5)."""
def test_root_declares_chat_column_72rem_equal_to_the_container() -> None:
""":root declares --chat-column: 72rem — EQUAL to the .container's
72rem cap (one width for everything) — with the owner-provenance
comment (instruction 2026-09-12)."""
css = _css()
root = _rule_block(css, ":root")
assert "--chat-column: 46rem" in root, (
":root must declare the --chat-column base (46rem)"
assert "--chat-column: 72rem" in root, (
":root must declare the --chat-column width (72rem)"
)
pre = css[: css.index("--chat-column: 46rem")]
# The token equals the container's cap (the RAG page's width).
container = _rule_block(css, ".container")
assert "max-width: 72rem" in container, (
".container's 72rem cap is the width the token now equals"
)
pre = css[: css.index("--chat-column: 72rem")]
comment = pre[pre.rindex("/*") : pre.rindex("*/")]
assert "owner instruction 2026-08-31" in comment, (
assert "owner instruction 2026-09-12" in comment, (
"the token's comment must cite the owner instruction "
"(2026-08-31, TODO L5)"
"(2026-09-12 — match the width of the RAG page)"
)
def test_wide_media_block_doubles_the_token() -> None:
"""A @media (min-width: 1500px) block sets --chat-column: 92rem on
:root — the single wide override (2x the base)."""
def test_the_wide_desktop_doubling_is_retired() -> None:
"""No @media (min-width: 1500px) block remains (the phase-58 2x
override is deleted in full, 2026-09-12) and the token is never
assigned the old wide value — 72rem at every viewport."""
css = _css()
m = re.search(r"@media \(min-width: 1500px\) \{", css)
assert m, "styles.css must carry the @media (min-width: 1500px) block"
start = css.index("{", m.start())
depth = 0
for i in range(start, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
block = css[m.start() : i + 1]
break
else:
raise AssertionError("unbalanced braces in the wide media block")
assert ":root { --chat-column: 92rem; }" in block, (
"the wide block must set :root { --chat-column: 92rem; }"
assert "@media (min-width: 1500px)" not in css, (
"the wide-desktop media block must be deleted (retired 2026-09-12)"
)
assert "--chat-column: 92rem" not in css, (
"the token is never doubled to the old wide value"
)
# The wide block is the ONLY min-width:1500 media in the file and
# the only place 92rem is assigned to the token.
assert css.count("@media (min-width: 1500px)") == 1
assert css.count("--chat-column: 92rem") == 1
def test_wide_block_lives_in_the_bottom_responsive_region() -> None:
"""The min-width sibling sits alongside the max-width responsive
blocks at the bottom of the file (after the <=640px block)."""
def test_no_literal_46rem_width_remains() -> None:
"""Flipped negative pin (phase 100 D1): ZERO literal
max-width: 46rem rules remain in the file — the phase-27/59/91
form-column caps are retired, and the dialog panels ride the
token, not a 46rem literal."""
css = _css()
wide = css.index("@media (min-width: 1500px)")
mobile = css.rindex("@media (max-width: 640px)")
assert wide > mobile, (
"the wide override belongs in the bottom media-query region"
assert css.count("max-width: 46rem") == 0, (
"no literal max-width: 46rem rule may remain (retired 2026-09-12)"
)
assert "min(46rem" not in css, (
"the dialog panels must ride the token, not a 46rem literal"
)
@@ -119,7 +118,7 @@ def test_the_four_reading_columns_use_the_token() -> None:
""".chat-shell, .shared-shell, .doc-md and
.doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those four rules use
the token (no other selector)."""
the token for a max-width (no other selector)."""
css = _css()
for selector in (
".chat-shell",
@@ -135,73 +134,89 @@ def test_the_four_reading_columns_use_the_token() -> None:
)
def test_shared_shell_keeps_the_centered_column_comment() -> None:
""".shared-shell's inline comment keeps the "centered chat column"
wording and notes the wide override (task 01 work item)."""
css = _css()
rule = css[css.index(".shared-shell {") : css.index(".shared-shell {") + 400]
assert "the PLAN §7 centered chat column" in rule
assert "92rem at >=1500px" in rule, "the comment must note the wide override"
def test_doc_md_keeps_width_100_under_the_cap() -> None:
""".doc-md stays width:100% under the token cap (the modal's
1100px panel remains its effective ceiling there)."""
1100px panel remains its effective ceiling there — phase 100 D2)."""
assert "width: 100%" in _rule_block(_css(), ".doc-md")
# ---------- the negative pins ----------
# ---------- the form shells + the dialog panels ----------
def test_tuning_shell_stays_hardcoded_46rem() -> None:
""".tuning-shell (the form column, out of scope) keeps its
hard-coded max-width: 46rem at every width — it never widens."""
def test_the_form_shells_ride_the_full_container() -> None:
"""The form shells (.tuning-shell — phase 27, .theme-shell —
phase 91, .doc-edit-shell — phase 59) carry NO cap, NO token, and
NO margin-inline: the .container ancestor centers them, so they
ride the 72rem frame like .sources-shell (flex column,
gap 1.25rem, flex: 1 — the owner instruction 2026-09-12 retires
the form-column caps)."""
css = _css()
tuning = _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in tuning, (
".tuning-shell must stay hard-coded 46rem (negative pin)"
for selector in (".tuning-shell", ".theme-shell", ".doc-edit-shell"):
block = _rule_block(css, selector)
assert "max-width" not in block, (
f"{selector} must carry no cap (retired 2026-09-12)"
)
assert "var(--chat-column)" not in tuning, (
".tuning-shell must NOT reference the reading-column token"
assert "var(--chat-column)" not in block, (
f"{selector} rides the .container frame, not the token"
)
assert "margin-inline" not in block, (
f"{selector} is centered by the .container ancestor"
)
for decl in (
"display: flex",
"flex-direction: column",
"gap: 1.25rem",
"flex: 1",
):
assert decl in block, (
f"{selector} keeps the .sources-shell shape ({decl})"
)
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27),
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form), and .theme-shell (phase 91 task 04 — the
admin Theme editor is a form column too: the palette grid +
fieldsets must never ride the wide-desktop doubling). Every
reading column rides the token (the --chat-column base
declaration is the other non-rule occurrence of 46rem)."""
def test_the_dialog_panels_ride_the_token() -> None:
"""The two source-page dialogs (phase 69 remove-confirm, phase 89
ignore-editor) cap at the chat-column width THROUGH THE TOKEN —
min(var(--chat-column), calc(100vw - 2rem)) — or the viewport,
whichever is narrower (the 46rem literals ride no more)."""
css = _css()
assert css.count("max-width: 46rem") == 3, (
"only the form columns (.tuning-shell, .doc-edit-shell, "
".theme-shell) may keep a literal max-width: 46rem"
for selector in (".remove-confirm-panel", ".ignore-editor-panel"):
block = _rule_block(css, selector)
assert "min(var(--chat-column), calc(100vw - 2rem))" in block, (
f"{selector} must ride the token (the chat-column width "
"or the viewport)"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None:
"""The block comments that claimed the "46rem column contract" now
name base 46rem + the 2x wide override, with the owner
instruction (2026-08-31, TODO L5) as the provenance at the token
and the media block."""
# ---------- the comment contract ----------
def test_comments_carry_the_72rem_contract() -> None:
"""The stale width claims are gone from the file: no "≤46rem",
"46rem base", "92rem at >=1500px" or "1500px" wording (the
2026-08-31 contract was rewritten to the 72rem contract), with the
owner instruction (2026-09-12) as the provenance at the token and
the reading-column comments naming 72rem."""
css = _css()
# The stale "≤46rem" contract claims are gone from the file.
assert "≤46rem" not in css, (
"the stale '≤46rem' contract wording must be updated"
)
# Provenance at the two authoritative spots (token + wide block).
token_idx = css.index("--chat-column: 46rem")
wide_idx = css.index("@media (min-width: 1500px)")
assert "owner instruction 2026-08-31" in css[max(0, token_idx - 400) : token_idx]
assert "owner instruction 2026-08-31" in css[max(0, wide_idx - 500) : wide_idx]
# The chat-shell comment names base + override.
assert "46rem base" not in css, "no stale '46rem base' claim may remain"
assert "92rem at >=1500px" not in css, (
"no stale wide-override claim may remain"
)
assert "1500px" not in css, (
"the wide-desktop breakpoint wording is retired (2026-09-12)"
)
# Provenance at the authoritative spot (the token).
token_idx = css.index("--chat-column: 72rem")
assert "owner instruction 2026-09-12" in css[max(0, token_idx - 400) : token_idx]
# The chat-shell + shared-shell comments name the 72rem contract.
chat_comment = css[: css.index(".chat-shell {")]
assert "46rem base" in chat_comment and "92rem" in chat_comment
assert "72rem" in chat_comment, (
"the chat-shell comment must name the 72rem contract"
)
shared_idx = css.index(".shared-shell {")
shared_rule = css[shared_idx : shared_idx + 400]
assert "72rem" in shared_rule, (
"the shared-shell comment must name the 72rem contract"
)