Render the manual as one HTML page the application can carry

The manual existed only as docs/manual/README.md, which the forge renders
and nothing else does. An installed copy of the application, on a laptop
with no network or on a tablet, had no manual it could open.

`traces manual` renders the README to docs/manual/index.html with
pulldown-cmark (already in the tree as Slint's Markdown parser, so this
adds a dependency edge and no crate). The page is one file with an inline
stylesheet that follows the system's light or dark preference, a
contents list of every section and subsection, and the pictures by their
relative media/ paths. Each heading carries the id the forge gives it, so
README.md#rating-and-flagging and index.html#rating-and-flagging are the
same link. A picture alone in its paragraph becomes a figure whose alt
text is shown as the caption, and every picture reserves its 16:11 box
before it loads, so a jump into the middle of the page lands where it
aimed rather than a screenful above. Links to design documents, which the
installed page has no copy of, point at the forge.

The page is committed rather than rendered at build time, as the gesture
book is: it is user-facing text reviewed in the diff, and the three
packagers then only copy it. `traces manual-check` fails in CI when the
committed page is not the render of the README, and the pre-commit hook
regenerates it when the README is staged.
This commit is contained in:
2026-09-24 22:32:30 -04:00
parent d8e031888e
commit 10216355c1
10 changed files with 872 additions and 2 deletions
+34
View File
@@ -6,6 +6,8 @@
//! traces check # gate: non-zero exit on failure
//! traces gestures # write docs/gestures.md and the app's gesture table
//! traces gestures-check # gate: non-zero exit if either has drifted
//! traces manual # write docs/manual/index.html from its README
//! traces manual-check # gate: non-zero exit if the page has drifted
//! ```
//!
//! The gesture half scans a different tag out of the same files — see
@@ -59,6 +61,12 @@ const GESTURE_TABLE: &str = "ui/dr-ui/src/gesture_book.rs";
/// its own examples is a scanner nobody can document.
const GESTURE_ROOTS: &[&str] = &["ui", "apps"];
/// The manual's source, and the page rendered from it that the application
/// carries. Committed and gated like the gesture book — see
/// [`traceability::manual`] for why it is not rendered at build time.
const MANUAL_SOURCE: &str = "docs/manual/README.md";
const MANUAL_PAGE: &str = "docs/manual/index.html";
fn main() -> Result<()> {
let base = repo_root()?;
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
@@ -66,6 +74,9 @@ fn main() -> Result<()> {
if mode.starts_with("gestures") {
return run_gestures(&base, mode == "gestures-check");
}
if mode.starts_with("manual") {
return run_manual(&base, mode == "manual-check");
}
let req_path = base.join("docs/dev/requirements.md");
let markdown = std::fs::read_to_string(&req_path)
@@ -191,6 +202,29 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
Ok(())
}
/// Render the manual to its page, or check the committed page is that render.
fn run_manual(base: &Path, check: bool) -> Result<()> {
let source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
.with_context(|| format!("reading {MANUAL_SOURCE}"))?;
let page = manual::render_html(&source);
let heads = manual::headings(&source);
println!("headings {}", heads.len());
if heads.is_empty() {
bail!("no headings parsed from {MANUAL_SOURCE} — misconfigured, not an empty manual");
}
if check {
let have = std::fs::read_to_string(base.join(MANUAL_PAGE)).unwrap_or_default();
if have != page {
bail!("{MANUAL_PAGE} is stale — run: cargo run -p traceability -- manual");
}
println!("\nmanual gate: PASS");
return Ok(());
}
std::fs::write(base.join(MANUAL_PAGE), &page)?;
println!("\nwrote {MANUAL_PAGE}");
Ok(())
}
/// Structural checks that fail regardless of the coverage threshold.
fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> {
// A run that parsed nothing is misconfigured, not passing.