diff --git a/changelog/329.internal.rst b/changelog/329.internal.rst new file mode 100644 index 0000000..ca26f3d --- /dev/null +++ b/changelog/329.internal.rst @@ -0,0 +1,6 @@ +Planned the reference quadrant's card landing page in six tasks, from the +specification of :pull:`327`. Planning found that the landing-page gate lets an +entry reach outside its section with ``..``, and the plan closes it. The plan's +code was run from the plan's own text before it was proposed, which caught a +line the linter rejects and a mutation predicted to fail fewer checks than it +does. (:user:`claude`) diff --git a/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md b/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md new file mode 100644 index 0000000..9ded65e --- /dev/null +++ b/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md @@ -0,0 +1,1270 @@ +# Reference Cards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The reference quadrant's landing page stops listing its pages in a sentence and becomes a grid of seven icon cards, held to its hidden toctree by the same gate that holds the other sections' tables. + +**Architecture:** No new extension and no build-time machinery. `tests/test_docs_landing_pages.py` gains a second index shape — `CARD_SECTIONS` beside `TABLE_SECTIONS` — read from each card's `:link:` option, with the API page (which exists only while a build runs) derived by executing `conf.py`. The page is hand-authored reStructuredText using sphinx-design's grid, the icons are hand-authored SVG in the root page's vocabulary, and the root page's card classes are renamed so both pages share one layout rule. + +**Tech Stack:** reStructuredText, Sphinx 9.1 with pydata-sphinx-theme, sphinx-design 0.7.0, sphinx-tippy, hand-authored SVG, pytest, pixi, pre-commit. Playwright 1.62.0 for local render checks only — nothing in CI drives a browser for this. + +**Spec:** [`../specs/2026-08-27-narrative-quadrants-design.md`](../specs/2026-08-27-narrative-quadrants-design.md) §3.9, the amendment of 2026-09-15 — cited below as `narrative spec §3.9`. Read it alongside this plan; every task argues from it. + +## Global Constraints + +- Every `.py` file carries the BSD copyright header (ruff `CPY001`). Every SVG carries the same notice as an XML comment above ``, as `docs/src/_static/cards/*.svg` already do. +- Every pull request adds `changelog/..rst` ending with ``(:user:`claude`)``, named once the pull request exists — a number cannot be reserved. +- A GitHub reference is written ``:issue:`N``` or ``:pull:`N``` in reStructuredText, CSS and Python, and ``{issue}`N``` in Markdown — never a bare `#N`, never a hand-written URL. +- **Citations are bare, never inside double backticks, and never split from their prefix by a line break.** Outside a specification always write the prefix: `narrative spec §3.9`, not `§3.9`. +- `pixi run docs` must build clean; the build is fail-on-warning. +- Verify **after** committing, never before: the pre-commit hooks rewrite files. +- Palette, exactly: navy `#1B3A6B` on light and `#8FB8E8` on dark; accent `#E4572E` on both; knock-out halo `#FFFFFF` on light and `#14181e` on dark. +- The grid is `.. grid:: 1 2 2 2` with `:gutter: 2`; the API card is first and carries `:columns: 12`. +- Card classes: `teph-card` on the card, `teph-card-icon` on each image. `teph-quadrant-button` belongs to the topics page and is **not** renamed. +- No `:term:` inside a card. First mentions belong in the prose. +- A card raises no hover tooltip (`sd-stretched-link` is in `tippy_skip_anchor_classes`). + +--- + +## What This Plan Measured Before It Was Written + +Established 2026-09-15 on `main` at `e3c1efc` and on the specification branch at `b47b843`, by building throwaway layouts in a worktree, rendering them in Chromium, and discarding them. + +**1. Two columns split words on a phone; `1 2 2 2` with a full-width API card does not.** Four layouts of seven cards were built into the real theme and rendered at 360, 600 and 1280px. `.. grid:: 2` — the root page's, two columns at every breakpoint — gave cards about 150px wide at 360px and split "Configuratio / n" in a title and "documentatio / n" in a sentence. `.. grid:: 1 2 2 2` stacked one column at 360px with no split, gave two balanced columns at 600px, and with `:columns: 12` on the first card filled three even rows below it. Three columns squeezed each sentence to four lines. Every layout of seven equal cards left one alone on the last row. + +**2. A card has no background of its own.** Walking up from `.sd-card` in the built root page, the first opaque background is ``: `rgb(255, 255, 255)` in light and `rgb(20, 24, 30)` — `#14181e` — in dark, with `data-theme` confirmed to have switched. So an icon's knock-out halo must be `#14181e` in dark to vanish. + +**3. The root page's dark halo leaves a ring.** Rendered on `#14181e`, the halo `#20242b` shows as a grey ring round every dot; `#14181e` shows none. Only the *Tutorials* pair carries a halo at all: `grep -c -E "#20242b|#FFFFFF"` over `docs/src/_static/cards/*.svg` counts one in `tutorials-light.svg`, one in `tutorials-dark.svg`, and zero in the other six. + +**4. Seven icon drafts were rendered at 56 and 96px on both grounds, and two failed before the third attempt.** The glossary drawn as an orange pill read as a hyperlink icon; a borderless label box vanished on white; a label box with a thin navy border reads as a label on a line in both themes. The changelog with a faint rail read as a bullet list; a solid rail reads as a timeline. Task 3 carries the chosen drawings. + +**5. A card raises no tooltip, although its tip is generated.** The built tooltip payload for a card page carries `a[href="cli.html"]` and the rest — but `tippy_skip_anchor_classes` includes `sd-stretched-link`, the class every card link carries, and the extension applies it at runtime. Reading the payload and concluding the card is tipped is the trap `tooltip spec §3.3` records. Task 4 hovers a card on the real page, with a table link as the positive control. + +**6. The API page is never on disk outside a build.** `docs/src/reference/generated/` does not exist after `make html`: `autoapi_keep_files = False`, and `.gitignore:73` ignores the directory besides. Page discovery cannot find it; the gate must derive it. + +**7. `conf.py` executes in the `test` environment.** Its top level imports only `datetime`, `importlib.metadata`, `pathlib` and `sys`, and reads `tephpy`'s installed version. `tests/test_docs_whatsnew.py` already executes it through `load_path`. + +**8. The own-section check lets `..` through.** `(DOCS / "reference" / "../howtos/units.rst").is_file()` is `True` today, so an entry reaching into another section passes `test_every_row_links_to_a_page_in_its_own_quadrant`. Task 1 closes it. + +**9. Renaming the four live tests breaks nothing.** Their names appear only in `tests/test_docs_landing_pages.py` and in two frozen plans. + +**10. Nothing gates an SVG's header, and no test reads `docs-style.rst`'s *Landing Pages* text.** The scripts that name `docs-style.rst` point at other sections of it. + +**11. The root page's own word split is not this plan's.** At 360px its four cards are 148px wide with a 114px text column under `overflow-wrap: break-word`, and "Understanding-" splits. Filed as {issue}`328`. + +--- + +### Task 1: The gate reads cards + +Teaches `tests/test_docs_landing_pages.py` the second index shape without any section taking it yet, and closes the `..` hole it would otherwise inherit (`narrative spec §3.9`, *The gate holds the cards as it holds the tables*). + +**Files:** +- Modify: `tests/test_docs_landing_pages.py` + +**Interfaces:** +- Consumes: `load_path(name: str, path: Path) -> ModuleType` from `tests/by_path.py`; `conf.autoapi_root` and `conf.autoapi_dirs` from `docs/src/conf.py`. +- Produces: `CARD_SECTIONS: tuple[str, ...]`, `CARD: str`, `card_targets(source: str) -> list[str | None]`, `generated_pages(section: str) -> list[str]`, `index_targets(section: str, source: str) -> list[str | None]`; `pages(quadrant, docs=DOCS)` keeps its signature. `toctree_entries` is unchanged — `tests/test_docs_whatsnew.py` imports it. + +- [ ] **Step 1: Write the failing tests** + +Append after `test_a_subdirectory_without_a_landing_page_gives_up_its_documents`: + +```python +def test_card_targets_reads_each_cards_link_in_order(): + source = ( + " .. grid-item-card:: API\n" + " :link: generated/api/tephpy/index\n" + " :link-type: doc\n" + " :columns: 12\n\n" + " Generated.\n\n" + " .. grid-item-card:: Command Line\n" + " :link-type: doc\n" + " :link: cli\n\n" + " What to type.\n" + ) + assert card_targets(source) == ["generated/api/tephpy/index", "cli"] + + +def test_card_targets_reports_a_card_that_links_nowhere(): + """Reported rather than skipped, as a table row is.""" + source = ( + " .. grid-item-card:: Glossary\n" + " :class-card: teph-card sd-rounded-3\n\n" + " .. image:: glossary-light.svg\n" + " :class: only-light teph-card-icon\n" + ) + assert card_targets(source) == [None] + + +def test_card_targets_reads_only_the_cards_own_options(): + """A ``:link:`` after the option block is body text, not the card's link.""" + source = ( + " .. grid-item-card:: Command Line\n" + " :link-type: doc\n\n" + " :link: cli\n" + ) + assert card_targets(source) == [None] + + +def test_the_api_page_is_derived_for_the_section_autoapi_writes_into(): + """It exists only while a build runs, so discovery cannot find it.""" + assert generated_pages("reference") == ["generated/api/tephpy/index"] + assert generated_pages("howtos") == [] + + +def test_discovery_passes_over_a_generated_tree_a_build_left_behind(tmp_path): + """A stale autoapi tree must not add pages the index is then asked to list.""" + section = tmp_path / "reference" + (section / "generated" / "api" / "tephpy").mkdir(parents=True) + (section / "index.rst").touch() + (section / "cli.rst").touch() + (section / "generated" / "api" / "index.rst").touch() + (section / "generated" / "api" / "tephpy" / "index.rst").touch() + assert pages("reference", docs=tmp_path) == ["cli"] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov` +Expected: the three `card_targets` tests and the `generated_pages` test FAIL with `NameError`; `test_discovery_passes_over_a_generated_tree_a_build_left_behind` FAILS with `['cli', 'generated/api/index'] == ['cli']`. Every existing test still passes. + +- [ ] **Step 3: Change the module docstring and the imports** + +Replace the module docstring and import block with: + +```python +"""A landing page's index and its toctree are one list (narrative spec §3.9).""" + +from __future__ import annotations + +from functools import cache +from pathlib import Path, PurePosixPath +import re + +import pytest + +from tests.by_path import load_path + +REPO = Path(__file__).parents[1] +DOCS = REPO / "docs" / "src" +CONF = DOCS / "conf.py" +``` + +- [ ] **Step 4: Add `CARD_SECTIONS` and `CARD`** + +Replace the last paragraph of the `TABLE_SECTIONS` comment — the one beginning *The reference quadrant is out, decided rather than deferred* — with: + +```python +#: The reference quadrant takes cards instead (`CARD_SECTIONS`): its pages are +#: looked up by name, and a card answers that with an icon where a row would offer a +#: choice nobody makes (narrative spec §3.9). +``` + +Immediately after the `TABLE_SECTIONS = (...)` line, add: + +```python +#: The sections whose landing page carries a grid of cards (narrative spec §3.9). +#: Empty until the reference page takes the shape: every check below reads the live +#: page, so a section joins in the commit that gives it the cards. +CARD_SECTIONS: tuple[str, ...] = () + +#: The directive a card is written with. A card's own options are the ``:name:`` +#: lines directly under it, and the first line that is not one ends them. +CARD = ".. grid-item-card::" +``` + +- [ ] **Step 5: Teach discovery to pass over autoapi's output** + +Replace `_entries` and `pages` with: + +```python +@cache +def _autoapi() -> tuple[PurePosixPath, str]: + """Return where autoapi writes the API pages, and the package it documents. + + Read by executing `conf.py`, as `tests/test_docs_whatsnew.py` reads + ``rst_epilog``: a text scan would pass on a value sitting in a comment. Cached, + because every section's discovery asks and the answer cannot change in a run. + + Returns + ------- + tuple of (PurePosixPath, str) + ``autoapi_root``, relative to the documentation source, and the name of the + package directory ``autoapi_dirs`` names. + + """ + conf = load_path("tephpy_docs_conf", CONF) + return PurePosixPath(conf.autoapi_root), PurePosixPath(conf.autoapi_dirs[0]).name + + +def generated_pages(section: str) -> list[str]: + """Return the pages a section offers that exist only while a build runs. + + autoapi writes the API reference under ``autoapi_root`` during a build and, with + ``autoapi_keep_files = False``, removes it afterwards; the directory is + git-ignored besides. Discovery cannot see a page that is not on disk, so the one + entry a section's index gives the API is derived instead (narrative spec §3.9). + + Parameters + ---------- + section : str + The section's directory name under the documentation source. + + Returns + ------- + list of str + ``["generated/api/tephpy/index"]`` for the section ``autoapi_root`` sits in, + and nothing for any other. + + """ + root, package = _autoapi() + if root.parts[0] != section: + return [] + return [str(PurePosixPath(*root.parts[1:], package, "index"))] + + +def _entries(directory: Path, skip: Path) -> list[Path]: + """Return the documents one section offers, one path per destination. + + A subdirectory carrying its own ``index.rst`` is a subsection: it + contributes that landing page and **nothing beneath it**, because from the + parent's index it is a single destination however many documents sit inside + it -- the specification collection is one row, not twenty. A subdirectory + without one is a plain grouping, and its documents belong to the parent. + Recursion stops at a landing page rather than pruning by name, so a + subsection nested two deep behaves the same as one nested one deep + (narrative spec §3.9). + + ``skip`` is autoapi's output directory, passed over wherever a build left it: + its one page is counted by `generated_pages` instead, and a stale tree would + otherwise add entries no index names. + + Parameters + ---------- + directory : Path + The section directory to read. + skip : Path + The directory autoapi writes into. + + Returns + ------- + list of Path + One path per destination: a document, or a subsection's landing page. + + """ + found: list[Path] = [] + for path in directory.iterdir(): + if path == skip: + continue + if path.is_dir(): + landing_page = path / "index.rst" + if landing_page.is_file(): + found.append(landing_page) + else: + found.extend(_entries(path, skip)) + elif path.suffix == ".rst" and path.name != "index.rst": + found.append(path) + return found + + +def pages(quadrant: str, docs: Path = DOCS) -> list[str]: + """Return every page on disk in a section, as its landing index would name it. + + A `:doc:` target on a landing page is relative to the section, so that is + what these are made relative to. The section's own ``index.rst`` is a landing + page rather than an entry in one and is left out; a subsection's is both, and + counts as one entry of its parent. A page only a build writes is not on disk, + and `generated_pages` supplies it. + + Parameters + ---------- + quadrant : str + The section's directory name under ``docs``. + docs : Path, optional + The documentation source root. + + Returns + ------- + list of str + The section's pages, sorted. + + """ + root = docs / quadrant + skip = docs / _autoapi()[0] + return sorted( + path.relative_to(root).with_suffix("").as_posix() + for path in _entries(root, skip) + ) +``` + +- [ ] **Step 6: Add the card reader** + +Immediately after `table_targets`, add: + +```python +def card_targets(source: str) -> list[str | None]: + """Return the documents a page's landing cards link to, in card order. + + Only a card's own option block is read -- the ``:name: value`` lines directly + under ``.. grid-item-card::`` -- so an option of an image nested in the card, or + a field in its body, is not mistaken for the card's link. + + Parameters + ---------- + source : str + The reStructuredText source of one page. + + Returns + ------- + list of str or None + Each card's ``:link:``, or ``None`` for a card carrying none -- reported + rather than skipped, so a card that links nowhere fails the page instead of + shrinking the list silently. + + """ + lines = source.splitlines() + found: list[str | None] = [] + for index, line in enumerate(lines): + if not line.strip().startswith(CARD): + continue + link = None + for option in lines[index + 1 :]: + stripped = option.strip() + if not stripped.startswith(":"): + break + name, _, value = stripped[1:].partition(":") + if name == "link": + link = value.strip() + found.append(link) + return found + + +def index_targets(section: str, source: str) -> list[str | None]: + """Return a landing page's index, read in the shape its section takes. + + Parameters + ---------- + section : str + The section's directory name. + source : str + The reStructuredText source of its landing page. + + Returns + ------- + list of str or None + What `card_targets` reads for a card section, and `table_targets` otherwise. + + """ + return card_targets(source) if section in CARD_SECTIONS else table_targets(source) +``` + +- [ ] **Step 7: Generalise the live checks** + +Replace everything from `def test_every_section_this_gate_governs_is_on_disk` to the end of the file with: + +```python +def test_every_section_this_gate_governs_is_on_disk(): + """A gate that finds nothing passes by never having looked.""" + for section in TABLE_SECTIONS + CARD_SECTIONS: + assert (DOCS / section).is_dir(), f"{section} is missing" + + +def test_a_section_takes_one_shape(): + """Two constants naming one section would put two indexes on its page.""" + assert not set(TABLE_SECTIONS) & set(CARD_SECTIONS) + + +@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS) +def test_a_landing_page_carries_one_index(section): + """A table page carries no cards, and a card page no table (narrative spec §3.9).""" + other = ".. list-table::" if section in CARD_SECTIONS else CARD + assert other not in landing(section) + + +@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS) +def test_the_index_and_the_toctree_are_one_ordered_list(section): + """Narrative spec §3.9: the visible index and the navigation are one list. + + Sequence and not set. The toctree is hidden, which hides it from the page body + and from nothing else: the sidebar, the breadcrumb and the previous/next footer + all read its order, so an index ordered differently would disagree with the + navigation drawn around it. + """ + source = landing(section) + assert index_targets(section, source) == toctree_entries(source) + + +@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS) +def test_every_entry_links_to_a_page_in_its_own_section(section): + """A target is a page of the section, or the one page a build generates there. + + ``..`` is refused outright: ``DOCS / section / "../howtos/units.rst"`` names a + file that exists, so without it an entry pointing into another section would + pass as a page of this one. + """ + generated = generated_pages(section) + for target in index_targets(section, landing(section)): + assert target is not None, f"{section} has an entry that links nowhere" + assert ".." not in PurePosixPath(target).parts, ( + f"{section}'s index links to {target}, outside the section" + ) + assert target in generated or (DOCS / section / f"{target}.rst").is_file(), ( + f"{section}'s index links to {target}, which is not a page in it" + ) + + +@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS) +def test_the_index_lists_every_page_in_the_section(section): + """The index is the section's index, so it indexes the section. + + The ordered comparison above holds the index and the toctree to each other and + would not notice a page missing from both, which is how a page goes unlisted: + one commit that adds a page and neither list. The fail-on-warning build catches + the ordinary case -- Sphinx reports a document in no toctree -- but not an + `:orphan:` page, which builds clean and would sit in the section unreachable + from its own landing page. + """ + listed = sorted( + target for target in index_targets(section, landing(section)) if target + ) + assert listed == sorted(pages(section) + generated_pages(section)) + + +@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS) +def test_the_toctree_is_hidden(section): + """Narrative spec §3.9: the index is the visible one, and it is the only one. + + Without this the page renders the same list twice, the index and the toctree + under it, which is the duplication the shape exists to remove. + """ + assert ":hidden:" in toctree_options(landing(section)) +``` + +- [ ] **Step 8: Run the gate and its one importer** + +Run: `pixi run -e test pytest tests/test_docs_landing_pages.py tests/test_docs_whatsnew.py -q --no-cov` +Expected: PASS. `CARD_SECTIONS` is empty, so every live check runs over the five table sections exactly as before, under its new name. + +- [ ] **Step 9: Prove the `..` refusal by mutation** + +```bash +sed -i 's|:doc:`units`|:doc:`../howtos/units`|' docs/src/howtos/index.rst +pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k "own_section and howtos" +git checkout -- docs/src/howtos/index.rst +``` + +Expected: `test_every_entry_links_to_a_page_in_its_own_section[howtos]` FAILS with `howtos's index links to ../howtos/units, outside the section`. Then confirm the tree is clean: `git status --short docs/` prints nothing. + +- [ ] **Step 10: Lint, commit, and verify after the commit** + +```bash +pixi run -e devs pre-commit run --files tests/test_docs_landing_pages.py +git add tests/test_docs_landing_pages.py +git commit -m "Teach the landing-page gate to read cards" +pixi run -e test pytest tests/test_docs_landing_pages.py tests/test_docs_whatsnew.py -q --no-cov +``` + +Expected: every hook passes; the tests pass again on the committed tree. + +--- + +### Task 2: Rename the card classes + +Behaviour-preserving. The classes stop saying *quadrant* before a page whose cards are not quadrants uses them (`narrative spec §3.9`, §4's 2026-09-15 companion changes). + +**Files:** +- Modify: `docs/src/index.rst` (four `:class-card:` lines, eight `:class:` lines) +- Modify: `docs/src/_static/tephpy.css` (two selectors) + +**Interfaces:** +- Consumes: nothing. +- Produces: `teph-card` and `teph-card-icon`, which Task 4's page uses. + +- [ ] **Step 1: Write the layout probe** + +Create `/tmp/measure_cards.py` — outside the repository; it is a check, not a file this plan commits: + +```python +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +page_path = Path(sys.argv[1]).resolve().as_uri() +probe = """() => [...document.querySelectorAll('article.bd-article .sd-card')].map(card => + Math.round([...card.querySelectorAll('.sd-card-body p')].pop().getBoundingClientRect().width))""" +with sync_playwright() as p: + browser = p.chromium.launch() + for width in (360, 576, 1280): + page = browser.new_page(viewport={"width": width, "height": 800}) + page.goto(page_path) + page.wait_for_load_state("networkidle") + print(width, page.evaluate(probe)) + page.close() + browser.close() +``` + +Where Chromium cannot find its system libraries, supply them through `LD_LIBRARY_PATH`; Playwright's own error names the missing ones. + +- [ ] **Step 2: Measure the root page before** + +```bash +pixi run docs-html +pixi run -e docs python /tmp/measure_cards.py docs/_build/html/index.html +``` + +Expected: `360 [114, 114, 114, 114]` and `576 [154, 154, 154, 154]`. Record the 1280 line exactly. The 576 line is the one that discriminates: if the icon rule stops matching, the icon falls back above the text and the column widens to 222px. + +- [ ] **Step 3: Rename** + +```bash +sed -i -e 's/teph-quadrant-icon/teph-card-icon/g' \ + -e 's/:class-card: teph-quadrant sd-rounded-3/:class-card: teph-card sd-rounded-3/' \ + docs/src/index.rst +sed -i -e 's/^\.teph-quadrant-icon {$/.teph-card-icon {/' \ + -e 's/^ \.teph-quadrant \.sd-card-body {$/ .teph-card .sd-card-body {/' \ + -e 's/^ \.teph-quadrant-icon {$/ .teph-card-icon {/' \ + docs/src/_static/tephpy.css +``` + +- [ ] **Step 4: Check nothing was missed and nothing extra was touched** + +```bash +git grep -n -E "teph-quadrant(-icon)?\b" -- docs/src | grep -v teph-quadrant-button +git grep -c "teph-quadrant-button" -- docs/src +git grep -c -E "teph-card(-icon)?\b" -- docs/src/index.rst docs/src/_static/tephpy.css +``` + +Expected: the first prints nothing. The second still counts `docs/src/_ext/tephpy_topics.py:1`, `docs/src/_static/tephpy.css:4`, `docs/src/_static/topics.js:1`. The third counts `docs/src/index.rst:12` and `docs/src/_static/tephpy.css:3`. + +- [ ] **Step 5: Measure the root page after** + +```bash +pixi run docs-html +pixi run -e docs python /tmp/measure_cards.py docs/_build/html/index.html +``` + +Expected: all three lines identical to Step 2. + +- [ ] **Step 6: Commit** + +```bash +git add docs/src/index.rst docs/src/_static/tephpy.css +git commit -m "Name the landing cards' classes for cards, not quadrants" +``` + +--- + +### Task 3: The seven icons, and the Tutorials halo + +**Files:** +- Create: `docs/src/_static/cards/reference/{api,cli,config,glossary,references,whatsnew,changelog}-{light,dark}.svg` +- Modify: `docs/src/_static/cards/tutorials-dark.svg` (one colour) + +**Interfaces:** +- Consumes: nothing. +- Produces: fourteen files at the paths above, with the `aria-label` texts below, which Task 4's `:alt:` options repeat verbatim. + +| file stem | `aria-label` and `:alt:` | +|---|---| +| `api` | a module tree, one entry found | +| `cli` | a shell prompt, its cursor marked | +| `config` | two options on their scales, one of them set | +| `glossary` | a line, and the label that names it | +| `references` | a printed page, its source marked | +| `whatsnew` | a spark, marking what is new | +| `changelog` | a run of entries, the newest marked | + +- [ ] **Step 1: Write the seven light drawings** + +Each file opens with this notice, exactly: + +```xml + +``` + +followed by its ``. The lattice group is the root page's, copied exactly. + +`docs/src/_static/cards/reference/api-light.svg`: + +```xml + + a module tree, one entry found + + + + + + + + + + +``` + +`docs/src/_static/cards/reference/cli-light.svg`: + +```xml + + a shell prompt, its cursor marked + + + + + + + +``` + +`docs/src/_static/cards/reference/config-light.svg`: + +```xml + + two options on their scales, one of them set + + + + + + + + + + +``` + +`docs/src/_static/cards/reference/glossary-light.svg`: + +```xml + + a line, and the label that names it + + + + + + + + +``` + +`docs/src/_static/cards/reference/references-light.svg`: + +```xml + + a printed page, its source marked + + + + + + + + +``` + +`docs/src/_static/cards/reference/whatsnew-light.svg`: + +```xml + + a spark, marking what is new + + + + + + + +``` + +`docs/src/_static/cards/reference/changelog-light.svg`: + +```xml + + a run of entries, the newest marked + + + + + + + + + + + + +``` + +- [ ] **Step 2: Derive the dark twins** + +The dark file is the light file with two colours swapped and nothing else (`narrative spec §3.9`): + +```bash +cd docs/src/_static/cards/reference +for stem in api cli config glossary references whatsnew changelog; do + sed -e 's/#1B3A6B/#8FB8E8/g' -e 's/#FFFFFF/#14181e/g' "$stem-light.svg" > "$stem-dark.svg" +done +cd - +``` + +- [ ] **Step 3: Correct the root page's Tutorials halo** + +```bash +sed -i 's/fill="#20242b"/fill="#14181e"/' docs/src/_static/cards/tutorials-dark.svg +``` + +- [ ] **Step 4: Check the files mechanically** + +```bash +grep -l -E "#1B3A6B|#FFFFFF" docs/src/_static/cards/reference/*-dark.svg +grep -L "#8FB8E8" docs/src/_static/cards/reference/*-dark.svg +grep -rn "#20242b" docs/src/_static/cards +pixi run -e test python -c " +import xml.etree.ElementTree as ET +from pathlib import Path +files = sorted(Path('docs/src/_static/cards/reference').glob('*.svg')) +assert len(files) == 14, len(files) +for f in files: + root = ET.parse(f).getroot() + assert root.get('aria-label'), f +print('14 well-formed, all labelled') +" +``` + +Expected: the three `grep`s print nothing, and the last prints `14 well-formed, all labelled`. + +- [ ] **Step 5: Render the drawings and look at them** + +Create `/tmp/render_icons.py`: + +```python +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +cards = Path("docs/src/_static/cards/reference").resolve() +stems = ["api", "cli", "config", "glossary", "references", "whatsnew", "changelog"] +out = Path(sys.argv[1]).resolve() + + +def cells(theme): + return "".join( + f'
' + f'' + f"
{s}
" + for s in stems + ) + + +html = ( + "" + f'
{cells("light")}
' + f'
{cells("dark")}
' +) +sheet = out.with_suffix(".html") +sheet.write_text(html, encoding="utf-8") +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1000, "height": 400}) + page.goto(sheet.as_uri()) + page.wait_for_load_state("networkidle") + page.screenshot(path=str(out), full_page=True) + browser.close() +print("wrote", out) +``` + +Run: `pixi run -e docs python /tmp/render_icons.py /tmp/reference-icons.png`, and open the image. +Expected, checked by eye at 56px on both grounds: each drawing reads as its label; exactly one orange element per icon; no grey ring round any dot or the glossary's label box on the dark ground. If a drawing fails, redraw it here and record why in the pull request — do not carry a failed drawing into Task 4. + +- [ ] **Step 6: Commit** + +```bash +git add docs/src/_static/cards/reference docs/src/_static/cards/tutorials-dark.svg +git commit -m "Draw the reference cards' icons, and knock out the Tutorials halo" +``` + +--- + +### Task 4: The reference landing page + +**Files:** +- Modify: `docs/src/reference/index.rst` (whole file) +- Modify: `tests/test_docs_landing_pages.py` (`CARD_SECTIONS`) +- Modify: `docs/src/_static/tephpy.css` (two comments) + +**Interfaces:** +- Consumes: `CARD_SECTIONS`, `card_targets`, `generated_pages` (Task 1); `teph-card`, `teph-card-icon` (Task 2); the fourteen icons and their labels (Task 3). +- Produces: the page `tests/test_docs_snippets.py::test_the_reference_index_sends_a_caller_to_the_hierarchy` reads, which Task 5 touches. + +- [ ] **Step 1: Put the reference quadrant under the gate** + +In `tests/test_docs_landing_pages.py`, replace the `CARD_SECTIONS` block with: + +```python +#: The sections whose landing page carries a grid of cards (narrative spec §3.9). +CARD_SECTIONS: tuple[str, ...] = ("reference",) +``` + +- [ ] **Step 2: Run the gate to verify it fails** + +Run: `pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov` +Expected: exactly three FAIL, all `[reference]` — `test_the_index_and_the_toctree_are_one_ordered_list` (`[] == [...seven entries...]`), `test_the_index_lists_every_page_in_the_section`, and `test_the_toctree_is_hidden`. `test_every_entry_links_to_a_page_in_its_own_section[reference]` passes vacuously, because the page has no cards yet — that is why Step 5 mutates it. + +- [ ] **Step 3: Write the page** + +Replace the whole of `docs/src/reference/index.rst` with: + +```rst +Reference +========= + +.. The cards are the visible index and the toctree below is the navigation; the two + are one list, held by tests/test_docs_landing_pages.py (narrative spec §3.9). + + Each icon is drawn in the root page's vocabulary, as that page's comment sets it + out: API, a module tree with one entry found; Command Line, a shell prompt at its + cursor; Configuration Options, two options on their scales with one set; + Glossary, a line and the label that names it; References, a printed page with its + source marked; What's New, a spark; Changelog, a run of entries with the newest + marked. Light and dark differ only in the navy and the knock-out halo, and live in + _static/cards/reference/. + +The factual material, for looking things up rather than reading through. + +.. grid:: 1 2 2 2 + :gutter: 2 + + .. grid-item-card:: API + :link: generated/api/tephpy/index + :link-type: doc + :class-card: teph-card sd-rounded-3 + :columns: 12 + + .. image:: ../_static/cards/reference/api-light.svg + :class: only-light teph-card-icon + :alt: a module tree, one entry found + + .. image:: ../_static/cards/reference/api-dark.svg + :class: only-dark teph-card-icon + :alt: a module tree, one entry found + + Generated from the source, so it describes the version you have installed. + + .. grid-item-card:: Command Line + :link: cli + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/cli-light.svg + :class: only-light teph-card-icon + :alt: a shell prompt, its cursor marked + + .. image:: ../_static/cards/reference/cli-dark.svg + :class: only-dark teph-card-icon + :alt: a shell prompt, its cursor marked + + What to type at a shell. + + .. grid-item-card:: Configuration Options + :link: config + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/config-light.svg + :class: only-light teph-card-icon + :alt: two options on their scales, one of them set + + .. image:: ../_static/cards/reference/config-dark.svg + :class: only-dark teph-card-icon + :alt: two options on their scales, one of them set + + What to set, in Python or in a file. + + .. grid-item-card:: Glossary + :link: glossary + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/glossary-light.svg + :class: only-light teph-card-icon + :alt: a line, and the label that names it + + .. image:: ../_static/cards/reference/glossary-dark.svg + :class: only-dark teph-card-icon + :alt: a line, and the label that names it + + What a word means here, and where the API carries it. + + .. grid-item-card:: References + :link: references + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/references-light.svg + :class: only-light teph-card-icon + :alt: a printed page, its source marked + + .. image:: ../_static/cards/reference/references-dark.svg + :class: only-dark teph-card-icon + :alt: a printed page, its source marked + + Where a convention or definition came from. + + .. grid-item-card:: What's New + :link: whatsnew/index + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/whatsnew-light.svg + :class: only-light teph-card-icon + :alt: a spark, marking what is new + + .. image:: ../_static/cards/reference/whatsnew-dark.svg + :class: only-dark teph-card-icon + :alt: a spark, marking what is new + + A release, in the few things worth knowing. + + .. grid-item-card:: Changelog + :link: changelog + :link-type: doc + :class-card: teph-card sd-rounded-3 + + .. image:: ../_static/cards/reference/changelog-light.svg + :class: only-light teph-card-icon + :alt: a run of entries, the newest marked + + .. image:: ../_static/cards/reference/changelog-dark.svg + :class: only-dark teph-card-icon + :alt: a run of entries, the newest marked + + A release, one entry per pull request. + +If you are deciding what to catch, read :mod:`tephpy.exceptions`. What +``tephpy`` raises about your data — its units, its physical consistency, the +source it came from, the configuration file in force — derives from +:class:`TephpyError `, so one ``except`` clause +covers that subject, and the module sets out the narrower classes for when it +is too broad. Ordinary argument mistakes stay outside the hierarchy and raise +the builtin exceptions instead. + +The glossary is worth knowing about before you need it. ``tephpy``'s audience is +scientific software engineers rather than meteorologists, so each entry gives the +concept in one plain sentence and then says how it appears in the package — the +data it involves, its units, and the API that carries it. + +.. toctree:: + :hidden: + :maxdepth: 1 + + generated/api/tephpy/index + cli + config + glossary + references + whatsnew/index + changelog +``` + +`:maxdepth: 1` stays because the toctree it sits on is unchanged apart from hiding it — the sidebar reads the same directive. + +- [ ] **Step 4: Run the gate and the signpost test** + +Run: `pixi run -e test pytest tests/test_docs_landing_pages.py "tests/test_docs_snippets.py::test_the_reference_index_sends_a_caller_to_the_hierarchy" -q --no-cov` +Expected: PASS. + +- [ ] **Step 5: Prove each check by mutation** + +Run each block from a clean tree, read the failure, and restore before the next. + +```bash +# (a) order: swap What's New and Changelog in the toctree only +sed -i -e 's|^ whatsnew/index$| SWAP|' -e 's|^ changelog$| whatsnew/index|' -e 's|^ SWAP$| changelog|' docs/src/reference/index.rst +pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference +git checkout -- docs/src/reference/index.rst +``` +Expected: `test_the_index_and_the_toctree_are_one_ordered_list[reference]` FAILS. + +```bash +# (b) reach outside the section +sed -i 's|^ :link: cli$| :link: ../howtos/units|' docs/src/reference/index.rst +pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference +git checkout -- docs/src/reference/index.rst +``` +Expected: three FAIL, all `[reference]`. `test_every_entry_links_to_a_page_in_its_own_section` fails with `reference's index links to ../howtos/units, outside the section` — the check this mutation is for. `test_the_index_and_the_toctree_are_one_ordered_list` fails because the card no longer matches the toctree's `cli`, and `test_the_index_lists_every_page_in_the_section` because `cli` is no longer listed. + +```bash +# (c) an orphan page neither list names +printf ':orphan:\n\nStray\n=====\n' > docs/src/reference/stray.rst +pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference +rm docs/src/reference/stray.rst +``` +Expected: `test_the_index_lists_every_page_in_the_section[reference]` FAILS, naming `stray`. + +```bash +# (d) the toctree shown +sed -i '/^ :hidden:$/d' docs/src/reference/index.rst +pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference +git checkout -- docs/src/reference/index.rst +``` +Expected: `test_the_toctree_is_hidden[reference]` FAILS. + +```bash +# (e) a missing icon, against the build rather than the gate +mv docs/src/_static/cards/reference/api-light.svg /tmp/api-light.svg +pixi run docs-html; echo "exit=$?" +mv /tmp/api-light.svg docs/src/_static/cards/reference/api-light.svg +``` +Expected: the fail-on-warning build exits non-zero, naming `api-light.svg`. **If it exits 0, do not add a gate:** record in the pull request that a missing icon builds clean, per `narrative spec §3.9` (presentation is ungated). + +Finish with `git status --short` printing only this task's intended changes. + +- [ ] **Step 6: Update the stylesheet's comments** + +In `docs/src/_static/tephpy.css`, replace the comment line + +```css + * The four Diátaxis quadrant cards on the landing page. +``` + +with + +```css + * The landing pages' cards: the root page's four Diátaxis quadrants, and the + * reference quadrant's seven (narrative spec §3.9). +``` + +and in the comment above `@media (min-width: 576px)`, replace + +```css + * `.. grid:: 2` is two columns at *every* breakpoint -- the emitted row carries +``` + +with + +```css + * The root page's `.. grid:: 2` is two columns at *every* breakpoint -- the + * emitted row carries +``` + +then, immediately before that comment's closing ` */`, add: + +```css + * + * The reference quadrant's `.. grid:: 1 2 2 2` is one column below 576px, so its + * cards never meet that width. Above the icon, the root page's longest segment + * still splits at 360px (:issue:`328`). +``` + +- [ ] **Step 7: Build, render, and hover** + +Create `/tmp/render_reference.py`: + +```python +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +html = Path("docs/_build/html").resolve() +out = Path(sys.argv[1]).resolve() +out.mkdir(parents=True, exist_ok=True) +with sync_playwright() as p: + browser = p.chromium.launch() + for scheme in ("light", "dark"): + for width in (360, 600, 1280): + page = browser.new_page(viewport={"width": width, "height": 900}, color_scheme=scheme) + page.goto((html / "reference" / "index.html").as_uri()) + page.wait_for_load_state("networkidle") + page.locator("article.bd-article").screenshot(path=str(out / f"{scheme}-{width}.png")) + page.close() + for label, path, selector in ( + ("a card", "reference/index.html", ".sd-card:has-text('Command Line')"), + ("a table link (control)", "howtos/index.html", "article.bd-article table a.reference.internal"), + ): + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto((html / path).as_uri()) + page.wait_for_load_state("networkidle") + page.locator(selector).first.hover() + page.wait_for_timeout(800) + print(label, "raised", page.locator("[data-tippy-root]").count(), "tooltips") + page.close() + browser.close() +``` + +```bash +pixi run docs +pixi run -e docs python /tmp/render_reference.py /tmp/reference-page +``` + +Expected: `pixi run docs` passes every gate. The script prints `a card raised 0 tooltips` and `a table link (control) raised 1 tooltips` — if the control raises 0, the probe is broken and the card's 0 means nothing; fix the probe before believing it. By eye, in all six images: no word split mid-word; one column at 360px; the API card spanning both columns at 600 and 1280px; three even rows beneath it; no ring round any dot on the dark ground. + +- [ ] **Step 8: Commit, and verify after the commit** + +```bash +git add docs/src/reference/index.rst tests/test_docs_landing_pages.py docs/src/_static/tephpy.css +git commit -m "Give the reference quadrant a landing page of cards" +pixi run -e test pytest tests/test_docs_landing_pages.py tests/test_docs_snippets.py tests/test_citations.py tests/test_github_references.py -q --no-cov +``` + +Expected: every hook passes, and the tests pass on the committed tree. + +--- + +### Task 5: The style guide's rule, and the signpost test's docstring + +The rule a page author reads, rewritten for two shapes; it loses two stale claims on the way (`narrative spec §3.9`, §4's 2026-09-15 companion changes). + +**Files:** +- Modify: `docs/src/developer/docs-style.rst` (the *Landing Pages* section) +- Modify: `tests/test_docs_snippets.py` (one docstring) + +**Interfaces:** +- Consumes: the page and the gate as Tasks 1–4 left them. +- Produces: nothing a later task reads. + +- [ ] **Step 1: Rewrite the section** + +In `docs/src/developer/docs-style.rst`, replace everything from the `Landing Pages` heading up to, but not including, the `Topic Tags` heading with: + +```rst +Landing Pages +------------- + +A section landing page is navigation rather than prose, which is the rule above read +forwards: it carries no reading-time banner because nobody reads it through. Six +sections take one of two shapes, and both end in a hidden ``toctree``. + +**A table**, in :doc:`getting started <../start/index>`, the tutorials, how-to and +explanation quadrants, and this developer guide. The page carries, in order, an +introduction, one two-column ``list-table``, and the ``toctree``. + +The introduction says what the section is for, who it assumes the reader is, what +it guarantees of every page in it, and where to go if this is the wrong section. +It says nothing about an individual page. A paragraph that summarises the section +page by page is a list that has to track a directory, and the how-to page's grew +from six clauses to nine by hand before this rule existed. + +Each row is a ``:doc:`` link against one sentence. The sentence is editorial rather +than descriptive: it is there so a reader can tell this page from its siblings, and +it is deliberately not the page's opening line, which a hover already shows. Write +``:widths: auto`` and no header row, which is the shape the API reference's own +summary tables already take. + +**A grid of cards**, in the reference quadrant, whose pages are looked up by name +rather than chosen between. The page carries, in order, a one-sentence +introduction, a ``.. grid:: 1 2 2 2`` of cards, the guidance paragraphs, and the +``toctree``. The API card comes first and spans the row with ``:columns: 12``. One +column below 576px is not a matter of taste: two columns at that width split words +mid-word, which narrative spec §3.9 records measuring. + +Each card takes its page's title — the API card excepted, because that page is +titled by the package name — a light and a dark icon from +``_static/cards/reference/``, and one sentence that tells it from the card beside +it. A card raises no hover tooltip, so its sentence may say what the page opens +with where that is clearest. Draw an icon in the root page's vocabulary; its dark +file differs from the light one only in the navy, ``#8FB8E8`` for ``#1B3A6B``, and +the knock-out halo, ``#14181e`` for ``#FFFFFF``. + +The index and the toctree carry the same pages in the same order, and it is the +order a reader needs rather than the alphabet. Hiding a toctree hides it from the +page body only: the sidebar, the breadcrumb and the previous and next footer all +read its order. ``tests/test_docs_landing_pages.py`` fails when the two disagree, +in membership or in order; when an entry links outside its section; when the index +omits a page the section holds, which an ``:orphan:`` page would otherwise do +silently, since the build's own toctree check never sees one; and when the toctree +is not hidden, which would publish the same list twice. It reads a row's first +cell and a card's ``:link:``, and derives the API card's page from ``conf.py``, +since that page exists only while a build runs. + +Glossary terms stay out of the cells and the cards. Both are directives, and +:ref:`the first-mention rule ` already passes over a directive's +body, so a ``:term:`` in either neither satisfies that rule nor breaks it. Write +first mentions in the prose, and let a cell or a card take the plain word. +``` + +- [ ] **Step 2: Correct the signpost test's docstring** + +In `tests/test_docs_snippets.py`, replace the docstring of `test_the_reference_index_sends_a_caller_to_the_hierarchy` with: + +```python + """The quadrant's own landing page otherwise omits the subject entirely. + + Its cards name what the reference holds, and none of them is the exception + hierarchy, so without this paragraph a reader deciding what to catch has + nothing to follow (:issue:`213`). + """ +``` + +- [ ] **Step 3: Lint and test** + +```bash +pixi run -e devs pre-commit run --files docs/src/developer/docs-style.rst tests/test_docs_snippets.py +pixi run -e test pytest "tests/test_docs_snippets.py::test_the_reference_index_sends_a_caller_to_the_hierarchy" tests/test_citations.py -q --no-cov +``` + +Expected: every hook passes, including `design specification citations resolve`; the tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add docs/src/developer/docs-style.rst tests/test_docs_snippets.py +git commit -m "Write the card shape into the landing-page rule" +``` + +--- + +### Task 6: Verify the branch and open the pull request + +**Files:** +- Create: `changelog/.documentation.rst` + +**Interfaces:** +- Consumes: everything above. +- Produces: the pull request. + +- [ ] **Step 1: Run every gate on the committed tree** + +```bash +git status --short +pixi run tests +pixi run lint +pixi run docs +``` + +Expected: `git status` prints nothing; the suite passes, and its count is higher than `main`'s by the new unit tests and the `[reference]` parametrisations; every hook passes; `pixi run docs` builds clean with every gate passing. + +- [ ] **Step 2: Push and open the pull request** + +Open it against `main` with the labels `type: documentation` and `type: testing`. Its description says what changed, what Step 7 of Task 4 printed for the two hovers, which way mutation (e) of Task 4 went, and any drawing redrawn in Task 3 and why. Attach the six renders from `/tmp/reference-page` and the icon sheet from Task 3. + +- [ ] **Step 3: Add the fragment, named for the pull request** + +Create `changelog/.documentation.rst`: + +```rst +The reference quadrant's landing page is now a grid of seven cards, each with an +icon drawn in the root page's vocabulary, in place of an introduction that listed +its pages by hand (narrative spec §3.9). ``tests/test_docs_landing_pages.py`` +holds the cards to the page's hidden toctree as it holds the other sections' +tables, and now refuses an entry that reaches outside its section with ``..``. +The root page's cards share the renamed ``teph-card`` classes, and its dark +*Tutorials* icon no longer rings its accent on the dark ground. (:user:`claude`) +``` + +```bash +git add changelog/.documentation.rst +git commit -m "Add the changelog fragment" +git push +``` + +Expected: the hooks pass, and `pixi run changelog --version 0.1.0 --draft` shows the entry under Documentation without writing or deleting anything.