Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-manual/src/lib.rs

//! 📖 **facett-manual** — the SHARED embedded Manual pane.
//!
//! One documentation pane, consumed by every app that wants its manual a click
//! away inside the GUI (dwarves, korp, …). It renders a set of Markdown documents
//! — a searchable, category-grouped sidebar on the left, the selected document
//! rendered on the right — through [`facett_markdown::render`] (L5: the SAME
//! markdown→egui renderer the ordning editor's Preview lane uses; no second
//! hand-rolled renderer here).
//!
//! ## The embed is the HOST's job, the pane is shared
//! This crate is storage-agnostic: it takes the doc set as **data**
//! ([`ManualDoc`]s). Each host embeds its own `.nornir/*.md` into its binary via
//! `rust_embed` behind a `manual-embed` feature and hands the decoded files to
//! [`ManualPane::from_files`]. So the *mechanism* of baking docs into the binary
//! lives per-app (like nornir's `manual-embed`), while the *pane* is shared — one
//! core, many apps.
//!
//! ## Honest empty state
//! Built with the manual NOT embedded (feature off → the host passes zero docs),
//! the pane says so plainly — "manual not embedded in this build" — it never draws
//! an empty pane as if it were content.
//!
//! ## Why markdown, not nornir's SVG book
//! nornir's `viz::manual_tab` renders a pre-rendered **vector-SVG book**
//! (`facett-docview`, typst → one SVG per page) packed into a `book.znippy`. That
//! path needs the typst/warehouse book pipeline to run first. dwarves' and korp's
//! manuals are their raw `.nornir/*.md` sources (idiot guides, guides, designs),
//! and their warehouses are frequently locked mid-demo — so embedding the markdown
//! sources directly and rendering them live is simpler, warehouse-free, and
//! arguably truer for the idiot guides. This pane is that markdown path.
//!
//! ## Two variants, merged behind one pane (embedded)
//! This pane no longer renders *only* markdown. It also carries a **typeset vector
//! book** and can render EITHER, user-selectable:
//!
//! - **Typeset (SVG)** — the pretty, book-like variant (the **default**), rendered
//!   by [`facett_docview`]'s pure-Rust resvg engine over prebuilt SVG pages.
//! - **Markdown** — the continuous, searchable Markdown flow described above.
//!
//! A header **variant toggle** (see [`Variant`]) switches between them. When built
//! via [`ManualPane::embedded`] the whole manual — both the Markdown sources and the
//! typeset SVG book — is compiled into the binary (`include_str!`/`include_bytes!`,
//! see [`mod@embedded`]), so it ships with **no runtime disk dependency** regardless
//! of whether the host wired up a docview book. There is no live Typst compiler
//! in-tree; the typeset pages are prebuilt SVG (rendered, never shelled out to).

/// Compiled-in manual content (Markdown sources + prebuilt typeset SVG book).
pub mod embedded;

use egui::Ui;
use facett_docview::DocView;
use serde::{Deserialize, Serialize};

/// What kind of document a `.nornir/*.md` file is — derived from its filename, used
/// to GROUP and ORDER the sidebar so the beginner ("for idiots") guides surface
/// first (the ones a live demo is driven from), then readmes, guides, designs, …
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocKind {
    /// A `*-for-idiots.md` / `for-idiots.md` beginner guide — surfaced FIRST.
    ForIdiots,
    /// A top-level `README.md` / `README-full.md`.
    Readme,
    /// A `*-guide.md` / `guide.md` how-to.
    Guide,
    /// A `*-design.md` / `design.md` architecture note.
    Design,
    /// A `*-reasoning.md` / `reasoning.md` rationale.
    Reasoning,
    /// Anything else (plans, histories, specs, …).
    Other,
}

impl DocKind {
    /// The sidebar section header this kind groups under.
    pub fn header(self) -> &'static str {
        match self {
            DocKind::ForIdiots => "🔰 For idiots (start here)",
            DocKind::Readme => "📄 Readme",
            DocKind::Guide => "📘 Guides",
            DocKind::Design => "📐 Design",
            DocKind::Reasoning => "🧠 Reasoning",
            DocKind::Other => "🗂 Other",
        }
    }

    /// Sidebar ordering: beginner guides first, catch-all last.
    fn order(self) -> u8 {
        match self {
            DocKind::ForIdiots => 0,
            DocKind::Readme => 1,
            DocKind::Guide => 2,
            DocKind::Design => 3,
            DocKind::Reasoning => 4,
            DocKind::Other => 5,
        }
    }

    /// The ordered roster of kinds (the sidebar section order).
    fn all() -> [DocKind; 6] {
        [
            DocKind::ForIdiots,
            DocKind::Readme,
            DocKind::Guide,
            DocKind::Design,
            DocKind::Reasoning,
            DocKind::Other,
        ]
    }

    /// Classify by file stem (lower-cased, no extension).
    fn classify(stem: &str) -> DocKind {
        let s = stem.to_ascii_lowercase();
        if s.contains("for-idiots") || s == "for-dummies" || s.ends_with("-for-dummies") {
            DocKind::ForIdiots
        } else if s == "readme" || s == "readme-full" {
            DocKind::Readme
        } else if s == "guide" || s.ends_with("-guide") {
            DocKind::Guide
        } else if s == "design" || s.ends_with("-design") {
            DocKind::Design
        } else if s == "reasoning" || s.ends_with("-reasoning") {
            DocKind::Reasoning
        } else {
            DocKind::Other
        }
    }
}

/// One embedded manual document.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ManualDoc {
    /// Stable id — the file stem (e.g. `infra-for-idiots`). The sidebar key + the
    /// `state_json` selection id + the deep-link target.
    pub slug: String,
    /// Display title — the document's first `# ` heading if it has one, else the
    /// slug prettified (`infra-for-idiots` → `Infra for idiots`).
    pub title: String,
    /// Which sidebar group this doc lives under.
    pub kind: DocKind,
    /// The raw markdown body.
    pub body: String,
}

impl ManualDoc {
    /// Build a doc from a file NAME (e.g. `infra-for-idiots.md`, or a path like
    /// `.nornir/infra-for-idiots.md`) and its UTF-8 body. Derives the slug from the
    /// stem, the kind from the stem, and the title from the first `# ` heading (or
    /// the prettified stem).
    pub fn from_file(name: &str, body: impl Into<String>) -> Self {
        let body = body.into();
        let file = name.rsplit('/').next().unwrap_or(name);
        let stem = file.strip_suffix(".md").or_else(|| file.strip_suffix(".markdown")).unwrap_or(file);
        let kind = DocKind::classify(stem);
        let title = first_heading(&body).unwrap_or_else(|| prettify(stem));
        Self { slug: stem.to_string(), title, kind, body }
    }

    /// Whether a filename is a **template / scaffold** the manual should SKIP —
    /// `.nornir/_TEMPLATE-*.md`, `_TEMPLATES.md`, and dotfile-underscore scaffolds.
    /// (Called by hosts filtering the embedded set; kept here so every host skips
    /// the same non-content files.)
    pub fn is_template(name: &str) -> bool {
        let file = name.rsplit('/').next().unwrap_or(name);
        file.starts_with('_')
    }
}

/// The first `# ` heading's text in `md`, if any (trimmed).
fn first_heading(md: &str) -> Option<String> {
    for line in md.lines() {
        let t = line.trim_start();
        if let Some(rest) = t.strip_prefix("# ") {
            let h = rest.trim();
            if !h.is_empty() {
                return Some(h.to_string());
            }
        }
    }
    None
}

/// `infra-for-idiots` → `Infra for idiots` (first letter upper, `-`/`_` → space).
fn prettify(stem: &str) -> String {
    let spaced: String = stem.chars().map(|c| if c == '-' || c == '_' { ' ' } else { c }).collect();
    let mut chars = spaced.chars();
    match chars.next() {
        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
        None => spaced,
    }
}

/// How the reading area presents the doc set.
///
/// - [`ViewMode::Paged`] — the classic behavior: ONE doc at a time, chosen from the
///   sidebar (least surprise; the default).
/// - [`ViewMode::Continuous`] — every (search-passing) doc concatenated top-to-bottom
///   in one vertically-scrolling view, read like a book. The sidebar still works: a
///   click SCROLLS to that doc's section rather than switching pages.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ViewMode {
    /// One doc at a time (the sidebar picks it). The default — least surprise.
    Paged,
    /// All docs in one scrolling flow; the sidebar scrolls to a section.
    Continuous,
}

impl ViewMode {
    /// The `state_json` token — `"paged"` / `"continuous"` (robot-assertable).
    pub fn as_str(self) -> &'static str {
        match self {
            ViewMode::Paged => "paged",
            ViewMode::Continuous => "continuous",
        }
    }
}

/// Which **rendering variant** the Manual pane shows — the user-selectable choice
/// behind the header toggle.
///
/// - [`Variant::Typeset`] — the pretty, book-like **typeset SVG** book, rendered by
///   [`facett_docview`] (resvg). The **default** whenever a typeset book is present.
/// - [`Variant::Markdown`] — the continuous, searchable **Markdown** flow (the
///   classic Manual pane). The only variant when no typeset book was supplied.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Variant {
    /// The typeset vector (SVG) book — the default when available.
    Typeset,
    /// The continuous Markdown flow.
    Markdown,
}

impl Variant {
    /// The `state_json` token — `"typeset"` / `"markdown"` (robot-assertable).
    pub fn as_str(self) -> &'static str {
        match self {
            Variant::Typeset => "typeset",
            Variant::Markdown => "markdown",
        }
    }
}

/// 📖 The shared **Manual pane**: a searchable, category-grouped Markdown viewer over
/// an embedded doc set. A [`facett_core::Facet`], so any host mounts it like any pane.
pub struct ManualPane {
    /// The app whose manual this is (`"dwarves"`, `"korp"`) — shown in the header
    /// and the honest empty state.
    app: String,
    /// The embedded docs, already sorted (kind order, then title).
    docs: Vec<ManualDoc>,
    /// The selected doc index into `docs` (clamped; ignored when `docs` is empty).
    /// In [`ViewMode::Paged`] this is the shown doc; in [`ViewMode::Continuous`] it is
    /// the "current"/last-navigated-to section (the sidebar-scroll target).
    selected: usize,
    /// The live search query — filters the sidebar to docs whose title OR body
    /// contains it (case-insensitive).
    query: String,
    /// Paged vs continuous reading. See [`ViewMode`]. (Applies to the Markdown variant.)
    view_mode: ViewMode,
    /// In [`ViewMode::Continuous`], set when the sidebar (or a search jump) asks the
    /// view to scroll to `selected`'s section; consumed on the next `ui()` frame.
    pending_scroll: bool,
    /// Which rendering variant is shown (Typeset SVG vs Markdown). See [`Variant`].
    /// Defaults to [`Variant::Typeset`] when a typeset book is present, else Markdown.
    variant: Variant,
    /// The typeset (SVG) book, when one was supplied — the [`Variant::Typeset`]
    /// renderer. `None` for a Markdown-only pane (e.g. [`ManualPane::from_files`]),
    /// in which case the variant toggle is hidden and the pane is Markdown-only.
    book: Option<DocView>,
}

impl ManualPane {
    /// Build the pane from a decoded doc set. Templates should already be filtered
    /// by the host (via [`ManualDoc::is_template`]); the docs are sorted here into
    /// the stable sidebar order (kind, then title). An EMPTY set is the honest
    /// "manual not embedded in this build" state.
    pub fn new(app: impl Into<String>, docs: Vec<ManualDoc>) -> Self {
        Self::assemble(app, docs, None)
    }

    /// Build the **merged** pane from the crate's OWN compiled-in content (see
    /// [`mod@embedded`]): the embedded Markdown sources *and* the embedded typeset
    /// SVG book. Everything loads via `include_str!`/`include_bytes!` — **no
    /// filesystem access** — so the manual ships in the binary regardless of docview.
    ///
    /// The default variant is [`Variant::Typeset`] (a typeset book is present); the
    /// header toggle switches to [`Variant::Markdown`]. This is the constructor a
    /// host uses to mount the full, self-contained facett Manual.
    pub fn embedded(app: impl Into<String>) -> Self {
        let app = app.into();
        let docs = embedded::MARKDOWN_DOCS
            .iter()
            .filter(|(name, _)| !ManualDoc::is_template(name))
            .map(|(name, body)| ManualDoc::from_file(name, *body))
            .collect();
        let book = Self::embedded_book(&app);
        Self::assemble(app, docs, book)
    }

    /// Build the typeset [`DocView`] from the embedded SVG book, or `None` if the
    /// crate carries no prebuilt pages. Parsed via facett-docview's pure-Rust resvg
    /// path (no shelling to a Typst binary).
    fn embedded_book(app: &str) -> Option<DocView> {
        if embedded::SVG_PAGES.is_empty() {
            return None;
        }
        let toc: Vec<(String, usize)> =
            embedded::TYPESET_TOC.iter().map(|(t, p)| (t.to_string(), *p)).collect();
        Some(DocView::from_svgs(
            format!("{app} manual"),
            embedded::SVG_PAGES.iter().copied(),
            toc,
        ))
    }

    /// Assemble a pane from a (to-be-sorted) doc set and an optional typeset book.
    /// The variant defaults to [`Variant::Typeset`] when a book is present (the
    /// pretty default), else [`Variant::Markdown`].
    fn assemble(app: impl Into<String>, mut docs: Vec<ManualDoc>, book: Option<DocView>) -> Self {
        docs.sort_by(|a, b| {
            a.kind
                .order()
                .cmp(&b.kind.order())
                .then_with(|| a.title.to_ascii_lowercase().cmp(&b.title.to_ascii_lowercase()))
        });
        // Default to the pretty Typeset (SVG) variant whenever a book is present;
        // a Markdown-only pane (no book) can only be Markdown.
        let variant = if book.is_some() { Variant::Typeset } else { Variant::Markdown };
        Self {
            app: app.into(),
            docs,
            selected: 0,
            query: String::new(),
            // Default to Paged: least surprise for existing hosts (korp/dwarves) and a
            // preserved regression baseline. The Continuous toggle is right there in the
            // header for anyone who wants to read the manual straight through.
            view_mode: ViewMode::Paged,
            pending_scroll: false,
            variant,
            book,
        }
    }

    /// Build the pane from `(name, body)` file pairs — the shape a host's
    /// `rust_embed` iterator yields. Template/scaffold files ([`ManualDoc::is_template`])
    /// are skipped; the rest become [`ManualDoc`]s.
    pub fn from_files<'a, I, S>(app: impl Into<String>, files: I) -> Self
    where
        I: IntoIterator<Item = (&'a str, S)>,
        S: Into<String>,
    {
        let docs = files
            .into_iter()
            .filter(|(name, _)| !ManualDoc::is_template(name))
            .map(|(name, body)| ManualDoc::from_file(name, body))
            .collect();
        Self::new(app, docs)
    }

    /// Build the **merged, dual-variant** pane from a HOST's own data: its decoded
    /// Markdown `(name, body)` files *and* a prebuilt **typeset SVG book** (one SVG
    /// blob per page, in reading order, plus a chapter-level `toc` of
    /// `(title, 0-based page index)`).
    ///
    /// This is the storage-agnostic twin of [`ManualPane::embedded`]: where `embedded`
    /// reads *this crate's* compiled-in sample content, this constructor takes the
    /// host's real doc set — so an app (dwarves, korp, …) can ship the **typeset SVG
    /// of ITS OWN `.nornir` docs** as the default variant, with the SAME docs as the
    /// Markdown toggle, without this shared crate carrying any per-app content. The
    /// SVG pages are typically `include_bytes!`-bundled by the host and rendered
    /// offline by [`facett_docview`]'s pure-Rust resvg engine (no Typst at runtime).
    ///
    /// Template/scaffold files ([`ManualDoc::is_template`]) are filtered from the
    /// Markdown set. When `svg_pages` is empty the pane degrades gracefully to
    /// Markdown-only (identical to [`ManualPane::from_files`]); otherwise it defaults
    /// to [`Variant::Typeset`] (the pretty book) with the header toggle offering
    /// Markdown. Additive: [`from_files`](Self::from_files) and existing hosts (korp)
    /// are unaffected.
    pub fn from_files_with_svgs<'a, I, S, P, B>(
        app: impl Into<String>,
        files: I,
        svg_pages: P,
        toc: Vec<(String, usize)>,
    ) -> Self
    where
        I: IntoIterator<Item = (&'a str, S)>,
        S: Into<String>,
        P: IntoIterator<Item = B>,
        B: AsRef<[u8]> + Send,
    {
        let app = app.into();
        let docs = files
            .into_iter()
            .filter(|(name, _)| !ManualDoc::is_template(name))
            .map(|(name, body)| ManualDoc::from_file(name, body))
            .collect();
        let svgs: Vec<B> = svg_pages.into_iter().collect();
        let book = if svgs.is_empty() {
            None
        } else {
            Some(DocView::from_svgs(format!("{app} manual"), svgs, toc))
        };
        Self::assemble(app, docs, book)
    }

    /// The honest EMPTY pane — the manual was NOT embedded in this build (the host's
    /// `manual-embed` feature is off). Renders a plain "not embedded" hint, never a
    /// blank pane masquerading as content.
    pub fn not_embedded(app: impl Into<String>) -> Self {
        Self::new(app, Vec::new())
    }

    /// Whether any manual content is embedded (Markdown docs OR a typeset book) —
    /// i.e. the manual is really available in this build.
    pub fn is_embedded(&self) -> bool {
        !self.docs.is_empty() || self.book.is_some()
    }

    /// The current rendering variant (Typeset SVG vs Markdown).
    pub fn variant(&self) -> Variant {
        self.variant
    }

    /// Whether a typeset (SVG) book is present — i.e. the variant toggle is offered
    /// and [`Variant::Typeset`] is selectable.
    pub fn has_typeset(&self) -> bool {
        self.book.is_some()
    }

    /// Switch the rendering variant. Selecting [`Variant::Typeset`] with no book
    /// present is a no-op (there is nothing typeset to show).
    pub fn set_variant(&mut self, variant: Variant) {
        if variant == Variant::Typeset && self.book.is_none() {
            return;
        }
        self.variant = variant;
    }

    /// The number of pages in the embedded typeset book (0 when none).
    pub fn typeset_page_count(&self) -> usize {
        self.book.as_ref().map(|b| b.page_count()).unwrap_or(0)
    }

    /// The number of embedded docs.
    pub fn doc_count(&self) -> usize {
        self.docs.len()
    }

    /// The slug of the currently-selected doc, or `""` when empty.
    pub fn selected_slug(&self) -> &str {
        self.docs.get(self.selected).map(|d| d.slug.as_str()).unwrap_or("")
    }

    /// Select a doc by slug (returns whether it matched). In [`ViewMode::Continuous`]
    /// this also arms a scroll-to-section so the seam behaves like a sidebar click.
    pub fn select(&mut self, slug: &str) -> bool {
        if let Some(i) = self.docs.iter().position(|d| d.slug == slug) {
            self.selected = i;
            if self.view_mode == ViewMode::Continuous {
                self.pending_scroll = true;
            }
            true
        } else {
            false
        }
    }

    /// The current view mode.
    pub fn view_mode(&self) -> ViewMode {
        self.view_mode
    }

    /// Switch view mode. Entering [`ViewMode::Continuous`] arms a scroll to the current
    /// section so the reader lands where they were, not at the top.
    pub fn set_view_mode(&mut self, mode: ViewMode) {
        if mode == ViewMode::Continuous && self.view_mode != ViewMode::Continuous {
            self.pending_scroll = true;
        }
        self.view_mode = mode;
    }

    /// Whether `doc` passes the current search query (title or body match).
    fn matches(&self, doc: &ManualDoc) -> bool {
        if self.query.trim().is_empty() {
            return true;
        }
        let q = self.query.to_ascii_lowercase();
        doc.title.to_ascii_lowercase().contains(&q) || doc.body.to_ascii_lowercase().contains(&q)
    }

    // ── Facet contract ──────────────────────────────────────────────────────────

    pub fn title(&self) -> &str {
        "Manual"
    }

    pub fn state_json(&self) -> serde_json::Value {
        // The search-passing docs, in sidebar order — the sidebar's current list AND,
        // in Continuous mode, exactly the sections the reading area renders top-to-bottom.
        let hits: Vec<String> =
            self.docs.iter().filter(|d| self.matches(d)).map(|d| d.slug.clone()).collect();
        // The sections physically present in the reading area this frame:
        //   Paged      → just the one selected doc,
        //   Continuous → every hit (so a robot can assert >1 section is present).
        let sections: Vec<String> = match self.view_mode {
            ViewMode::Paged => {
                self.docs.get(self.selected).map(|d| vec![d.slug.clone()]).unwrap_or_default()
            }
            ViewMode::Continuous => hits.clone(),
        };
        serde_json::json!({
            "app": self.app,
            "embedded": self.is_embedded(),
            "doc_count": self.doc_count(),
            // Typeset (SVG, default) | Markdown — the robot-assertable rendering variant.
            "variant": self.variant.as_str(),
            // Whether a typeset book is present (the variant toggle is offered).
            "has_typeset": self.has_typeset(),
            // Pages in the embedded typeset book (0 when none).
            "typeset_page_count": self.typeset_page_count(),
            "selected": self.selected_slug(),
            "query": self.query,
            // Paged (default) | Continuous — the Markdown variant's reading mode.
            "view_mode": self.view_mode.as_str(),
            // The current sidebar-scroll target section (== `selected` in continuous).
            "scroll_target": self.selected_slug(),
            "docs": self.docs.iter().map(|d| d.slug.clone()).collect::<Vec<_>>(),
            // The filtered (search) hit slugs — what the sidebar currently lists.
            "hits": hits,
            // The sections actually rendered in the reading area this frame (see above).
            "sections": sections,
        })
    }

    /// Render the pane: an optional **variant toggle** (shown only when a typeset
    /// book is present) then the selected variant — [`Variant::Typeset`] (the SVG
    /// book, via [`facett_docview`]) or [`Variant::Markdown`] (the classic flow).
    pub fn ui(&mut self, ui: &mut Ui) {
        // Honest empty state — no manual content (neither Markdown nor a book) baked in.
        if self.docs.is_empty() && self.book.is_none() {
            ui.add_space(24.0);
            ui.vertical_centered(|ui| {
                ui.label(egui::RichText::new("📖 Manual not embedded in this build").strong());
                ui.add_space(6.0);
                ui.label(
                    egui::RichText::new(format!(
                        "Rebuild {} with the manual embedded to bake the docs into the binary.",
                        self.app
                    ))
                    .weak(),
                );
            });
            return;
        }

        // Variant toggle — only when both variants are available (a book is present).
        if self.book.is_some() {
            self.variant_toggle(ui);
            ui.separator();
        }

        match self.variant {
            Variant::Typeset => self.reading_typeset(ui),
            Variant::Markdown => self.markdown_ui(ui),
        }
    }

    /// The header **variant toggle**: Typeset (SVG) ↔ Markdown. Only drawn when a
    /// typeset book exists; `set_variant` guards the no-book case regardless.
    fn variant_toggle(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            ui.weak("variant:");
            let mut v = self.variant;
            let typ = ui
                .selectable_value(&mut v, Variant::Typeset, "🖨 Typeset (SVG)")
                .on_hover_text("the pretty, typeset vector book (default)");
            let md = ui
                .selectable_value(&mut v, Variant::Markdown, "📝 Markdown")
                .on_hover_text("the same manual as continuous, searchable Markdown");
            if typ.clicked() || md.clicked() {
                self.set_variant(v);
            }
        });
    }

    /// The **Typeset (SVG)** variant — delegate the whole reader (sidebar, zoom,
    /// continuous/paged, search) to the embedded [`facett_docview`] book.
    fn reading_typeset(&mut self, ui: &mut Ui) {
        // Bring the docview Facet trait into scope so `book.ui(..)` resolves (it is
        // the SAME `facett_core::Facet` this pane implements, re-exported by docview).
        use facett_docview::Facet as _;
        if let Some(book) = self.book.as_mut() {
            book.ui(ui);
        }
    }

    /// The Markdown-variant reading UI (search box + Paged/Continuous toggle +
    /// category sidebar + rendered doc) — the classic Manual pane, now one of the
    /// two variants [`ManualPane::ui`] dispatches to.
    fn markdown_ui(&mut self, ui: &mut Ui) {
        // Honest empty state — no Markdown docs baked into this build.
        if self.docs.is_empty() {
            ui.add_space(24.0);
            ui.vertical_centered(|ui| {
                ui.label(egui::RichText::new("📖 Manual not embedded in this build").strong());
                ui.add_space(6.0);
                ui.label(
                    egui::RichText::new(format!(
                        "Rebuild {} with `--features manual-embed` to bake the .nornir docs \
                         (idiot guides, guides, designs) into the binary.",
                        self.app
                    ))
                    .weak(),
                );
            });
            return;
        }

        // Search box + hit count (left) and the view-mode toggle (right), across the top.
        ui.horizontal(|ui| {
            ui.label("🔎");
            let hits = self.docs.iter().filter(|d| self.matches(d)).count();
            ui.add(
                egui::TextEdit::singleline(&mut self.query)
                    .desired_width(240.0)
                    .hint_text("search the manual…"),
            );
            if !self.query.trim().is_empty() {
                if ui.button("✕").on_hover_text("clear search").clicked() {
                    self.query.clear();
                }
                ui.weak(format!("{hits} / {} docs", self.docs.len()));
            } else {
                ui.weak(format!("{} docs", self.docs.len()));
            }

            // View-mode toggle, pinned to the right: Paged (one doc) ↔ Continuous
            // (all docs in one scroll). `set_view_mode` arms the scroll-to-section on
            // entering Continuous so the reader lands on the doc they were on.
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                let mut mode = self.view_mode;
                // right_to_left: add in reverse visual order (Continuous shows rightmost).
                let cont = ui
                    .selectable_value(&mut mode, ViewMode::Continuous, "📜 Continuous")
                    .on_hover_text("read every doc top-to-bottom in one scroll");
                let paged = ui
                    .selectable_value(&mut mode, ViewMode::Paged, "📄 Paged")
                    .on_hover_text("one doc at a time");
                if cont.clicked() || paged.clicked() {
                    self.set_view_mode(mode);
                }
                ui.weak("view:");
            });
        });
        ui.separator();

        // If the search hid the selected doc, jump to the first hit so the reading
        // pane always shows a doc that is actually in the list.
        let selected_visible = self
            .docs
            .get(self.selected)
            .map(|d| self.matches(d))
            .unwrap_or(false);
        if !selected_visible && let Some(i) = self.docs.iter().position(|d| self.matches(d)) {
            self.selected = i;
        }

        // Two panes: the grouped sidebar (left) | the rendered doc (right).
        egui::Panel::left(ui.id().with("facett_manual_sidebar"))
            .resizable(true)
            .default_size(240.0)
            .show(ui, |ui| {
                egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                    let query_active = !self.query.trim().is_empty();
                    let mut any_shown = false;
                    for kind in DocKind::all() {
                        // Collect this section's visible (search-passing) docs.
                        let indices: Vec<usize> = self
                            .docs
                            .iter()
                            .enumerate()
                            .filter(|(_, d)| d.kind == kind && self.matches(d))
                            .map(|(i, _)| i)
                            .collect();
                        if indices.is_empty() {
                            continue;
                        }
                        any_shown = true;
                        ui.add_space(4.0);
                        ui.label(egui::RichText::new(kind.header()).small().strong());
                        for i in indices {
                            let selected = i == self.selected;
                            let title = self.docs[i].title.clone();
                            if ui.selectable_label(selected, &title).clicked() {
                                self.selected = i;
                                // Continuous: a click SCROLLS to that section (arm it);
                                // Paged: a click switches the shown doc (nothing to scroll).
                                if self.view_mode == ViewMode::Continuous {
                                    self.pending_scroll = true;
                                }
                            }
                        }
                        let _ = query_active;
                    }
                    if !any_shown {
                        ui.add_space(8.0);
                        ui.weak("no docs match your search");
                    }
                });
            });

        // The reading pane, rendered with the SHARED markdown renderer
        // (facett-markdown's Preview-lane path). L5: no second renderer.
        egui::CentralPanel::default().show(ui, |ui| match self.view_mode {
            ViewMode::Paged => self.reading_paged(ui),
            ViewMode::Continuous => self.reading_continuous(ui),
        });
    }

    /// Paged reading area — the single selected doc (unchanged classic behavior).
    fn reading_paged(&mut self, ui: &mut Ui) {
        if let Some(doc) = self.docs.get(self.selected) {
            egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                ui.label(egui::RichText::new(&doc.title).heading());
                ui.weak(format!("{} · {}.md", doc.kind.header(), doc.slug));
                ui.separator();
                facett_markdown::render(ui, &doc.body);
            });
        }
    }

    /// Continuous reading area — EVERY search-passing doc concatenated top-to-bottom in
    /// one `ScrollArea`, in the sidebar's order, each opened by a clear section heading
    /// (its title) + a divider. A sidebar click / search jump arms `pending_scroll`,
    /// which here scrolls the matching section's heading to the top (`scroll_to_me`).
    fn reading_continuous(&mut self, ui: &mut Ui) {
        // The visible sections, in sidebar order. Snapshot indices so the borrow of
        // `self.docs` doesn't collide with `self.pending_scroll` writes below.
        let visible: Vec<usize> =
            self.docs.iter().enumerate().filter(|(_, d)| self.matches(d)).map(|(i, _)| i).collect();

        if visible.is_empty() {
            ui.add_space(8.0);
            ui.weak("no docs match your search");
            return;
        }

        // Sticky current-section banner — a restrained "you are reading" indicator.
        if let Some(doc) = self.docs.get(self.selected) {
            ui.label(
                egui::RichText::new(format!("📖 {}", doc.title)).small().strong(),
            );
        }
        ui.separator();

        let want_scroll = self.pending_scroll;
        let target = self.selected;
        let mut did_scroll = false;

        egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
            let last = *visible.last().unwrap();
            for i in visible {
                let doc = &self.docs[i];
                // Section heading — the anchor the sidebar scrolls to. Give it a stable
                // id via the doc slug so it is a distinct, queryable widget per section.
                let heading = ui.push_id(("facett_manual_section", &doc.slug), |ui| {
                    let r = ui.label(egui::RichText::new(&doc.title).heading());
                    ui.weak(format!("{} · {}.md", doc.kind.header(), doc.slug));
                    r
                });
                if want_scroll && i == target {
                    heading.inner.scroll_to_me(Some(egui::Align::TOP));
                    did_scroll = true;
                }
                ui.separator();
                facett_markdown::render(ui, &doc.body);
                if i != last {
                    ui.add_space(18.0);
                    ui.separator();
                    ui.add_space(6.0);
                }
            }
        });

        // Consume the scroll request once issued (or if the target vanished from view).
        if want_scroll && (did_scroll || !self.docs.get(target).map(|d| self.matches(d)).unwrap_or(false)) {
            self.pending_scroll = false;
        }
    }

    pub fn update_json(&mut self, msg_json: &str) {
        // Robot / deep-link drive seam:
        //   {"variant":"typeset"|"markdown"}   — switch the rendering variant (toggle twin);
        //   {"select":"<slug>"}      — open (Paged) / scroll to (Continuous) a doc by slug;
        //   {"search":"<text>"}      — set the search query (headless twin of the box);
        //   {"view_mode":"paged"|"continuous"} — switch reading mode (twin of the toggle).
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        // {"variant":"typeset"|"markdown"} — switch the rendering variant (twin of
        // the header toggle). Typeset is ignored when no book is present.
        if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) {
            match variant {
                "typeset" => self.set_variant(Variant::Typeset),
                "markdown" => self.set_variant(Variant::Markdown),
                _ => {}
            }
        }
        if let Some(m) = v.get("view_mode").and_then(|s| s.as_str()) {
            match m {
                "paged" => self.set_view_mode(ViewMode::Paged),
                "continuous" => self.set_view_mode(ViewMode::Continuous),
                _ => {}
            }
        }
        if let Some(slug) = v.get("select").and_then(|s| s.as_str()) {
            self.select(slug);
        }
        if let Some(q) = v.get("search").and_then(|s| s.as_str()) {
            self.query = q.to_string();
        }
    }
}

impl facett_core::Facet for ManualPane {
    fn title(&self) -> &str {
        ManualPane::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        ManualPane::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        ManualPane::state_json(self)
    }
    fn update_json(&mut self, msg_json: &str) {
        ManualPane::update_json(self, msg_json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classify_by_stem() {
        assert_eq!(DocKind::classify("infra-for-idiots"), DocKind::ForIdiots);
        assert_eq!(DocKind::classify("for-idiots"), DocKind::ForIdiots);
        assert_eq!(DocKind::classify("readme"), DocKind::Readme);
        assert_eq!(DocKind::classify("pipeline-guide"), DocKind::Guide);
        assert_eq!(DocKind::classify("graph-design"), DocKind::Design);
        assert_eq!(DocKind::classify("collectors-reasoning"), DocKind::Reasoning);
        assert_eq!(DocKind::classify("korp-worklist"), DocKind::Other);
    }

    #[test]
    fn from_file_derives_title_and_slug() {
        let d = ManualDoc::from_file(
            ".nornir/infra-for-idiots.md",
            "# Infra for idiots\n\nSpark 4.1 → Iceberg → FalkorDB.\n",
        );
        assert_eq!(d.slug, "infra-for-idiots");
        assert_eq!(d.title, "Infra for idiots");
        assert_eq!(d.kind, DocKind::ForIdiots);
    }

    #[test]
    fn from_file_prettifies_when_no_heading() {
        let d = ManualDoc::from_file("three-modes.md", "no heading here\n");
        assert_eq!(d.title, "Three modes");
    }

    #[test]
    fn templates_are_skipped() {
        assert!(ManualDoc::is_template("_TEMPLATE-guide.md"));
        assert!(ManualDoc::is_template(".nornir/_TEMPLATES.md"));
        assert!(!ManualDoc::is_template("infra-for-idiots.md"));
    }

    #[test]
    fn from_files_filters_templates_and_sorts_idiots_first() {
        let pane = ManualPane::from_files(
            "korp",
            vec![
                ("_TEMPLATE-guide.md", "# T\n"),
                ("graph-design.md", "# Graph design\n"),
                ("infra-for-idiots.md", "# Infra for idiots\n"),
            ],
        );
        assert_eq!(pane.doc_count(), 2, "template skipped");
        // ForIdiots sorts first, so it is the default selection.
        assert_eq!(pane.selected_slug(), "infra-for-idiots");
        assert_eq!(pane.docs[0].slug, "infra-for-idiots");
        assert_eq!(pane.docs[1].slug, "graph-design");
    }

    #[test]
    fn not_embedded_is_honest_empty() {
        let pane = ManualPane::not_embedded("dwarves");
        assert!(!pane.is_embedded());
        assert_eq!(pane.doc_count(), 0);
        assert_eq!(pane.state_json()["embedded"], serde_json::json!(false));
    }

    #[test]
    fn search_and_select_over_json_seam() {
        let mut pane = ManualPane::from_files(
            "korp",
            vec![
                ("infra-for-idiots.md", "# Infra\n\nSpark and Iceberg and FalkorDB.\n"),
                ("graph-design.md", "# Graph\n\nedges and nodes.\n"),
            ],
        );
        pane.update_json(r#"{"select":"graph-design"}"#);
        assert_eq!(pane.selected_slug(), "graph-design");
        pane.update_json(r#"{"search":"falkordb"}"#);
        let hits = pane.state_json()["hits"].as_array().unwrap().clone();
        assert_eq!(hits, vec![serde_json::json!("infra-for-idiots")]);
    }

    #[test]
    fn selected_slug_after_select() {
        let mut pane = ManualPane::from_files(
            "korp",
            vec![("a-guide.md", "# A\n"), ("b-design.md", "# B\n")],
        );
        assert!(pane.select("b-design"));
        assert_eq!(pane.selected_slug(), "b-design");
        assert!(!pane.select("nope"));
    }

    // ── merged-variant coverage (embedded content, default variant, toggle) ──────

    #[test]
    fn embedded_default_variant_is_typeset_svg() {
        // The merged, self-contained Manual defaults to the pretty Typeset (SVG)
        // variant — the owner's requirement: DEFAULT = typst-SVG.
        let pane = ManualPane::embedded("facett");
        assert_eq!(pane.variant(), Variant::Typeset, "default variant is typeset (SVG)");
        assert!(pane.has_typeset(), "a typeset book is present");
        let st = pane.state_json();
        assert_eq!(st["variant"], serde_json::json!("typeset"), "state_json reports typeset by default");
        assert_eq!(st["has_typeset"], serde_json::json!(true));
    }

    #[test]
    fn variant_toggle_switches_typeset_to_markdown() {
        // The toggle (and its headless twin) flips Typeset ⇄ Markdown, defaulting typeset.
        let mut pane = ManualPane::embedded("facett");
        assert_eq!(pane.variant(), Variant::Typeset);

        // Twin of clicking the "Markdown" toggle.
        pane.update_json(r#"{"variant":"markdown"}"#);
        assert_eq!(pane.variant(), Variant::Markdown, "toggle switched to markdown");
        assert_eq!(pane.state_json()["variant"], serde_json::json!("markdown"));

        // …and back to typeset.
        pane.set_variant(Variant::Typeset);
        assert_eq!(pane.variant(), Variant::Typeset, "toggle switched back to typeset");
    }

    #[test]
    fn manual_content_is_embedded_no_filesystem() {
        // The whole manual is compiled in via include_str!/include_bytes! — this test
        // touches ZERO filesystem paths: the content comes straight from the binary.
        assert!(!embedded::MARKDOWN_DOCS.is_empty(), "markdown sources are embedded");
        assert!(!embedded::SVG_PAGES.is_empty(), "typeset SVG pages are embedded");
        for (_, body) in embedded::MARKDOWN_DOCS {
            assert!(!body.trim().is_empty(), "embedded markdown body is non-empty");
        }
        for page in embedded::SVG_PAGES {
            assert!(page.windows(4).any(|w| w == b"<svg"), "embedded page is real SVG");
        }

        // Built from the embedded content alone, the pane is genuinely available:
        // markdown docs AND a parsed typeset book, no disk access anywhere.
        let pane = ManualPane::embedded("facett");
        assert!(pane.is_embedded(), "embedded pane is available");
        assert!(pane.doc_count() >= 1, "embedded markdown docs loaded");
        assert_eq!(
            pane.typeset_page_count(),
            embedded::SVG_PAGES.len(),
            "every embedded SVG page parsed into the typeset book"
        );
    }

    #[test]
    fn from_files_with_svgs_defaults_to_typeset_over_host_docs() {
        // A minimal but well-formed SVG page (parseable by facett-docview's resvg).
        const PAGE: &[u8] =
            br#"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="160"></svg>"#;
        // The host hands in its OWN markdown docs AND its OWN typeset SVG book.
        let pane = ManualPane::from_files_with_svgs(
            "dwarves",
            vec![
                ("_TEMPLATE-guide.md", "# T\n"),
                ("dwarves-for-idiots.md", "# Dwarves for idiots\n\nreal content.\n"),
                ("release-design.md", "# Release design\n\nmore real content.\n"),
            ],
            vec![PAGE, PAGE],
            vec![("Dwarves for idiots".to_string(), 0)],
        );
        // Default is the pretty Typeset (SVG) variant, backed by the host's book.
        assert_eq!(pane.variant(), Variant::Typeset, "default is typeset when a book is present");
        assert!(pane.has_typeset(), "the host's typeset book is present");
        assert_eq!(pane.typeset_page_count(), 2, "both SVG pages parsed into the book");
        // The Markdown variant carries the host's REAL docs (template filtered out).
        assert_eq!(pane.doc_count(), 2, "template skipped, both real docs kept");
        assert_eq!(pane.selected_slug(), "dwarves-for-idiots", "for-idiots sorts first");
        let st = pane.state_json();
        assert_eq!(st["variant"], serde_json::json!("typeset"));
        assert_eq!(st["has_typeset"], serde_json::json!(true));
        let slugs = st["docs"].as_array().unwrap().clone();
        assert!(slugs.iter().any(|v| v == "dwarves-for-idiots"), "real doc slug present");
    }

    #[test]
    fn from_files_with_svgs_empty_book_is_markdown_only() {
        // No SVG pages supplied → graceful degrade to a Markdown-only pane (exactly
        // `from_files`): no book, no toggle, Markdown variant.
        let empty: Vec<&[u8]> = Vec::new();
        let pane = ManualPane::from_files_with_svgs(
            "dwarves",
            vec![("a-guide.md", "# A\n")],
            empty,
            Vec::new(),
        );
        assert!(!pane.has_typeset(), "no book when no SVG pages supplied");
        assert_eq!(pane.variant(), Variant::Markdown, "markdown-only without a book");
        assert_eq!(pane.doc_count(), 1);
    }

    #[test]
    fn markdown_only_pane_has_no_typeset_and_stays_markdown() {
        // A pane built from Markdown files alone (the classic host path) has no book,
        // so it is Markdown-only — the variant toggle is not offered and Typeset is a
        // no-op. This preserves the existing korp/dwarves behavior exactly.
        let mut pane = ManualPane::from_files("korp", vec![("a-guide.md", "# A\n")]);
        assert!(!pane.has_typeset());
        assert_eq!(pane.variant(), Variant::Markdown, "markdown-only pane is markdown");
        pane.set_variant(Variant::Typeset); // no book → no-op
        assert_eq!(pane.variant(), Variant::Markdown, "cannot switch to typeset without a book");
    }
}