Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e9d94cff | ||
|
|
a2ca2f905f |
@@ -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).
|
||||
+96
@@ -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
|
||||
+18
@@ -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`
|
||||
+295
@@ -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)
|
||||
+13
@@ -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)
|
||||
+96
@@ -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
|
||||
+14
@@ -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`
|
||||
+96
@@ -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
|
||||
+18
@@ -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).
|
||||
+96
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,170 +302,206 @@ 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")
|
||||
# (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)
|
||||
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.
|
||||
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10)
|
||||
assert r.status_code == 200
|
||||
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=VIEWPORT_1920)
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
expect(anon.locator("#shared-title")).to_have_text(
|
||||
_auto_title(q), timeout=15_000
|
||||
)
|
||||
expect(anon.locator(".msg.brain .bubble")).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
_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:
|
||||
base.close()
|
||||
page.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)
|
||||
|
||||
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"]
|
||||
anon_ctx = None
|
||||
try:
|
||||
# Public since phase 55 — the share endpoint takes no session.
|
||||
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10)
|
||||
assert r.status_code == 200
|
||||
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 = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
expect(anon.locator("#shared-title")).to_have_text(
|
||||
_auto_title(q), timeout=15_000
|
||||
)
|
||||
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_centered(anon, ".shared-shell", 1920, "shared @ 1920px")
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
+125
-110
@@ -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)"
|
||||
)
|
||||
assert "var(--chat-column)" not in tuning, (
|
||||
".tuning-shell must NOT reference the reading-column token"
|
||||
)
|
||||
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 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"
|
||||
)
|
||||
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")
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user