diff --git a/oceanarray/reports/_manifest.py b/oceanarray/reports/_manifest.py
new file mode 100644
index 0000000..c405252
--- /dev/null
+++ b/oceanarray/reports/_manifest.py
@@ -0,0 +1,357 @@
+"""Section-manifest model and resolver for report pages.
+
+A report page is described by a :class:`Profile`: an ordered sequence of
+:class:`Section` (and :class:`Expand`) entries, each naming :class:`Panel` ids
+that render figures or tables. :func:`resolve` walks a profile once against a
+render context and returns a :class:`ResolvedReport` whose sections are numbered
+over the *rendered subset* — absent sections leave no gap, and identity is the
+section id (a stable slug), not the integer.
+
+This module holds only the model and the resolution algorithm — it is
+package-neutral (names no variable, page, or science) and is vendored
+byte-identical to the sister repos. Each page's concrete registry (its
+``Ctx`` builder, panels, sections, and profiles) lives in that page's own
+module — grid's in ``reports/_grid.py``, and so on — never in a plural
+``_manifests.py`` companion. The design rationale is in
+``.claude/notes/2026-08-15-report-section-manifest-design.md``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Callable, Literal, Sequence
+
+#: Panel content kinds. ``"figure"`` renders a base64 PNG (escaped as an image
+#: ``src``); ``"html"`` and ``"table"`` render pre-built markup that the template
+#: macro emits with ``|safe``. The discriminator keeps the ``autoescape=True``
+#: boundary to one auditable branch — a figure payload is never ``|safe``-d.
+PanelKind = Literal["figure", "html", "table"]
+
+
+def _always(_ctx: Any) -> bool:
+ """Return True for any context (the default ``applies_to`` predicate)."""
+ return True
+
+
+# ---------------------------------------------------------------------------
+# Authored model — what a profile is built from
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class Panel:
+ """One figure or table, addressable by id.
+
+ Parameters
+ ----------
+ id : str
+ Unique panel identifier, e.g. ``"temperature_field"``. Also the anchor
+ a caption or cross-reference can point at.
+ render : Callable
+ Adapter that returns the panel's payload — a base64 PNG string for a
+ ``"figure"``, or ready-to-emit markup for ``"html"``/``"table"`` — or
+ ``None`` when the panel applies but no output could be produced (a plot
+ raised, or the variable is absent), which the resolver turns into a
+ ``.warn`` stub. Figure adapters wrap the existing ``_make_*_b64``
+ functions unchanged.
+ kind : {"figure", "html", "table"}, optional
+ Content discriminator. The template macro branches on it so that only
+ ``"html"``/``"table"`` payloads are emitted ``|safe``; a ``"figure"``
+ payload is always escaped into an image ``src``.
+ slot : str or Callable, optional
+ ``SLOTS`` key giving the panel's display width, or a callable
+ ``ctx -> slot`` that computes it (e.g. from a section's aspect ratio).
+ Belongs to the panel, not the section, so a panel is the same width
+ wherever it is placed. The resolver calls it when callable.
+ caption : str, optional
+ Caption text rendered beneath the panel.
+ applies_to : Callable, optional
+ Predicate deciding whether the panel is attempted at all. ``False``
+ omits the panel silently (it is excluded, not merely unavailable).
+
+ """
+
+ id: str
+ render: Callable[[Any], str | None]
+ kind: PanelKind = "figure"
+ slot: str | Callable[[Any], str] = "full"
+ caption: str | None = None
+ applies_to: Callable[[Any], bool] = _always
+
+
+@dataclass(frozen=True)
+class Section:
+ """A numbered heading and the panels beneath it.
+
+ Parameters
+ ----------
+ id : str
+ Unique section identifier, e.g. ``"hydrography"``. Doubles as the anchor
+ and the cross-reference key.
+ title : str
+ Human-readable heading text (without a number — the number is computed).
+ panels : tuple of str
+ Panel ids, in display order.
+ level : int, optional
+ Heading level (``2`` for ``
``).
+ intro : str, optional
+ Introductory prose rendered under the heading, before the panels.
+ applies_to : Callable, optional
+ Predicate deciding whether the section is included. When ``None``
+ (default), the section applies if *any* of its panels apply. A section
+ dropped here is named in the report's "not applicable" footer line.
+ role : str, optional
+ ``"content"`` (numbered ``1..N``) or ``"appendix"`` (numbered ``A..``),
+ so appendix material such as a NetCDF-variable table does not pad the
+ science numbering.
+
+ """
+
+ id: str
+ title: str
+ panels: tuple[str, ...]
+ level: int = 2
+ intro: str | None = None
+ applies_to: Callable[[Any], bool] | None = None
+ role: str = "content"
+
+
+@dataclass(frozen=True)
+class Expand:
+ """A single manifest entry that becomes N sections at resolution time.
+
+ Parameters
+ ----------
+ over : Callable
+ Returns the sequence of items to expand over (e.g. the variables present
+ on the page, in display order).
+ section : Callable
+ Builds one :class:`Section` per item yielded by ``over``.
+
+ """
+
+ over: Callable[[Any], Sequence[Any]]
+ section: Callable[[Any], Section]
+
+
+@dataclass(frozen=True)
+class Profile:
+ """An ordered page description plus a numbering policy.
+
+ Parameters
+ ----------
+ numbering : str
+ ``"flat"`` (content ``1..N``, appendices ``A..``), ``"none"`` (no
+ numbers), or ``"grouped"`` (reserved — not yet implemented).
+ entries : tuple of (Section or Expand)
+ The page's sections, in order.
+
+ """
+
+ numbering: str = "flat"
+ entries: tuple[Section | Expand, ...] = field(default_factory=tuple)
+
+
+# ---------------------------------------------------------------------------
+# Resolved model — what the template renders
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class ResolvedPanel:
+ """A panel after rendering: a payload (figure b64 or markup) or a ``.warn`` stub."""
+
+ id: str
+ kind: PanelKind
+ slot: str
+ payload: str | None
+ caption: str | None
+ stub_reason: str | None = None
+
+ @property
+ def is_stub(self) -> bool:
+ """True when the panel applied but produced no output."""
+ return self.payload is None
+
+
+@dataclass(frozen=True)
+class ResolvedSection:
+ """A section after resolution: numbered heading plus resolved panels."""
+
+ id: str
+ number: str
+ title: str
+ level: int
+ intro: str | None
+ panels: tuple[ResolvedPanel, ...]
+ role: str
+
+
+@dataclass(frozen=True)
+class ResolvedReport:
+ """The full resolved page: numbered sections plus the not-applicable list."""
+
+ sections: tuple[ResolvedSection, ...]
+ not_applicable: tuple[str, ...]
+
+ def anchor(self, section_id: str) -> str | None:
+ """Return the display number for *section_id*, or None if not rendered."""
+ for sec in self.sections:
+ if sec.id == section_id:
+ return sec.number
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Resolution algorithm
+# ---------------------------------------------------------------------------
+
+_STUB_REASON = "applicable but unavailable"
+
+
+def _letter(n: int) -> str:
+ """Return the appendix letter for a zero-based index (0 -> 'A', 25 -> 'Z', 26 -> 'AA')."""
+ letters = ""
+ n += 1
+ while n > 0:
+ n, rem = divmod(n - 1, 26)
+ letters = chr(ord("A") + rem) + letters
+ return letters
+
+
+def _expand(entries: Sequence[Section | Expand], ctx: Any) -> list[Section]:
+ """Splice every :class:`Expand` into the concrete sections it yields."""
+ out: list[Section] = []
+ for entry in entries:
+ if isinstance(entry, Expand):
+ out.extend(entry.section(item) for item in entry.over(ctx))
+ else:
+ out.append(entry)
+ return out
+
+
+def _section_applies(sec: Section, ctx: Any, panels: dict[str, Panel]) -> bool:
+ """Decide whether *sec* is included: explicit predicate, else any panel applies."""
+ if sec.applies_to is not None:
+ return bool(sec.applies_to(ctx))
+ return any(panels[pid].applies_to(ctx) for pid in sec.panels)
+
+
+def _resolve_slot(panel: Panel, ctx: Any) -> str:
+ """Return the panel's slot, calling it with *ctx* when it is a callable."""
+ return panel.slot(ctx) if callable(panel.slot) else panel.slot
+
+
+def _resolve_panels(
+ sec: Section, ctx: Any, panels: dict[str, Panel]
+) -> tuple[ResolvedPanel, ...]:
+ """Render each applicable panel of a kept section; None output becomes a stub.
+
+ A kept section whose panels all drop out (none apply, or all render None)
+ keeps one stub so the heading is never empty.
+ """
+ resolved: list[ResolvedPanel] = []
+ for pid in sec.panels:
+ panel = panels[pid]
+ if not panel.applies_to(ctx):
+ continue
+ payload = panel.render(ctx)
+ resolved.append(
+ ResolvedPanel(
+ id=panel.id,
+ kind=panel.kind,
+ slot=_resolve_slot(panel, ctx),
+ payload=payload,
+ caption=panel.caption,
+ stub_reason=None if payload is not None else _STUB_REASON,
+ )
+ )
+ if not resolved:
+ # Kept but empty (case 2): keep the heading with a single stub.
+ first = panels[sec.panels[0]] if sec.panels else None
+ resolved.append(
+ ResolvedPanel(
+ id=first.id if first else f"{sec.id}_stub",
+ kind=first.kind if first else "figure",
+ slot=_resolve_slot(first, ctx) if first else "full",
+ payload=None,
+ caption=first.caption if first else None,
+ stub_reason=_STUB_REASON,
+ )
+ )
+ return tuple(resolved)
+
+
+def resolve(profile: Profile, ctx: Any, panels: dict[str, Panel]) -> ResolvedReport:
+ """Resolve *profile* against *ctx* into a numbered :class:`ResolvedReport`.
+
+ One pass: expand :class:`Expand` entries, drop sections whose ``applies_to``
+ is false (collecting them for the not-applicable footer), resolve each kept
+ section's panels (``None`` render → ``.warn`` stub), then number the
+ survivors — ``content`` sections ``1..N`` and ``appendix`` sections ``A..``.
+
+ Parameters
+ ----------
+ profile : Profile
+ The page's ordered section description and numbering policy.
+ ctx : Any
+ The render context passed to every predicate and render callable
+ (dataset, config, paths — whatever the page's panels need).
+ panels : dict of str to Panel
+ The panel registry; section ``panels`` entries are ids into this map.
+
+ Returns
+ -------
+ ResolvedReport
+ Numbered, rendered sections plus the titles of any omitted sections.
+
+ Raises
+ ------
+ NotImplementedError
+ If ``profile.numbering == "grouped"`` (reserved for a later branch).
+ KeyError
+ If a section references a panel id absent from *panels*.
+
+ """
+ if profile.numbering not in ("flat", "none"):
+ raise NotImplementedError(
+ f"numbering={profile.numbering!r} is not implemented; use 'flat' or 'none'"
+ )
+
+ sections = _expand(profile.entries, ctx)
+
+ kept: list[tuple[Section, tuple[ResolvedPanel, ...]]] = []
+ not_applicable: list[str] = []
+ for sec in sections:
+ if not _section_applies(sec, ctx, panels):
+ not_applicable.append(sec.title)
+ continue
+ kept.append((sec, _resolve_panels(sec, ctx, panels)))
+
+ resolved: list[ResolvedSection] = []
+ content_n = 0
+ appendix_n = 0
+ for sec, rpanels in kept:
+ if profile.numbering == "none":
+ number = ""
+ elif sec.role == "appendix":
+ number = _letter(appendix_n)
+ appendix_n += 1
+ else:
+ content_n += 1
+ number = str(content_n)
+ resolved.append(
+ ResolvedSection(
+ id=sec.id,
+ number=number,
+ title=sec.title,
+ level=sec.level,
+ intro=sec.intro,
+ panels=rpanels,
+ role=sec.role,
+ )
+ )
+
+ return ResolvedReport(
+ sections=tuple(resolved), not_applicable=tuple(not_applicable)
+ )
diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py
new file mode 100644
index 0000000..01d08c4
--- /dev/null
+++ b/tests/unit/test_manifest.py
@@ -0,0 +1,228 @@
+"""Tests for the section-manifest model and resolver (``reports/_manifest.py``).
+
+These exercise the resolution algorithm in isolation with synthetic panels and
+sections — no rendered page needed. They pin the invariants from the design
+note: compact numbering over the rendered subset, appendix lettering, ``Expand``
+splicing, ``applies_to`` dropping to the not-applicable list, and ``None`` renders
+becoming stubs while the section keeps its heading and number.
+"""
+
+import pytest
+
+from oceanarray.reports._manifest import (
+ Expand,
+ Panel,
+ Profile,
+ Section,
+ _letter,
+ resolve,
+)
+
+
+def _panel(pid: str, html: str | None = "", **kw) -> Panel:
+ """Build a synthetic panel returning a fixed HTML string (or None)."""
+ return Panel(id=pid, render=lambda _ctx: html, **kw)
+
+
+def _registry(*panels: Panel) -> dict[str, Panel]:
+ """Return a panel registry keyed by id."""
+ return {p.id: p for p in panels}
+
+
+# ---------------------------------------------------------------------------
+# Numbering
+# ---------------------------------------------------------------------------
+
+
+def test_flat_numbering_is_one_to_n():
+ """Content sections number 1..N in profile order."""
+ panels = _registry(_panel("a"), _panel("b"), _panel("c"))
+ profile = Profile(
+ numbering="flat",
+ entries=(
+ Section("s1", "One", ("a",)),
+ Section("s2", "Two", ("b",)),
+ Section("s3", "Three", ("c",)),
+ ),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert [s.number for s in report.sections] == ["1", "2", "3"]
+ assert [s.title for s in report.sections] == ["One", "Two", "Three"]
+
+
+def test_appendix_sections_get_letters_and_do_not_pad_content():
+ """Appendix sections number A.. independently of the content 1..N run."""
+ panels = _registry(_panel("a"), _panel("b"), _panel("z"))
+ profile = Profile(
+ numbering="flat",
+ entries=(
+ Section("s1", "One", ("a",)),
+ Section("appx", "NetCDF variables", ("z",), role="appendix"),
+ Section("s2", "Two", ("b",)),
+ ),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ nums = {s.id: s.number for s in report.sections}
+ assert nums == {"s1": "1", "appx": "A", "s2": "2"}
+
+
+def test_numbering_none_leaves_numbers_blank():
+ """numbering='none' renders every section with an empty number."""
+ panels = _registry(_panel("a"), _panel("b"))
+ profile = Profile(
+ numbering="none",
+ entries=(Section("s1", "One", ("a",)), Section("s2", "Two", ("b",))),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert all(s.number == "" for s in report.sections)
+
+
+def test_grouped_numbering_not_implemented():
+ """numbering='grouped' is reserved and raises until a later branch builds it."""
+ profile = Profile(numbering="grouped", entries=())
+ with pytest.raises(NotImplementedError):
+ resolve(profile, ctx=None, panels={})
+
+
+@pytest.mark.parametrize(
+ "n,expected", [(0, "A"), (1, "B"), (25, "Z"), (26, "AA"), (27, "AB")]
+)
+def test_letter_sequence(n, expected):
+ """Appendix lettering rolls over A..Z, AA, AB like spreadsheet columns."""
+ assert _letter(n) == expected
+
+
+# ---------------------------------------------------------------------------
+# Inclusion / not-applicable
+# ---------------------------------------------------------------------------
+
+
+def test_section_dropped_by_explicit_predicate_lands_in_not_applicable():
+ """A section whose applies_to is false is omitted and named in the footer list."""
+ panels = _registry(_panel("a"), _panel("v"))
+ profile = Profile(
+ entries=(
+ Section("hydro", "Hydrography", ("a",)),
+ Section("vel", "Velocity", ("v",), applies_to=lambda _c: False),
+ ),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert [s.id for s in report.sections] == ["hydro"]
+ assert report.not_applicable == ("Velocity",)
+ # The kept section renumbers compactly — no gap where Velocity was.
+ assert report.sections[0].number == "1"
+
+
+def test_default_section_applies_iff_any_panel_applies():
+ """With applies_to=None, a section is dropped when none of its panels apply."""
+ panels = _registry(
+ _panel("present"),
+ _panel("absent", applies_to=lambda _c: False),
+ )
+ profile = Profile(
+ entries=(
+ Section("kept", "Kept", ("present",)),
+ Section("gone", "Gone", ("absent",)),
+ ),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert [s.id for s in report.sections] == ["kept"]
+ assert report.not_applicable == ("Gone",)
+
+
+# ---------------------------------------------------------------------------
+# Panel rendering / stubs
+# ---------------------------------------------------------------------------
+
+
+def test_none_render_becomes_stub_but_keeps_content():
+ """A panel that renders None becomes a stub alongside rendered panels."""
+ panels = _registry(_panel("ok", ""), _panel("empty", None))
+ profile = Profile(entries=(Section("s", "S", ("ok", "empty")),))
+ report = resolve(profile, ctx=None, panels=panels)
+ rpanels = report.sections[0].panels
+ assert rpanels[0].is_stub is False and rpanels[0].payload == ""
+ assert rpanels[1].is_stub is True and rpanels[1].stub_reason
+
+
+def test_kept_section_with_all_panels_none_keeps_heading_and_number():
+ """A section applicable but with every panel None keeps its heading + one stub."""
+ panels = _registry(_panel("empty", None))
+ profile = Profile(
+ entries=(Section("before", "Before", ("empty",), applies_to=lambda _c: True),),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ sec = report.sections[0]
+ assert sec.number == "1"
+ assert len(sec.panels) == 1 and sec.panels[0].is_stub
+
+
+def test_panel_kind_is_carried_to_resolved_panel():
+ """A panel's kind survives resolution so the macro can branch on escaping."""
+ panels = _registry(
+ _panel("fig"),
+ _panel("tbl", "
", kind="table"),
+ _panel("prose", "
", kind="html"),
+ )
+ profile = Profile(entries=(Section("s", "S", ("fig", "tbl", "prose")),))
+ report = resolve(profile, ctx=None, panels=panels)
+ kinds = {p.id: p.kind for p in report.sections[0].panels}
+ assert kinds == {"fig": "figure", "tbl": "table", "prose": "html"}
+
+
+def test_callable_slot_is_resolved_against_ctx():
+ """A panel whose slot is a callable has it computed from ctx at resolution."""
+ panels = _registry(_panel("f", slot=lambda ctx: ctx["width"]))
+ profile = Profile(entries=(Section("s", "S", ("f",)),))
+ report = resolve(profile, ctx={"width": "half"}, panels=panels)
+ assert report.sections[0].panels[0].slot == "half"
+
+
+def test_panel_not_applying_is_omitted_silently():
+ """A panel whose applies_to is false is dropped without a stub."""
+ panels = _registry(
+ _panel("shown"),
+ _panel("hidden", applies_to=lambda _c: False),
+ )
+ profile = Profile(entries=(Section("s", "S", ("shown", "hidden")),))
+ report = resolve(profile, ctx=None, panels=panels)
+ ids = [p.id for p in report.sections[0].panels]
+ assert ids == ["shown"]
+
+
+# ---------------------------------------------------------------------------
+# Expand
+# ---------------------------------------------------------------------------
+
+
+def test_expand_splices_one_section_per_item():
+ """An Expand entry becomes N sections, numbered in the spliced order."""
+ panels = _registry(_panel("head"), _panel("var"))
+ expand = Expand(
+ over=lambda _c: ["Temperature", "Salinity"],
+ section=lambda name: Section(f"var_{name.lower()}", name, ("var",)),
+ )
+ profile = Profile(
+ entries=(Section("intro", "Processing history", ("head",)), expand),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert [s.title for s in report.sections] == [
+ "Processing history",
+ "Temperature",
+ "Salinity",
+ ]
+ assert [s.number for s in report.sections] == ["1", "2", "3"]
+
+
+def test_anchor_returns_number_or_none():
+ """ResolvedReport.anchor maps a section id to its number, or None if dropped."""
+ panels = _registry(_panel("a"), _panel("v"))
+ profile = Profile(
+ entries=(
+ Section("hydro", "Hydrography", ("a",)),
+ Section("vel", "Velocity", ("v",), applies_to=lambda _c: False),
+ ),
+ )
+ report = resolve(profile, ctx=None, panels=panels)
+ assert report.anchor("hydro") == "1"
+ assert report.anchor("vel") is None