`, entries as
+ "1. Title", bulletless (app/components/ProjectTOC.tsx + app/tocTree.ts).
+ status: pending
+ target: jupyter-book/myst-theme
+ provenance:
+ - local_pr: 240
+ note: Landing-page TOC parity with the Sphinx lecture sites.
+ upstream:
+ pr: null
+ notes: |
+ Upstream has no TOC renderer at all — myst-transforms' buildTocTransform
+ flattens the directive into a plain list, baking the enumerator into the
+ text with no period, and every myst-theme template renders it as prose.
+ Two possible upstream shapes: keep the enumerator as a field on the
+ generated list AST (myst-transforms), or a `toc:*`-aware block renderer
+ in @myst-theme/site like this one. The section-title anchor ids and
+ the `nav` landmark are worth carrying either way.
diff --git a/app/components/ProjectTOC.tsx b/app/components/ProjectTOC.tsx
new file mode 100644
index 000000000..6a66c3881
--- /dev/null
+++ b/app/components/ProjectTOC.tsx
@@ -0,0 +1,94 @@
+/**
+ * Landing-page table of contents: renders the `{tableofcontents}`
+ * directive's `toc:project` block to match the existing lecture sites —
+ * section titles as headings with the section's lectures listed under them,
+ * entries as "1. About These Lectures", no bullets (styles/front-toc.css).
+ *
+ * The CLI's toc transform has already flattened the directive into a plain
+ * nested list whose link text bakes the enumerator in without a period
+ * ("1 About These Lectures") and whose section titles are bare text nodes.
+ * Instead of re-parsing that, the component rebuilds the TOC from the site
+ * manifest — the same source ContentsSidebar.tsx uses — where `enumerator`
+ * is a separate field and sections are headings without a slug.
+ *
+ * Semantics deliberately improve on the old quantecon-book-theme markup:
+ * real `` elements (the old theme faked them with
+ * ` `) inside a labelled ``, each with
+ * a stable id, so they land in the document outline and are deep-linkable.
+ */
+import classNames from 'classnames';
+import type { GenericNode } from 'myst-common';
+import { slugToUrl } from 'myst-common';
+import { getProjectHeadings } from '@myst-theme/common';
+import {
+ useBaseurl,
+ useLinkProvider,
+ useProjectManifest,
+ useSiteManifest,
+ withBaseurl,
+} from '@myst-theme/providers';
+import { Block } from 'myst-to-react';
+import type { TocEntry } from '../tocTree';
+import { buildTocTree, tocLabel, tocSegments } from '../tocTree';
+
+// Upstream types `Block`'s node as `GenericParent`; renderer props carry
+// `GenericNode`. Same cast STDERR_RENDERERS (app/renderers.tsx) uses.
+const UpstreamBlock = Block as (props: { node: GenericNode; className?: string }) => JSX.Element;
+
+function TocList({ entries }: { entries: TocEntry[] }) {
+ const baseurl = useBaseurl();
+ const Link = useLinkProvider();
+ return (
+
+ {entries.map((entry) => (
+
+ {entry.slug ? (
+ {tocLabel(entry)}
+ ) : (
+ // A nested captionless group (depth ≥ 2 in the project toc).
+ // Only top-level sections get real headings; promoting deeper
+ // groups would need level bookkeeping no lecture repo exercises.
+ tocLabel(entry)
+ )}
+ {entry.children.length > 0 && }
+
+ ))}
+
+ );
+}
+
+export function ProjectTOCBlock({ node, className }: { node: GenericNode; className?: string }) {
+ const config = useSiteManifest();
+ const project = useProjectManifest();
+ const headings = config
+ ? getProjectHeadings(config, project?.slug, { addGroups: false })
+ : undefined;
+ const segments = tocSegments(buildTocTree(headings ?? []));
+ // No manifest in this render context, or nothing to list: fall back to the
+ // CLI's baked list so the directive never renders as a hole in the page.
+ if (segments.length === 0) return ;
+ return (
+
+ {segments.map((segment) =>
+ segment.kind === 'section' ? (
+
+ {/* The `.heading-text` span is upstream's outline contract:
+ useHeaders drops any heading without one (DocumentOutline's
+ `.filter((h) => !!h.text)`), and myst's own heading renderer
+ always emits it. */}
+
+ {tocLabel(segment.entry)}
+
+ {segment.entry.children.length > 0 && }
+
+ ) : (
+
+ )
+ )}
+
+ );
+}
diff --git a/app/renderers.tsx b/app/renderers.tsx
index 55a6ee582..b59a48a99 100644
--- a/app/renderers.tsx
+++ b/app/renderers.tsx
@@ -1,7 +1,8 @@
import type { GenericNode } from 'myst-common';
import type { NodeRenderers } from '@myst-theme/providers';
-import { MyST } from 'myst-to-react';
+import { Block, MyST } from 'myst-to-react';
import { OUTPUT_RENDERERS } from '@myst-theme/jupyter';
+import { ProjectTOCBlock } from './components/ProjectTOC';
/**
* Fancy ordered lists (QuantEcon/mystmd#50): `list` nodes carry `style`
@@ -100,3 +101,26 @@ export const STDERR_RENDERERS: NodeRenderers = {
return ;
},
};
+
+/**
+ * Landing-page `{tableofcontents}`: the CLI's toc transform turns the
+ * directive into a `block` whose only marker is `data.part === 'toc:project'`
+ * — unreachable by `selectRenderer`'s unist-util-select keys, which match
+ * top-level props only. So this wraps the base `block` renderer instead:
+ * project TOCs go to ProjectTOCBlock (app/components/ProjectTOC.tsx), every
+ * other block — including `toc:children`/`toc:page`/`toc:section` — falls
+ * through to upstream's Block untouched.
+ */
+const UpstreamBlock = Block as (props: {
+ node: GenericNode;
+ className?: string;
+}) => JSX.Element;
+
+export const TOC_RENDERERS: NodeRenderers = {
+ block(props: { node: GenericNode; className?: string }) {
+ if ((props.node.data as { part?: string } | undefined)?.part === 'toc:project') {
+ return ;
+ }
+ return ;
+ },
+};
diff --git a/app/root.tsx b/app/root.tsx
index 062808f7e..d93c5d59d 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -20,7 +20,7 @@ import type { ISearch, MystSearchIndex } from '@myst-theme/search';
import { SEARCH_ATTRIBUTES_ORDERED } from '@myst-theme/search';
import { useCallback } from 'react';
import { JUPYTER_RENDERERS } from '@myst-theme/jupyter';
-import { LIST_RENDERERS, STDERR_RENDERERS } from './renderers';
+import { LIST_RENDERERS, STDERR_RENDERERS, TOC_RENDERERS } from './renderers';
import { Document } from './components/Document';
import { htmlDir, htmlLang } from './i18n';
import { normalizeBaseurl } from './seo';
@@ -36,6 +36,8 @@ const RENDERERS: NodeRenderers = mergeRenderers([
LIST_RENDERERS,
// After JUPYTER_RENDERERS: wraps upstream's `output` renderer.
STDERR_RENDERERS,
+ // Wraps the base `block` renderer: `toc:project` blocks only.
+ TOC_RENDERERS,
]);
export const meta: V2_MetaFunction = ({ data }) => {
diff --git a/app/tocTree.ts b/app/tocTree.ts
new file mode 100644
index 000000000..8d2bfe3d6
--- /dev/null
+++ b/app/tocTree.ts
@@ -0,0 +1,98 @@
+/**
+ * Pure helpers behind the landing-page `{tableofcontents}` renderer
+ * (app/components/ProjectTOC.tsx). Split out of the component so the tree
+ * building and label formatting run under `node --test` with type stripping,
+ * like app/i18n.ts and app/seo.ts.
+ *
+ * The site manifest's project headings (`getProjectHeadings` from
+ * `@myst-theme/common`) are the data source, not the directive's baked AST:
+ * the CLI's toc transform flattens `"1 About These Lectures"` into plain text
+ * with no separate enumerator, while the manifest keeps `enumerator` as its
+ * own field — which is what lets `tocLabel` emit the `1.` the lecture sites
+ * use without guessing at strings that merely start with a number.
+ */
+import { createHtmlId } from 'myst-common';
+
+/** The subset of `@myst-theme/common`'s `Heading` this module consumes. */
+export type TocHeading = {
+ slug?: string;
+ title: string;
+ level: number | 'index';
+ enumerator?: string;
+};
+
+export type TocEntry = {
+ title: string;
+ slug?: string;
+ enumerator?: string;
+ /** Anchor id, present only on captionless section titles (no slug). */
+ htmlId?: string;
+ children: TocEntry[];
+};
+
+/** "1. About These Lectures" — enumerator, period, space, title. */
+export function tocLabel(entry: { title: string; enumerator?: string }): string {
+ return entry.enumerator ? `${entry.enumerator}. ${entry.title}` : entry.title;
+}
+
+/**
+ * Nest the flat, level-annotated manifest headings into a tree, dropping the
+ * `level: 'index'` entry (the landing page must not list itself). Section
+ * titles — headings without a slug — get a stable, deduplicated anchor id so
+ * the document outline and deep links can target them.
+ */
+export function buildTocTree(headings: TocHeading[]): TocEntry[] {
+ const root: TocEntry[] = [];
+ const stack: { level: number; entry: TocEntry }[] = [];
+ const idCounts = new Map();
+
+ for (const heading of headings) {
+ if (heading.level === 'index') continue;
+ const entry: TocEntry = {
+ title: heading.title,
+ slug: heading.slug,
+ enumerator: heading.enumerator,
+ children: [],
+ };
+ if (!heading.slug) {
+ const base = createHtmlId(heading.title) || 'section';
+ const seen = idCounts.get(base) ?? 0;
+ idCounts.set(base, seen + 1);
+ entry.htmlId = seen === 0 ? base : `${base}-${seen + 1}`;
+ }
+ while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
+ stack.pop();
+ }
+ const parent = stack[stack.length - 1];
+ (parent ? parent.entry.children : root).push(entry);
+ stack.push({ level: heading.level, entry });
+ }
+ return root;
+}
+
+/**
+ * Split the top level into render segments: each captionless section title
+ * becomes a heading with its children as the sibling list, and runs of
+ * directly-linked pages (a flat TOC, or stray top-level pages between
+ * sections) collapse into one list.
+ */
+export type TocSegment =
+ | { kind: 'section'; entry: TocEntry }
+ | { kind: 'list'; entries: TocEntry[] };
+
+export function tocSegments(tree: TocEntry[]): TocSegment[] {
+ const segments: TocSegment[] = [];
+ for (const entry of tree) {
+ if (!entry.slug) {
+ segments.push({ kind: 'section', entry });
+ continue;
+ }
+ const last = segments[segments.length - 1];
+ if (last?.kind === 'list') {
+ last.entries.push(entry);
+ } else {
+ segments.push({ kind: 'list', entries: [entry] });
+ }
+ }
+ return segments;
+}
diff --git a/docs/index.md b/docs/index.md
index 5efdca993..9c6133f38 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -26,7 +26,7 @@ Every theme option lives under `site.options` and is listed in
| --- | --- |
| [migrating](migrating.md) | moving a lecture repo off `quantecon-book-theme`, step by step |
| [configuration](configuration.md) | every `site.options` key, its scope and its default |
-| [layout](layout.md) | header, contents drawer, "On this page" panel, back-to-top, footer |
+| [layout](layout.md) | header, contents drawer, "On this page" panel, landing-page table of contents, back-to-top, footer |
| [authors](authors.md) | author line and translator credit |
| [launch](launch.md) | notebook launch buttons (Colab) and the notebook repo conventions |
| [notebooks](notebooks.md) | notebook output rendering, live compute, collapsible stderr |
diff --git a/docs/layout.md b/docs/layout.md
index 5e87810be..7589f2ae9 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -40,6 +40,21 @@ into one, its subsections then expand, and an h3's h4s expand once that h3 or
one of those h4s is current. Ancestors of the current entry are expanded but
not marked — exactly one entry is marked at a time.
+On a landing page that carries a `{tableofcontents}` directive, the TOC's
+section titles are headings too, so they are listed here and can be linked to
+by fragment.
+
+## Landing-page table of contents
+
+The `{tableofcontents}` directive renders as the Sphinx lecture sites render
+their toctree: each section title from `project.toc` as a heading over a
+bulletless list of its lectures, every entry labelled with its number and a
+period ("1. About These Lectures") when `numbering.titles` is on, and the bare
+title otherwise. The landing page itself is never listed. The theme builds the
+list from the site manifest rather than the directive's output, so a lecture
+whose title starts with a number is left alone. The whole block is a `nav`
+labelled "Table of contents".
+
## Back to top
A "↑ Top" link in the margin column, visible after 80px of scrolling, as a plain
diff --git a/styles/app.css b/styles/app.css
index cacc9bf52..e3c8c221b 100644
--- a/styles/app.css
+++ b/styles/app.css
@@ -3,6 +3,7 @@
font paths would resolve relative to this file's *output* and 404. */
@import '@myst-theme/styles';
@import './lists.css';
+@import './front-toc.css';
@import './mpl-widget.css';
/* QuantEcon content styles. Last, so it has the final say among the
imports -- see the file's header for the specificity rules. */
diff --git a/styles/front-toc.css b/styles/front-toc.css
new file mode 100644
index 000000000..5ad5fb3f7
--- /dev/null
+++ b/styles/front-toc.css
@@ -0,0 +1,69 @@
+/* ---------------------------------------------------------------------------
+ LANDING-PAGE TABLE OF CONTENTS
+
+ Styles the `nav.qe-front-toc` emitted by app/components/ProjectTOC.tsx for
+ the `{tableofcontents}` directive, matching the lecture builds'
+ `.toctree-wrapper` (quantecon-book-theme on top of pydata-sphinx-theme):
+ section captions in serif, bulletless un-indented lists, links at 1.1em,
+ underline on hover only.
+
+ Sizes are `em`, not `rem`, so they track the content size like the heading
+ scale in quantecon.css (UNITS there). Measured on the live
+ python.quantecon.org (2026-09-14): toctree links compute to 19.8px (1.1em
+ of the 18px root) at every depth and captions to 21.6px — 1.1em/1.2em here
+ against the same 18px base lands on the same pixels.
+
+ Selectors are `.article .qe-front-toc ...` (0,2,1)+ to outweigh the content
+ rules they replace: `.article h2` (0,1,1) for the caption, and the
+ typography plugin's `:where(ul)` padding for the lists. Link colour rides
+ the `--qe-link-*` tokens (quantecon.css), which flip to white under `.dark`
+ -- and in dark mode `.dark .article :where(a)` paints these classless
+ anchors white anyway, so no separate dark rules are needed here.
+ --------------------------------------------------------------------------- */
+@layer components {
+ /* Section captions. Real s, but at the lecture builds' caption scale:
+ 1.2em (21.6px) -- the h4 step of the heading scale -- instead of the
+ article h2's 1.7em. PT Serif at its real 400 face; margins tightened so
+ the list hugs its caption (1em above, from the builds' UA-default ). */
+ .article .qe-front-toc h2 {
+ font-size: 1.2em; /* 21.6px */
+ font-weight: normal;
+ margin: 1em 0 0;
+ }
+
+ /* Bulletless lists: no markers at any depth, no indent on the top level,
+ 1.5em per nested level (logical, so the RTL builds mirror it). */
+ .article .qe-front-toc ul {
+ list-style: none;
+ padding-inline-start: 0;
+ }
+
+ .article .qe-front-toc ul ul {
+ padding-inline-start: 1.5em;
+ }
+
+ /* The lecture builds' item rhythm: 9px above (their 0.5rem of an 18px
+ root, so 0.5em of the 18px content here, not 0.5rem of this 16px root),
+ 0.2em below. The typography plugin pads every `li` by 0.375em; the
+ builds' items sit flush, so that goes too. */
+ .article .qe-front-toc li {
+ margin: 0.5em 0 0.2em;
+ padding-inline-start: 0;
+ }
+
+ /* Links at 1.1em (19.8px), every depth, as the lecture builds render.
+ Colour through the tokens; underline only on hover/keyboard focus,
+ drawn in the link's own colour. */
+ .article .qe-front-toc a {
+ font-size: 1.1em;
+ color: var(--qe-link-color, #0072bc);
+ text-decoration: none;
+ }
+
+ .article .qe-front-toc a:hover,
+ .article .qe-front-toc a:focus-visible {
+ color: var(--qe-link-hover-color, #004979);
+ text-decoration: underline;
+ text-decoration-color: currentColor;
+ }
+}
diff --git a/tests/unit/toc-tree.test.mjs b/tests/unit/toc-tree.test.mjs
new file mode 100644
index 000000000..82a634bb6
--- /dev/null
+++ b/tests/unit/toc-tree.test.mjs
@@ -0,0 +1,113 @@
+/**
+ * Unit tests for the landing-page TOC helpers (app/tocTree.ts): manifest
+ * headings -> nested tree, section-title anchor ids, render segments, and
+ * the "1. Title" label the lecture sites use. Plain TypeScript with no
+ * React, run under `node --test` with type stripping like i18n.test.mjs.
+ *
+ * Run with: npm run test:unit
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { buildTocTree, tocLabel, tocSegments } from '../../app/tocTree.ts';
+
+test('tocLabel joins enumerator, period, space, title', () => {
+ assert.equal(tocLabel({ title: 'About These Lectures', enumerator: '1' }), '1. About These Lectures');
+});
+
+test('tocLabel without an enumerator is the bare title', () => {
+ assert.equal(tocLabel({ title: 'About These Lectures' }), 'About These Lectures');
+});
+
+test('tocLabel leaves a title that starts with a number untouched', () => {
+ // The whole point of reading the manifest's separate enumerator field: a
+ // string-surgery approach would mangle this into "2008. Financial Crisis".
+ assert.equal(tocLabel({ title: '2008 Financial Crisis' }), '2008 Financial Crisis');
+});
+
+test('buildTocTree drops the index heading (the landing page never lists itself)', () => {
+ const tree = buildTocTree([
+ { title: 'Python Programming', slug: 'index', level: 'index' },
+ { title: 'About These Lectures', slug: 'about-py', level: 1, enumerator: '1' },
+ ]);
+ assert.equal(tree.length, 1);
+ assert.equal(tree[0].slug, 'about-py');
+});
+
+test('buildTocTree groups pages under a captionless section title', () => {
+ const tree = buildTocTree([
+ { title: 'Introduction to Python', level: 1 },
+ { title: 'About These Lectures', slug: 'about-py', level: 2, enumerator: '1' },
+ { title: 'Getting Started', slug: 'getting-started', level: 2, enumerator: '2' },
+ { title: 'The Scientific Libraries', level: 1 },
+ { title: 'NumPy', slug: 'numpy', level: 2, enumerator: '3' },
+ ]);
+ assert.equal(tree.length, 2);
+ assert.equal(tree[0].title, 'Introduction to Python');
+ assert.deepEqual(
+ tree[0].children.map((c) => c.slug),
+ ['about-py', 'getting-started']
+ );
+ assert.deepEqual(
+ tree[1].children.map((c) => c.slug),
+ ['numpy']
+ );
+});
+
+test('buildTocTree gives section titles anchor ids, deduplicated on collision', () => {
+ const tree = buildTocTree([
+ { title: 'Other Topics', level: 1 },
+ { title: 'A', slug: 'a', level: 2 },
+ { title: 'Other Topics', level: 1 },
+ { title: 'B', slug: 'b', level: 2 },
+ ]);
+ assert.equal(tree[0].htmlId, 'other-topics');
+ assert.equal(tree[1].htmlId, 'other-topics-2');
+ // Linked pages need no generated anchor.
+ assert.equal(tree[0].children[0].htmlId, undefined);
+});
+
+test('buildTocTree nests deeper levels recursively', () => {
+ const tree = buildTocTree([
+ { title: 'Part I', level: 1 },
+ { title: 'Section A', level: 2 },
+ { title: 'Lecture', slug: 'lecture', level: 3, enumerator: '1.1' },
+ { title: 'Part II', level: 1 },
+ ]);
+ assert.equal(tree.length, 2);
+ assert.equal(tree[0].children[0].title, 'Section A');
+ assert.equal(tree[0].children[0].children[0].slug, 'lecture');
+ assert.deepEqual(tree[1].children, []);
+});
+
+test('tocSegments: a flat numbered toc is one list segment', () => {
+ const segments = tocSegments(
+ buildTocTree([
+ { title: 'Intro', slug: 'index', level: 'index' },
+ { title: 'One', slug: 'one', level: 1, enumerator: '1' },
+ { title: 'Two', slug: 'two', level: 1, enumerator: '2' },
+ ])
+ );
+ assert.equal(segments.length, 1);
+ assert.equal(segments[0].kind, 'list');
+ assert.equal(segments[0].entries.length, 2);
+});
+
+test('tocSegments: sections become section segments, stray pages merge into list runs', () => {
+ const segments = tocSegments(
+ buildTocTree([
+ { title: 'Preface', slug: 'preface', level: 1 },
+ { title: 'Introduction to Python', level: 1 },
+ { title: 'About These Lectures', slug: 'about-py', level: 2, enumerator: '1' },
+ ])
+ );
+ assert.deepEqual(
+ segments.map((s) => s.kind),
+ ['list', 'section']
+ );
+ assert.equal(segments[1].entry.htmlId, 'introduction-to-python');
+});
+
+test('tocSegments of an empty tree is empty (component falls back to the baked list)', () => {
+ assert.deepEqual(tocSegments(buildTocTree([])), []);
+});
diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/front-toc.png b/tests/visual/__snapshots__/desktop-chrome-darwin/front-toc.png
new file mode 100644
index 000000000..80d706786
Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-darwin/front-toc.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/front-toc.png b/tests/visual/__snapshots__/desktop-chrome-linux/front-toc.png
new file mode 100644
index 000000000..f494467e8
Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-linux/front-toc.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/front-toc.png b/tests/visual/__snapshots__/mobile-chrome-darwin/front-toc.png
new file mode 100644
index 000000000..1d17e1b5d
Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-darwin/front-toc.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/front-toc.png b/tests/visual/__snapshots__/mobile-chrome-linux/front-toc.png
new file mode 100644
index 000000000..2dc6d7dab
Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-linux/front-toc.png differ
diff --git a/tests/visual/fixture-no-thebe/intro.md b/tests/visual/fixture-no-thebe/intro.md
index b0eeed65e..835d2f31e 100644
--- a/tests/visual/fixture-no-thebe/intro.md
+++ b/tests/visual/fixture-no-thebe/intro.md
@@ -3,3 +3,11 @@
This project does **not** set `project.thebe`, so live compute is off and the
header must show no live-compute (Power) toggle. Used by the
`live-compute-toggle-absent-without-thebe` visual test.
+
+The landing-page TOC below is rebuilt from the site manifest by the theme
+(`toc:project` block → app/components/ProjectTOC.tsx): section titles as real
+headings, entries as "1. Title", no bullets. Asserted by the `front-toc-*`
+tests in theme.spec.ts.
+
+```{tableofcontents}
+```
diff --git a/tests/visual/fixture-no-thebe/myst.yml.in b/tests/visual/fixture-no-thebe/myst.yml.in
index 6a924468b..6981e7892 100644
--- a/tests/visual/fixture-no-thebe/myst.yml.in
+++ b/tests/visual/fixture-no-thebe/myst.yml.in
@@ -14,9 +14,17 @@ project:
titles: true
headings: true
heading_1: false
+ # One captionless section title like the lecture repos, exercised by
+ # intro.md's `{tableofcontents}` and the front-toc tests in theme.spec.ts
+ # (section heading, "1." labels, no bullets, a stray top-level page).
+ # outline.md deliberately stays top-level: nesting it under the section
+ # would switch its heading enumerators to the title-prefixed "2.1." shape
+ # and break outline-pinned-and-nested's section-only expectations.
toc:
- file: intro.md
- - file: notebook.ipynb
+ - title: Introduction to Python
+ children:
+ - file: notebook.ipynb
- file: outline.md
site:
title: QE Theme No-Thebe Fixture
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index 2ff832bc7..960c56fd3 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -983,3 +983,73 @@ test.describe("Multilingual editions", () => {
});
});
});
+
+test.describe("Landing-page table of contents", () => {
+ // The `{tableofcontents}` on the no-thebe fixture's home page, rebuilt from
+ // the site manifest by ProjectTOCBlock (app/components/ProjectTOC.tsx) to
+ // match the lecture builds' toctree: section titles as real headings,
+ // entries as "1. Title", bulletless lists. The no-thebe fixture carries the
+ // numbered, nested toc; the main fixture stays flat and unnumbered.
+ const noThebeBase = `http://localhost:${process.env.NO_THEBE_PORT || "3112"}`;
+ const toc = (page: Page) => page.getByRole("navigation", { name: "Table of contents" });
+
+ test("front-toc-structure", async ({ page }) => {
+ await page.goto(`${noThebeBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+
+ // The section title is a real
(not the old theme's role="heading"
+ // ), with a stable anchor id for the outline and deep links.
+ const caption = toc(page).getByRole("heading", { level: 2 });
+ await expect(caption).toHaveText("Introduction to Python");
+ expect(await caption.evaluate((el) => el.tagName)).toBe("H2");
+ expect(await caption.getAttribute("id")).toBe("introduction-to-python");
+
+ // The section's page carries "enumerator period space title" — the
+ // period is the point: the CLI's own baked list renders "1 Title". The
+ // stray top-level page gets no enumerator from the CLI's numbering, so
+ // it must render as the bare title, never "undefined." or a lone dot.
+ const entries = toc(page).locator("ul a");
+ await expect(entries).toHaveCount(2);
+ await expect(entries.nth(0)).toHaveText(/^1\. Notebook outputs$/);
+ await expect(entries.nth(1)).toHaveText(/^Outline page$/);
+
+ // Never a link to the page the TOC is on.
+ await expect(toc(page).locator('a[href="/"]')).toHaveCount(0);
+
+ // Bulletless, like the lecture builds' `.toctree-wrapper`.
+ await expect(toc(page).locator("ul").first()).toHaveCSS("list-style-type", "none");
+ await expect(toc(page).locator("li").first()).toHaveCSS("list-style-type", "none");
+ });
+
+ test("front-toc-in-outline", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chrome", "the margin column is desktop-only");
+ await page.goto(`${noThebeBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ // The real h2 lands in "On this page" — deliberate (see ProjectTOC.tsx) —
+ // and its anchor targets the heading's own id. The panel's hrefs carry
+ // the page path ("/#…"), so match on the fragment.
+ const outline = page.getByRole("navigation", { name: "On this page" });
+ await expect(
+ outline.locator('a[href$="#introduction-to-python"]')
+ ).toHaveText(/Introduction to Python$/);
+ });
+
+ test("front-toc", async ({ page }, testInfo) => {
+ await page.goto(`${noThebeBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ if (testInfo.project.name === "desktop-chrome") {
+ // The outline fills from a throttled mutation observer; pin the capture
+ // to the settled state (TOC section present) or the baseline races it.
+ await expect(
+ page
+ .getByRole("navigation", { name: "On this page" })
+ .locator('a[href$="#introduction-to-python"]')
+ ).toBeVisible();
+ }
+ await expect(page).toHaveScreenshot("front-toc.png", {
+ fullPage: true,
+ maxDiffPixelRatio: 0.01,
+ animations: "disabled",
+ });
+ });
+});