diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d85268e0..9bdfd231 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,13 @@ jobs: # Builds the candidate theme from this branch into .deploy/quantecon-theme. - name: Build theme template run: make build-theme + # Node-level e2e of the build-time plugin: builds a throwaway MyST + # project with the CLI installed above and asserts the git history + # injected into the page AST. + - name: Plugin tests (git-metadata) + run: npm run test:plugin + env: + THEME_TEMPLATE: ${{ github.workspace }}/.deploy/quantecon-theme - name: Install Playwright Chromium run: npx playwright install --with-deps chromium - name: Run visual regression tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bdb4163..2ee5dbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The notebook-launch toolbar trigger now has an accessible name (`aria-label="Launch notebook"`); previously it exposed no name to assistive technology. Added alongside the first regression coverage of the launch popover (Colab URL + default selection — BinderHub was deliberately not added, see [#26](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/26)). ### Added +- Git history in page headers (Phase 1 of [`PLAN.md`](./PLAN.md)): a "Last + changed" control on the page-header author line opens a centred changelog + modal with GitHub-linked commit hashes and a full-history link, mirroring the + `quantecon-book-theme` header. Data is injected at build time by a new MyST + transform plugin (`plugins/git-metadata.mjs`, `git log --follow` per page → + `mdast.data.git_metadata`), with a `site.git_metadata` page-frontmatter + override for manual pinning. mystmd has no built-in last-modified support + (jupyter-book/mystmd#2213), and plugin transforms cannot modify page + frontmatter, hence the AST channel. - Per-PR rendered previews on GitHub Pages (`.github/workflows/preview.yml`): each PR statically builds the real `lecture-python-programming` lectures with the candidate theme (`myst build --html`, including the build-time Jupyter Book → MyST upgrade) and publishes to `gh-pages` at `pr-preview/pr-/` with a sticky link comment and teardown on close. Self-contained on `GITHUB_TOKEN` — no external preview service. ### Removed diff --git a/PLAN.md b/PLAN.md index 73fba08f..8c0063a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -233,26 +233,39 @@ and a "full history" link. `docs/user/git-metadata.md`. **Upstream (build-time) deliverable — required:** -- [ ] Investigate existing `mystmd` support for last-modified / git metadata in +- [x] Investigate existing `mystmd` support for last-modified / git metadata in frontmatter before writing anything custom. -- [ ] If absent, build a **MyST plugin** (option A) that, during `myst build`, runs - `git log --follow` per source file and injects into frontmatter, e.g. - `frontmatter.last_modified` (ISO + formatted) and - `frontmatter.changelog: [{hash, author, date, relative_time, message}]`. - Mirror the book-theme's git logic (timeouts, `--follow`, graceful no-git - fallback, configurable `max_entries` and date format). + *Finding (2026-06-12):* none built in — jupyter-book/mystmd#2213 is open with no + traction. Plugin transforms can't inject **frontmatter** either (page frontmatter + is extracted before `document`-stage transforms run), so the plugin attaches to + the page AST (`mdast.data.git_metadata`), which flows into the page JSON intact. + Per-page `site:` frontmatter also passes through (validated as a plain object) — + used as the manual override / deterministic-fixture channel. +- [x] If absent, build a **MyST plugin** (option A) that, during `myst build`, runs + `git log --follow` per source file and injects + `{ last_modified, changelog: [{hash, short_hash, author, date, message}] }`. + Mirrors the book-theme's git logic (5s timeout, `--follow`, graceful no-git/ + untracked fallback, `QE_GIT_METADATA_MAX` for max entries; relative time is + computed at render so it can't go stale). → `plugins/git-metadata.mjs`, with a + node-level e2e (`npm run test:plugin`) that builds a throwaway project with the + real `myst` CLI. - [ ] Decide where the plugin lives (a shared `quantecon-myst-plugins` package reused - across lecture repos is preferable to per-repo copies). + across lecture repos is preferable to per-repo copies). *Interim:* lives in this + repo (`plugins/`) — single self-contained `.mjs`, copy or reference from lecture + repos; revisit when a second shared plugin appears (the QuantEcon `mystmd` fork + is another candidate home, but a plugin stays usable on stock mystmd). **Theme (render) deliverable:** -- [ ] New `app/components/PageHeaderHistory.tsx` (or fold into `ProjectFrontmatter.tsx`) - that reads the injected frontmatter and renders the button + collapsible dropdown, - with the QuantEcon blue accent, dark mode, keyboard (Esc to close), and ARIA - matching the existing toolbar components. Use Radix (already a dependency) for the - disclosure. -- [ ] Build commit/history URLs from the project `github` field (reuse the `.myst`-suffix - handling already in `LaunchButton.tsx`). -- [ ] Graceful no-op when the frontmatter fields are absent. +- [x] New `app/components/PageHeaderHistory.tsx` (rendered from `ProjectFrontmatter.tsx`) + that reads the injected AST data (or the `site.git_metadata` page-frontmatter + override) and renders the button (aligned right of the author line) + a + centred changelog modal, with the QuantEcon blue accent, dark mode, keyboard + (Esc to close), and ARIA via Radix Dialog. A modal (not an anchored popover) + keeps the changelog clear of the left/right page menus and centres on mobile. +- [x] Build commit/history URLs from the project `github` field — commit links keep the + `.myst` suffix (they target the source repo, unlike `LaunchButton.tsx`'s notebook + URLs), and the full-history link derives from the mystmd-computed `source_url`. +- [x] Graceful no-op when the metadata is absent. **Effort:** M–L (plugin is the bulk). **Risk:** medium (new upstream component). **Deps:** Phase 0 preview harness helps validate against a real repo. diff --git a/README.md b/README.md index 6b0fe40b..ed91f3e3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,41 @@ need it). BinderHub is deliberately not offered — it proved flaky in practice; see issue [#26](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/26), kept open as a demand-driven future request. +### Git history in page headers + +The page header shows a "Last changed: ⟨date⟩" control (aligned to the right of +the author line) that opens a centred changelog modal listing the most recent +commits touching that page — commit hashes link to GitHub, and a "full history" +link opens the file's complete commit log (mirroring the `quantecon-book-theme` +header). + +The data is injected at build time by [`plugins/git-metadata.mjs`](./plugins/git-metadata.mjs), +a MyST transform that runs `git log --follow` per source file and attaches +`{ last_modified, changelog: [{hash, short_hash, author, date, message}] }` +to the page AST. Copy the plugin into a lecture repo (or reference a checkout) +and register it: + +```yaml +# myst.yml +project: + github: https://github.com/QuantEcon/lecture-python.myst # commit links target this repo + plugins: + - git-metadata.mjs +``` + +Notes: + +- The header control renders nothing when no metadata is present, so projects + without the plugin are unaffected. +- The plugin is a silent no-op for untracked files, non-git checkouts, missing + `git`, or a `git log` timeout (5s). Shallow CI clones (`fetch-depth: 1`) + produce truncated history — use `fetch-depth: 0` when building for deploy. +- `QE_GIT_METADATA_MAX` caps changelog entries per page (default 10; myst-cli + does not pass options to transform plugins, hence the environment variable). +- A page can pin or correct its history manually — set the same shape under + `site.git_metadata` in the page frontmatter, which takes precedence over the + injected data (this is how the visual fixture keeps snapshots deterministic). + ## Usage with MyST Point your project's `site.template` at a **pinned release** zip: diff --git a/app/components/PageHeaderHistory.tsx b/app/components/PageHeaderHistory.tsx new file mode 100644 index 00000000..0672d7ca --- /dev/null +++ b/app/components/PageHeaderHistory.tsx @@ -0,0 +1,206 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { ChevronDown, X } from "lucide-react"; +import React from "react"; +import { usePage } from "./PageProvider"; + +export interface GitChangelogEntry { + hash: string; + short_hash: string; + author: string; + date: string; + message: string; +} + +export interface GitMetadata { + last_modified?: string; + changelog?: GitChangelogEntry[]; +} + +const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + // Pin the timezone so the server render, client hydration, and visual + // snapshots agree on the date regardless of host timezone. + timeZone: "UTC", +}); + +function formatDate(iso: string) { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : DATE_FORMAT.format(date); +} + +function relativeTime(iso: string, now: number) { + const seconds = (now - new Date(iso).getTime()) / 1000; + if (Number.isNaN(seconds) || seconds < 60) return "just now"; + const units: [number, string][] = [ + [3600, "minute"], + [86400, "hour"], + [604800, "day"], + [2592000, "week"], + [31536000, "month"], + [Infinity, "year"], + ]; + let size = 60; + for (const [limit, label] of units) { + if (seconds < limit) { + const count = Math.floor(seconds / size); + return `${count} ${label}${count !== 1 ? "s" : ""} ago`; + } + size = limit; + } +} + +/** + * "Last changed" page-header control that opens a centred changelog modal, + * mirroring the quantecon-book-theme header. A modal (rather than an anchored + * popover) keeps the changelog clear of the left/right page menus and centres + * cleanly on mobile. + * + * Data sources, in order of precedence: + * 1. `site.git_metadata` in the page frontmatter (manual override, and how + * the visual fixture pins deterministic data), then + * 2. `mdast.data.git_metadata` injected at build time by + * plugins/git-metadata.mjs. + * + * Renders nothing when neither is present. + */ +export function PageHeaderHistory() { + const page = usePage(); + // Relative times depend on the clock; render absolute dates on the server + // and only switch after mount so statically exported HTML hydrates cleanly. + const [now, setNow] = React.useState(null); + React.useEffect(() => setNow(Date.now()), []); + + const frontmatter = page?.frontmatter as any; + const meta: GitMetadata | undefined = + frontmatter?.site?.git_metadata ?? (page?.mdast as any)?.data?.git_metadata; + const changelog = meta?.changelog ?? []; + const lastModified = meta?.last_modified ?? changelog[0]?.date; + if (!lastModified) return null; + + // Commit links target the source repository itself, so unlike + // LaunchButton's notebook URLs the `.myst` suffix must be kept. + const github: string | undefined = frontmatter?.github; + const repoUrl = github?.startsWith("https://github.com/") + ? github.replace(/\/$/, "") + : undefined; + // mystmd computes `source_url` as {repo}/blob/{branch}/{path}; only a URL of + // that shape can be rewritten into a commits view. + const sourceUrl = frontmatter?.source_url as string | undefined; + const historyUrl = sourceUrl?.includes("/blob/") + ? sourceUrl.replace("/blob/", "/commits/") + : undefined; + + // Without changelog entries there is nothing to expand — render plain text, + // not a non-functional interactive control. + if (changelog.length === 0) { + return ( +
+ Last changed: {formatDate(lastModified)} +
+ ); + } + + return ( + + + Last changed: {formatDate(lastModified)} + + + + {/* Dim backdrop so the centred modal stands out across browsers + (Chrome lacks Safari's default dialog outline) and reads clearly + over the left/right page menus. */} + + +
+ + Changelog + +
+ {historyUrl && ( + + full history + + )} + + + +
+
+
    + {changelog.map((entry) => ( +
  1. +
    + {repoUrl ? ( + + {entry.short_hash} + + ) : ( + + {entry.short_hash} + + )} + + {entry.message} + +
    +
    + {entry.author} + · + + {now + ? relativeTime(entry.date, now) + : formatDate(entry.date)} + +
    +
  2. + ))} +
+
+
+
+ ); +} diff --git a/app/components/ProjectFrontmatter.tsx b/app/components/ProjectFrontmatter.tsx index b18ab7fa..5df421a6 100644 --- a/app/components/ProjectFrontmatter.tsx +++ b/app/components/ProjectFrontmatter.tsx @@ -3,6 +3,7 @@ import { Author } from '@myst-theme/frontmatter'; import type { Affiliation, Contributor } from 'myst-frontmatter'; import React from 'react'; import { useBaseurl, useLinkProvider } from '@myst-theme/providers'; +import { PageHeaderHistory } from './PageHeaderHistory'; export function ProjectFrontmatter({ className, @@ -42,32 +43,44 @@ export function ProjectFrontmatter({ )} - {authors && ( -
- {authors.reduce((acc, a, i, authors) => { - let chunk: React.ReactNode = a.name; - if (a.url) { - chunk = ( - + {/* Authors on the left, the "Last changed" control aligned to the right + on the same row (more semantic, saves a header line); it wraps below + authors on narrow viewports. With no authors the control stands alone + — and it renders nothing at all without git metadata, so author-less + pages add no empty row. */} + {authors ? ( +
+
+ {authors.reduce((acc, a, i, authors) => { + let chunk: React.ReactNode = a.name; + if (a.url) { + chunk = ( + + ); + } + if (i > 0 && i < authors.length - 1) { + chunk = <>, {chunk}; + } else if (i === authors.length - 1 && authors.length > 1) { + chunk = <> and {chunk}; + } + return ( + + {acc} + {chunk} + ); - } - if (i > 0 && i < authors.length - 1) { - chunk = <>, {chunk}; - } else if (i === authors.length - 1 && authors.length > 1) { - chunk = <> and {chunk}; - } - return ( - - {acc} - {chunk} - - ); - }, '')} + }, '')} +
+
+ +
+ ) : ( + )}
); diff --git a/app/types.ts b/app/types.ts index 82fd76ba..fb6a7ee6 100644 --- a/app/types.ts +++ b/app/types.ts @@ -1,3 +1,5 @@ +import type { GitMetadata } from './components/PageHeaderHistory'; + export interface TemplateOptions { hide_toc?: boolean; hide_outline?: boolean; @@ -5,4 +7,10 @@ export interface TemplateOptions { hide_footer_links?: boolean; outline_maxdepth?: number; hide_title_block?: boolean; + /** + * Page-level override for the "Last changed" header control, normally + * injected at build time by plugins/git-metadata.mjs (set under `site:` in + * page frontmatter). + */ + git_metadata?: GitMetadata; } diff --git a/package-lock.json b/package-lock.json index 55525944..a3d60cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@myst-theme/search-minisearch": "^1.3.0", "@myst-theme/site": "^1.3.0", "@myst-theme/styles": "^1.3.0", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-radio-group": "^1.2.3", diff --git a/package.json b/package.json index 9f21ea17..eb8f5a5c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "start": "npm run build:css && remix dev", "test:visual": "playwright test", "test:visual:update": "playwright test --update-snapshots", + "test:plugin": "node --test \"tests/plugin/**/*.test.mjs\"", "test:fouc": "playwright test --project=webkit-fouc" }, "dependencies": { @@ -28,6 +29,7 @@ "@myst-theme/search-minisearch": "^1.3.0", "@myst-theme/site": "^1.3.0", "@myst-theme/styles": "^1.3.0", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-radio-group": "^1.2.3", diff --git a/plugins/git-metadata.mjs b/plugins/git-metadata.mjs new file mode 100644 index 00000000..5f408b37 --- /dev/null +++ b/plugins/git-metadata.mjs @@ -0,0 +1,94 @@ +/** + * QuantEcon git-metadata plugin for MyST. + * + * A `document`-stage transform that runs `git log --follow` on each page's + * source file during `myst build` / `myst start` and attaches the result to + * the page AST root as `mdast.data.git_metadata`: + * + * { + * last_modified: '2026-06-11T09:30:00+10:00', // ISO committer date + * changelog: [ + * { hash, short_hash, author, date, message }, // newest first + * ], + * } + * + * The QuantEcon theme renders this as a "Last changed" header control with a + * changelog dropdown (app/components/PageHeaderHistory.tsx). Pages can also + * set the same shape manually under `site.git_metadata` in their frontmatter, + * which takes precedence over the injected data. + * + * Mirrors quantecon-book-theme's get_git_last_modified/get_git_changelog: + * per-file `git log --follow`, a hard timeout, and a silent no-op when the + * file is untracked, the project is not a git repository, git is missing, or + * the command times out. Shallow clones (e.g. CI checkouts with depth 1) + * yield truncated history rather than an error. + * + * Usage in a lecture repo's myst.yml: + * + * project: + * plugins: + * - git-metadata.mjs + * + * Configuration (myst-cli does not pass options to transform plugins, so + * configuration is via environment variables): + * + * QE_GIT_METADATA_MAX maximum changelog entries per page (default 10) + */ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +const GIT_TIMEOUT_MS = 5000; +const MAX_ENTRIES = Number(process.env.QE_GIT_METADATA_MAX) > 0 + ? Number(process.env.QE_GIT_METADATA_MAX) + : 10; + +// %H full hash | %h short hash | %an author | %cI strict-ISO committer date | %s subject +const LOG_FORMAT = '%H|%h|%an|%cI|%s'; + +async function gitChangelog(file) { + const { stdout } = await exec( + 'git', + ['log', `-${MAX_ENTRIES}`, `--format=${LOG_FORMAT}`, '--follow', '--', file], + { cwd: path.dirname(file), timeout: GIT_TIMEOUT_MS, encoding: 'utf8' }, + ); + return stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + const [hash, short_hash, author, date, ...rest] = line.split('|'); + // The subject is last in the format string, so rejoining preserves + // commit messages that themselves contain `|`. + return { hash, short_hash, author, date, message: rest.join('|') }; + }) + .filter((entry) => entry.hash && entry.date); +} + +const gitMetadataTransform = { + name: 'qe-git-metadata', + doc: 'Attach per-page git history (last modified + changelog) to the page AST for the QuantEcon theme.', + stage: 'document', + plugin: () => async (tree, vfile) => { + if (!vfile?.path) return tree; + try { + const changelog = await gitChangelog(vfile.path); + if (changelog.length === 0) return tree; // untracked file + tree.data = { + ...(tree.data ?? {}), + git_metadata: { last_modified: changelog[0].date, changelog }, + }; + } catch { + // Not a git repository, git unavailable, or git timed out — skip silently. + } + return tree; + }, +}; + +const plugin = { + name: 'QuantEcon git metadata', + transforms: [gitMetadataTransform], +}; + +export default plugin; diff --git a/tests/plugin/git-metadata.test.mjs b/tests/plugin/git-metadata.test.mjs new file mode 100644 index 00000000..e3ef7186 --- /dev/null +++ b/tests/plugin/git-metadata.test.mjs @@ -0,0 +1,116 @@ +/** + * End-to-end test of plugins/git-metadata.mjs: builds a throwaway MyST + * project with the real `myst` CLI and asserts the git history that the + * transform attaches to each page's AST (`mdast.data.git_metadata`). + * + * Requirements (both are present in the `visual` CI job, which installs + * mystmd and runs `make build-theme` first): + * - `myst` on PATH + * - a built theme template (THEME_TEMPLATE, default .deploy/quantecon-theme) + * + * Run with: npm run test:plugin + */ +import assert from 'node:assert/strict'; +import { execFile, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pluginPath = path.join(repoRoot, 'plugins', 'git-metadata.mjs'); +const template = + process.env.THEME_TEMPLATE ?? path.join(repoRoot, '.deploy', 'quantecon-theme'); + +function mystAvailable() { + try { + execFileSync('myst', ['--version'], { encoding: 'utf8', timeout: 30000 }); + return true; + } catch { + return false; + } +} + +function git(cwd, ...args) { + execFileSync( + 'git', + ['-c', 'user.email=plugin-test@quantecon.org', '-c', 'user.name=Plugin Test', ...args], + { cwd, encoding: 'utf8' }, + ); +} + +const skip = !mystAvailable() + ? 'myst CLI not on PATH' + : !fs.existsSync(template) + ? `theme template not built at ${template} (run \`make build-theme\`)` + : false; + +test('git-metadata plugin injects per-page history into the page AST', { skip }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qe-git-metadata-')); + try { + fs.writeFileSync( + path.join(dir, 'myst.yml'), + [ + 'version: 1', + 'project:', + ' title: Plugin Test', + ' github: https://github.com/QuantEcon/plugin-test.myst', + ' plugins:', + ` - ${pluginPath}`, + ' toc:', + ' - file: page.md', + ' - file: untracked.md', + 'site:', + ' title: Plugin Test', + ` template: ${template}`, + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(dir, 'page.md'), '# Tracked page\n\nFirst version.\n'); + git(dir, 'init', '-q'); + git(dir, 'add', 'page.md'); + git(dir, 'commit', '-qm', 'first commit'); + fs.appendFileSync(path.join(dir, 'page.md'), '\nSecond version.\n'); + git(dir, 'add', 'page.md'); + // Pipe in the subject exercises the `--format` field-splitting in the plugin. + git(dir, 'commit', '-qm', 'second: keep a|b pipes intact'); + // untracked.md exists on disk but has no git history. + fs.writeFileSync(path.join(dir, 'untracked.md'), '# Untracked page\n'); + + await exec('myst', ['build', '--site'], { cwd: dir, timeout: 180000 }); + + // page.md is the first TOC entry, so it becomes the project index. + const tracked = JSON.parse( + fs.readFileSync(path.join(dir, '_build', 'site', 'content', 'index.json'), 'utf8'), + ); + const meta = tracked.mdast?.data?.git_metadata; + assert.ok(meta, 'tracked page should carry mdast.data.git_metadata'); + assert.equal(meta.changelog.length, 2); + const [head, initial] = meta.changelog; + assert.equal(head.message, 'second: keep a|b pipes intact'); + assert.equal(initial.message, 'first commit'); + assert.equal(head.author, 'Plugin Test'); + assert.match(head.hash, /^[0-9a-f]{40}$/); + assert.match(head.short_hash, /^[0-9a-f]{7,}$/); + assert.ok(head.hash.startsWith(head.short_hash)); + // %cI is strict ISO 8601 and orders newest-first. + assert.match(head.date, /^\d{4}-\d{2}-\d{2}T/); + assert.equal(meta.last_modified, head.date); + assert.ok(new Date(head.date) >= new Date(initial.date)); + + const untracked = JSON.parse( + fs.readFileSync(path.join(dir, '_build', 'site', 'content', 'untracked.json'), 'utf8'), + ); + assert.equal( + untracked.mdast?.data?.git_metadata, + undefined, + 'untracked page should not carry git metadata', + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/features.png b/tests/visual/__snapshots__/desktop-chrome-darwin/features.png index 9831d954..18f3bfae 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome-darwin/features.png and b/tests/visual/__snapshots__/desktop-chrome-darwin/features.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png b/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png new file mode 100644 index 00000000..a42ec6a4 Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-darwin/history-open.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/features.png b/tests/visual/__snapshots__/desktop-chrome-linux/features.png index 162d16e4..7d78688c 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/features.png and b/tests/visual/__snapshots__/desktop-chrome-linux/features.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png new file mode 100644 index 00000000..52f820dd Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/features.png b/tests/visual/__snapshots__/mobile-chrome-darwin/features.png index 9ef1d9da..e90cc94e 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome-darwin/features.png and b/tests/visual/__snapshots__/mobile-chrome-darwin/features.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png b/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png new file mode 100644 index 00000000..fa47a7e9 Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-darwin/history-open.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/features.png b/tests/visual/__snapshots__/mobile-chrome-linux/features.png index 3168e86f..f63d8a34 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/features.png and b/tests/visual/__snapshots__/mobile-chrome-linux/features.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png new file mode 100644 index 00000000..aeacb9ef Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png differ diff --git a/tests/visual/fixture/features.md b/tests/visual/fixture/features.md index 28d0fbbf..8e70f67f 100644 --- a/tests/visual/fixture/features.md +++ b/tests/visual/fixture/features.md @@ -1,3 +1,33 @@ +--- +# Authors exercise the header layout: names on the left, the "Last changed" +# control aligned to the right of the same row. +authors: + - name: Thomas J. Sargent + - name: John Stachurski +site: + # Deterministic stand-in for plugins/git-metadata.mjs output, so the header + # history control renders identically on every run (real git dates would + # change with each commit and churn the snapshots). + git_metadata: + last_modified: '2026-01-15T10:30:00Z' + changelog: + - hash: 3f9d2c41b8a7e6f5d4c3b2a1908f7e6d5c4b3a29 + short_hash: 3f9d2c4 + author: Matt McKay + date: '2026-01-15T10:30:00Z' + message: 'Update features page: add numbered equation' + - hash: 8e7d6c5b4a392817f6e5d4c3b2a190817263f4e5 + short_hash: 8e7d6c5 + author: Jane Economist + date: '2025-12-01T09:00:00Z' + message: Improve code examples + - hash: a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 + short_hash: a1b2c3d + author: Matt McKay + date: '2025-09-20T14:45:00Z' + message: Initial features fixture +--- + # Features Pages here exercise math, code, admonitions, tables and figures. diff --git a/tests/visual/fixture/myst.yml.in b/tests/visual/fixture/myst.yml.in index f639157c..f6a53e9e 100644 --- a/tests/visual/fixture/myst.yml.in +++ b/tests/visual/fixture/myst.yml.in @@ -4,7 +4,7 @@ version: 1 project: title: QuantEcon Theme — Visual Fixture - github: https://github.com/QuantEcon/quantecon-theme-src + github: https://github.com/QuantEcon/quantecon-theme.mystmd toc: - file: intro.md - file: features.md diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index fd09be87..7914b7e7 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -34,6 +34,39 @@ test.describe("QuantEcon theme — visual regression", () => { }); } + // The "Last changed" header control and its centred changelog modal. The + // fixture pins git_metadata in features.md frontmatter and the clock is + // frozen, so the relative times ("4 months ago") are deterministic. Viewport + // snapshot, not fullPage — the modal is a fixed-position portal overlay. + test("history-open", async ({ page }) => { + await page.clock.setFixedTime(new Date("2026-06-12T00:00:00Z")); + await page.goto("/features", { waitUntil: "domcontentloaded" }); + await settle(page); + const trigger = page.getByRole("button", { name: /Last changed/ }); + await expect(trigger).toContainText("Last changed: Jan 15, 2026"); + await trigger.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Changelog", { exact: true })).toBeVisible(); + // Commit links point at the source repo (myst.yml `github`), full history + // at the mystmd-computed source path. + await expect(dialog.getByRole("link", { name: "3f9d2c4" })).toHaveAttribute( + "href", + "https://github.com/QuantEcon/quantecon-theme.mystmd/commit/3f9d2c41b8a7e6f5d4c3b2a1908f7e6d5c4b3a29" + ); + await expect(dialog.getByRole("link", { name: "full history" })).toHaveAttribute( + "href", + /\/commits\/.*features\.md$/ + ); + await expect(dialog.getByText("4 months ago")).toBeVisible(); + await expect(page).toHaveScreenshot("history-open.png", { + maxDiffPixelRatio: 0.01, + animations: "disabled", + }); + // Esc closes the modal (Radix dialog behavior the header relies on). + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + }); + // The Contents sidebar is off-canvas by default, so the full-page snapshots // above never see it — #70 (unlinked sidebar entries) was invisible to them. // Open it and snapshot the viewport (not fullPage: stitching a scrolled page