From 5c52d62d00549f0a11d9fbe0ae6f1f023aba2459 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 10 Oct 2026 16:17:46 +0200 Subject: [PATCH] feat: sphinx: a preview of the linked need When the pointer stays on a link in a need card, and the link goes to another need card, a popup shows a copy of that card: the head, then the fields, then the text. The script fetches the target page in the browser, once per page, so the link can go to another document too. need_cards adds zdocs-need-preview.js to each page; zdocs-needs.css styles the popup. Only a link that goes to a need gets a popup: need_cards gives it the class zd-need-ref, so a link to a Doxygen page does not fetch that page. The popup also opens on keyboard focus, and Escape or a scroll of the page closes it. A device without hover gets no popups. A page opened as file:// cannot fetch other pages, so there only the needs on the same page get a preview. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 3 +- doc/manual/reference/directives-and-roles.rst | 19 ++ .../_tests/roots/test-needcards/index.rst | 3 + sphinx/_extensions/_tests/test_need_cards.py | 32 ++- sphinx/_extensions/need_cards.py | 37 +++- sphinx/_static/zdocs-need-preview.js | 189 ++++++++++++++++++ sphinx/_static/zdocs-needs.css | 38 ++++ 7 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 sphinx/_static/zdocs-need-preview.js diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index c53eec1..ee6bfb8 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -85,7 +85,8 @@ Re-reads a document when its sphinx-needs TOML file or the needs it imports chan ``need_cards`` ---------------- -The type colors and the status and verdict classes of the zdocs need cards. +The type colors, the status and verdict classes and the previews of the zdocs +need cards. .. automodule:: need_cards :members: diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 1363901..a51fc09 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -559,6 +559,25 @@ The ``need_cards`` extension (loaded by ``zdocs_conf``) gives each card the class ``zd-status-`` / ``zd-verdict-`` that colors the pill. A type without a color stays grey. +``need_cards`` also adds ``zdocs-need-preview.js`` to each page. When the +pointer stays on a link in a card, and the link goes to another need card, a +popup shows a copy of that card: the head, then the fields, then the text. The +link can go to a page of this document or of another one. The script fetches +the target page when the popup first opens and keeps it for the next link to +the same page. Only a link that goes to a need gets a popup: ``need_cards`` +gives it the class ``zd-need-ref`` (its ``#fragment`` is the id of a need), so +a link to a Doxygen page (Source, See also) does not make the script fetch +that page. The copy has the type colors of the page that shows it, so all +documents of a project must use the same ``needs_config.toml``. They do so +already for the needs that they import from each other +(``ZDOCS_NEEDS_CONFIG``, :doc:`consumer-contract`). + +The popup also opens on keyboard focus. Escape and a scroll of the page close +it. A link in the popup gets no popup of its own. A device without hover (a +touch screen) gets no popups. A browser does not let a page opened as +``file://`` fetch other pages, so there only the links to needs on the same +page get a popup; serve the HTML over HTTP to see all previews. + The engine puts the references of a generated need (Depends on, Source, See also, Declared in) in a ``need-refs`` container: the card shows them as one block under the text. diff --git a/sphinx/_extensions/_tests/roots/test-needcards/index.rst b/sphinx/_extensions/_tests/roots/test-needcards/index.rst index e90225c..c14e23f 100644 --- a/sphinx/_extensions/_tests/roots/test-needcards/index.rst +++ b/sphinx/_extensions/_tests/roots/test-needcards/index.rst @@ -8,3 +8,6 @@ Need cards .. spec:: A test case :id: SPEC_1 :status: Passed / OK + :links: REQ_1 + + Source: `main.c `_ diff --git a/sphinx/_extensions/_tests/test_need_cards.py b/sphinx/_extensions/_tests/test_need_cards.py index f30ff61..af9ef27 100644 --- a/sphinx/_extensions/_tests/test_need_cards.py +++ b/sphinx/_extensions/_tests/test_need_cards.py @@ -7,7 +7,14 @@ from pathlib import Path import pytest -from need_cards import CSS_NAME, status_class, type_colors_css +from need_cards import ( + _JS_SOURCE, + CSS_NAME, + JS_NAME, + NEED_REF_CLASS, + status_class, + type_colors_css, +) _ROOT = str(Path(__file__).parent / "roots" / "test-needcards") @@ -43,6 +50,14 @@ def test_the_page_links_the_css_and_the_build_writes_it(app): assert "needs_type_spec" not in css +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_the_page_loads_the_preview_script(app): + # The root has no zdocs_conf, so no zdocs _static in html_static_path. + app.build() + assert f'src="_static/{JS_NAME}' in (Path(app.outdir) / "index.html").read_text() + assert (Path(app.outdir) / "_static" / JS_NAME).read_text() == _JS_SOURCE.read_text() + + @pytest.mark.parametrize( "status, cls", [("passed", "zd-status-passed"), ("Draft", "zd-status-draft"), @@ -65,3 +80,18 @@ def test_each_need_table_gets_its_status_class(app): assert "zd-status-draft" in req assert "zd-status-passed-ok" in spec assert "zd-verdict-" not in req # no verdict field: no class + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_only_a_link_to_a_need_gets_the_need_ref_class(app): + app.build() + html = (Path(app.outdir) / "index.html").read_text() + spec = html[html.index('id="SPEC_1"'):] + + def link(href_part): + end = spec.index(href_part) + return spec[spec.rfind("`` and ``zd-verdict-`` to each need table that has the field (``zd-status-passed``, ``zd-verdict-partial``), so that the card can color the pill. The need data does not change. + +**A preview of the linked need.** The extension adds ``zdocs-need-preview.js`` +to each page. When the pointer stays on a link in a card, and the link goes +to another card, a popup shows a copy of that card. The script fetches the +target page in the browser, so it needs a page served over HTTP; a page +opened as ``file://`` gets previews of the needs on the same page only. """ import re @@ -28,6 +34,8 @@ from docutils import nodes CSS_NAME = "zdocs-need-types.css" +JS_NAME = "zdocs-need-preview.js" +_JS_SOURCE = Path(__file__).resolve().parents[1] / "_static" / JS_NAME #: A CSS color as needs_config.toml gives it: #rgb(a) / #rrggbb(aa) or a name. _COLOR = re.compile(r"^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|[A-Za-z]+)$") @@ -48,6 +56,11 @@ def type_colors_css(types): return "\n".join(rules) + "\n" +#: The class of a link in a card that goes to a need: the preview script +#: takes only these links, and so fetches no page that has no card (a +#: Doxygen source listing or API page). +NEED_REF_CLASS = "zd-need-ref" + #: The fields that a card shows as a pill: the engine's status and verdict. PILL_FIELDS = ("status", "verdict") @@ -75,6 +88,15 @@ def _add_status_classes(app, doctree, docname): cls = status_class(need.get(field), field) if cls and cls not in table["classes"]: table["classes"].append(cls) + _mark_need_refs(table, needs) + + +def _mark_need_refs(table, needs): + """Give each link in ``table`` whose fragment is a need id `NEED_REF_CLASS`.""" + for ref in table.findall(nodes.reference): + fragment = ref.get("refid") or ref.get("refuri", "").partition("#")[2] + if fragment and fragment in needs and NEED_REF_CLASS not in ref["classes"]: + ref["classes"].append(NEED_REF_CLASS) def _write(app, exception): @@ -88,17 +110,24 @@ def _write(app, exception): path = static / CSS_NAME if not path.exists() or path.read_text() != css: path.write_text(css) + # zdocs_conf has the zdocs _static in html_static_path, so the file is + # there already; a document without zdocs_conf gets it here. + js = _JS_SOURCE.read_text() + path = static / JS_NAME + if not path.exists() or path.read_text() != js: + path.write_text(js) -def _add_css(app): +def _add_files(app): if app.builder.format == "html": app.add_css_file(CSS_NAME) + app.add_js_file(JS_NAME) def setup(app): - app.connect("builder-inited", _add_css) - # After sphinx-needs replaced each need node with its table - # (process_need_nodes, doctree-resolved at the default priority 500). + app.connect("builder-inited", _add_files) + # After sphinx-needs replaced each need node with its table and resolved + # the links in it (doctree-resolved at the default priority 500). app.connect("doctree-resolved", _add_status_classes, priority=600) # After the HTML builder copied the static files, which it does at the end. app.connect("build-finished", _write) diff --git a/sphinx/_static/zdocs-need-preview.js b/sphinx/_static/zdocs-need-preview.js new file mode 100644 index 0000000..f5e1ca0 --- /dev/null +++ b/sphinx/_static/zdocs-need-preview.js @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 inovex GmbH + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * A preview of the linked need: when the pointer stays on a link in a need + * card, and the link goes to another need card, a popup shows a copy of that + * card. The target page is fetched once and kept. A page opened as file:// + * cannot fetch other pages; there only needs on the same page get a preview. + * The need_cards extension gives the links to a need the class zd-need-ref; + * the other links (Doxygen pages) get no popup, so no fetch. + * + * The popup is styled in zdocs-needs.css (.zd-preview). The need_cards + * extension adds this file to each page. + */ +(() => { + "use strict"; + + const SHOW_DELAY = 300; // ms the pointer stays on a link before the popup opens + const HIDE_DELAY = 250; // ms to move the pointer from the link into the popup + // need_cards gives this class to the links that go to a need. + const LINKS = "table.need a.zd-need-ref"; + + const pages = new Map(); // page URL -> Promise + let popup = null; + let link = null; // the link the popup is for, or will be for + let showTimer = 0; + let hideTimer = 0; + + function pageUrl(url) { + const u = new URL(url, location.href); + u.hash = ""; + return u.href; + } + + function load(page) { + if (page === pageUrl(location.href)) return Promise.resolve(document); + if (!pages.has(page)) { + pages.set( + page, + fetch(page) + .then((r) => (r.ok ? r.text() : Promise.reject(r.status))) + .then((text) => new DOMParser().parseFromString(text, "text/html")) + .catch(() => null), + ); + } + return pages.get(page); + } + + // A copy of the card with the id `id` in `doc`, or null if `id` is no card. + function card(doc, id, page) { + const found = doc.getElementById(id); + if (!found || !found.matches("table.need")) return null; + const copy = found.cloneNode(true); + // The page has the ids already; the copy keeps none. + copy.removeAttribute("id"); + copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); + if (doc !== document) { + // Relative to the target page, not to this one. + copy.querySelectorAll("a[href]").forEach((a) => { + a.href = new URL(a.getAttribute("href"), page).href; + }); + copy.querySelectorAll("img[src]").forEach((img) => { + img.src = new URL(img.getAttribute("src"), page).href; + }); + } + return copy; + } + + function target(a) { + const url = new URL(a.getAttribute("href"), location.href); + const id = decodeURIComponent(url.hash.slice(1)); + if (!id) return null; + // The id link of a card goes to the card itself. + const own = a.closest("table.need"); + if (own && own.id === id && pageUrl(url.href) === pageUrl(location.href)) return null; + return { page: pageUrl(url.href), id }; + } + + function ensurePopup() { + if (popup) return popup; + popup = document.createElement("div"); + popup.className = "zd-preview"; + popup.setAttribute("role", "tooltip"); + popup.hidden = true; + popup.addEventListener("mouseenter", () => clearTimeout(hideTimer)); + popup.addEventListener("mouseleave", scheduleHide); + // In the content area: the card rules of zdocs-needs.css start there. + (document.querySelector(".rst-content") || document.body).appendChild(popup); + return popup; + } + + function place(a) { + const gap = 6; + const margin = 16; + const r = a.getBoundingClientRect(); + const p = popup.getBoundingClientRect(); + const below = window.innerHeight - r.bottom - gap - margin; + const above = r.top - gap - margin; + const top = p.height <= below || below >= above ? r.bottom + gap : r.top - gap - p.height; + const left = Math.min(Math.max(margin, r.left), window.innerWidth - margin - p.width); + popup.style.top = `${Math.max(margin, top)}px`; + popup.style.left = `${Math.max(margin, left)}px`; + } + + function show(a) { + const t = target(a); + if (!t) return; + load(t.page).then((doc) => { + if (link !== a || !doc) return; // the pointer has gone, or no fetch + const copy = card(doc, t.id, t.page); + if (!copy) return; + ensurePopup(); + popup.replaceChildren(copy); + popup.scrollTop = 0; + popup.style.top = "0px"; + popup.style.left = "0px"; + popup.hidden = false; + place(a); + }); + } + + function hide() { + clearTimeout(showTimer); + link = null; + if (popup) popup.hidden = true; + } + + function scheduleHide() { + clearTimeout(showTimer); + clearTimeout(hideTimer); + hideTimer = setTimeout(hide, HIDE_DELAY); + } + + function enter(a) { + clearTimeout(hideTimer); + if (a === link) return; + clearTimeout(showTimer); + if (popup) popup.hidden = true; + link = a; + showTimer = setTimeout(() => show(a), SHOW_DELAY); + } + + // A link in a card, but not one in the popup: no preview of a preview. + function previewLink(el) { + const a = el instanceof Element ? el.closest(LINKS) : null; + return a && !a.closest(".zd-preview") ? a : null; + } + + function init() { + document.addEventListener("mouseover", (e) => { + const a = previewLink(e.target); + if (a) enter(a); + }); + document.addEventListener("mouseout", (e) => { + const a = previewLink(e.target); + if (a && !a.contains(e.relatedTarget)) scheduleHide(); + }); + document.addEventListener("focusin", (e) => { + const a = previewLink(e.target); + if (a) enter(a); + }); + document.addEventListener("focusout", (e) => { + if (previewLink(e.target)) scheduleHide(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") hide(); + }); + // The popup is fixed; a scroll of the page moves the link away from it. + document.addEventListener( + "scroll", + (e) => { + if (popup && !popup.hidden && !popup.contains(e.target)) hide(); + }, + true, + ); + } + + // Only with a pointer that can hover; a tap follows the link. + if (window.matchMedia("(hover: hover)").matches) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + } +})(); diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css index 1c4df39..dea4a26 100644 --- a/sphinx/_static/zdocs-needs.css +++ b/sphinx/_static/zdocs-needs.css @@ -376,3 +376,41 @@ table.need.need.need[class*=" needs_layout_zdocs"] :is(.zd-adq-ok, .zd-adq-miss, table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-ok { background: #e3f3e6; color: #1e6b33; } table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-miss { background: #fbe3e3; color: #a12828; } table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-none { background: #eceef1; color: #5f6670; } + +/* -- Preview: the popup of zdocs-need-preview.js ----------------------------- */ + +/* + * A copy of the linked need card. The script puts the popup in .rst-content, + * where the card rules start. + */ +.zd-preview { + position: fixed; + z-index: 1000; + width: min(640px, calc(100vw - 32px)); + max-height: min(420px, 60vh); + overflow-y: auto; + overscroll-behavior: contain; + background: #fff; + border-radius: 6px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18), 0 1px 4px rgba(0, 0, 0, 0.12); +} +.zd-preview[hidden] { + display: none; +} +/* The card fills the popup: no margin, and the popup gives the shadow. It + * clips nothing itself, so that the head can stick to the top of the popup. */ +.rst-content .zd-preview table.need.need.need.need { + margin: 0; + box-shadow: none; + overflow: visible; +} +/* A preview is to find out what the need is: the head and the fields first, + * then the text (the card on its page shows the text first). */ +.zd-preview table.need.need.need.need > tbody > tr.meta { order: 1; } +.zd-preview table.need.need.need.need > tbody > tr.content { order: 2; } +/* The head stays in view while the rest scrolls. */ +.zd-preview table.need.need.need.need > tbody > tr.head { + position: sticky; + top: 0; + z-index: 1; +}