diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a35d0c3..475e5a1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -28,7 +28,7 @@ body: attributes: label: DocSprout version description: Output of `docsprout --version`. - placeholder: docsprout 1.2.2 + placeholder: docsprout 1.2.3 validations: required: true - type: input diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a23e665..348bcdb 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/setup-python@v7 with: python-version: '3.11' - - run: python -m pip install "git+https://github.com/ikelaiah/docsprout.git@v1.2.2" + - run: python -m pip install "git+https://github.com/ikelaiah/docsprout.git@v1.2.3" - run: docsprout check - if: inputs.versioned run: docsprout check-release diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edbc5f..bbddbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 1.2.3 + +A reading-navigation and style-freshness patch. No configuration, route, +machine-format, public CSS token or dependency change is required. Update +package and workflow pins to `v1.2.3`, then rebuild. + +### Changed + +- Reading pages gain linked breadcrumb navigation, an always-present footer + with a back-to-top link, and hover heading anchors on sections. +- Search highlights every query term with a result count, and the results + popover anchors to the search control instead of the page shell. +- The mobile navigation carries a landmark, and the inline outline opens + automatically on the active section. +- Generated `site.css`, `site.js` and `math.js` carry stable content-hash + `?v=` URLs, so rebuilt sites can never serve stale styles or scripts. +- Tables, admonitions, hero, capability cards and print output are polished; + print hides navigation chrome including the sidebar while keeping the + footer in ink. + +### Fixed + +- Breadcrumb separators align on the text baseline with even gutters. +- The glassmorphic hero title no longer prints as invisible transparent text. +- Narrow tables no longer force a scroll frame; small tables fit their column. + ## 1.2.2 A reading and link-validation patch. No configuration, route, machine-format, diff --git a/README.md b/README.md index e134f62..c574cb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🌱 DocSprout -[![CI](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml?query=branch%3Amain) [![Docs](https://img.shields.io/website?url=https%3A%2F%2Fikelaiah.github.io%2Fdocsprout%2F1.2.2%2F&label=docs)](https://ikelaiah.github.io/docsprout/1.2.2/) [![Latest release](https://img.shields.io/github/v/release/ikelaiah/docsprout?display_name=tag&sort=semver)](https://github.com/ikelaiah/docsprout/releases/latest) [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-3776AB?logo=python&logoColor=white)](https://github.com/ikelaiah/docsprout/blob/main/docs/qualification.md#supported-python-versions) [![License: MIT](https://img.shields.io/github/license/ikelaiah/docsprout)](https://github.com/ikelaiah/docsprout/blob/main/LICENSE) +[![CI](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml?query=branch%3Amain) [![Docs](https://img.shields.io/website?url=https%3A%2F%2Fikelaiah.github.io%2Fdocsprout%2F1.2.3%2F&label=docs)](https://ikelaiah.github.io/docsprout/1.2.3/) [![Latest release](https://img.shields.io/github/v/release/ikelaiah/docsprout?display_name=tag&sort=semver)](https://github.com/ikelaiah/docsprout/releases/latest) [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-3776AB?logo=python&logoColor=white)](https://github.com/ikelaiah/docsprout/blob/main/docs/qualification.md#supported-python-versions) [![License: MIT](https://img.shields.io/github/license/ikelaiah/docsprout)](https://github.com/ikelaiah/docsprout/blob/main/LICENSE) ![DocSprout banner — Build documentation people can use.](docs/assets/docsprout-banner.svg) @@ -23,7 +23,7 @@ Open a terminal in your project's top-level folder—the folder that normally contains files such as `README.md`, `src/` or `.git/`—and run: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.2.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.3.zip" docsprout init docsprout serve ``` diff --git a/docs/architecture.md b/docs/architecture.md index 363c406..ba35527 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,6 @@ needs no custom CSS; the custom stylesheet mechanism is a deliberately bounded escape hatch whose inclusion mechanics DocSprout owns and whose accessibility the author owns. -Downstream projects should pin released tags such as `v1.2.2`, never the main +Downstream projects should pin released tags such as `v1.2.3`, never the main branch. The stable boundary and deprecation policy are in [Machine-readable contracts](machine-contracts.md). diff --git a/docs/beginners-guide.md b/docs/beginners-guide.md index b2ea329..2e04d64 100644 --- a/docs/beginners-guide.md +++ b/docs/beginners-guide.md @@ -26,7 +26,7 @@ that normally contains `README.md` or `src/`. Open a terminal in your project folder and run: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.2.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.3.zip" ``` Then check that the command is available: @@ -254,7 +254,7 @@ appear in the navigation. in [How DocSprout renders Markdown](markdown-showcase.md). - Learn the three configuration files in [Configuration](configuration.md). - Copy a small working project from the - [minimal example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/minimal). + [minimal example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/minimal). - When you truly want a public site, choose the simpler or historical path in [GitHub Pages](github-pages.md). - Look up unfamiliar words in the [glossary](glossary.md). diff --git a/docs/building.md b/docs/building.md index 6d3b8cd..6c08906 100644 --- a/docs/building.md +++ b/docs/building.md @@ -24,7 +24,7 @@ asset is local, and `index.html` opens directly in a browser. ## Ship a ZIP ```bash -docsprout build --release 1.2.2 --offline-archive dist/docsprout-docs-1.2.2.zip +docsprout build --release 1.2.3 --offline-archive dist/docsprout-docs-1.2.3.zip ``` The release label used for an offline archive must start with a letter or diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 385535d..8a63524 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -40,7 +40,7 @@ jobs: with: python-version: "3.12" - name: Install DocSprout - run: python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.2.zip" + run: python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.3.zip" - name: Validate the site run: docsprout check - name: Audit publications @@ -52,7 +52,7 @@ jobs: path: audit.json ``` -Pin a released tag (`v1.2.2` above), never a branch. `--format json` writes the +Pin a released tag (`v1.2.3` above), never a branch. `--format json` writes the machine-readable report; the artifact keeps it after the job, including when the audit fails. @@ -76,7 +76,7 @@ is yours. The same checks in GitLab CI: docs-checks: image: python:3.12 script: - - python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.2.zip" + - python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.3.zip" - docsprout check - docsprout audit --strict ``` diff --git a/docs/customisation.md b/docs/customisation.md index 4a1a05f..1dacb48 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -70,8 +70,8 @@ identity asset: Fields that are not relevant here, such as `banner` and `homepage`, are omitted for brevity. You can inspect the complete maintained -[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.2.2/docs/docsprout.json) -and its [local SVG asset](https://github.com/ikelaiah/docsprout/blob/v1.2.2/docs/assets/docsprout-mark.svg). +[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.2.3/docs/docsprout.json) +and its [local SVG asset](https://github.com/ikelaiah/docsprout/blob/v1.2.3/docs/assets/docsprout-mark.svg). On generated DocSprout pages, purple is the accent and highlight colour; the potted sprout mark (green leaves, brown soil and a beige pot) appears immediately before the project name in the top header; diff --git a/docs/existing-repository.md b/docs/existing-repository.md index a052a89..7a364ec 100644 --- a/docs/existing-repository.md +++ b/docs/existing-repository.md @@ -18,7 +18,7 @@ Run both commands from the repository's top-level folder—the one that contains `README.md` or `.git/`: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.2.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.2.3.zip" docsprout init ``` diff --git a/docs/github-pages.md b/docs/github-pages.md index b67252f..a2d522e 100644 --- a/docs/github-pages.md +++ b/docs/github-pages.md @@ -48,7 +48,7 @@ from feature branches do not deploy Pages. workflow are current, it makes no repository changes. The generated workflow carries a DocSprout ownership marker and pins the reusable -workflow to the installed release, such as `@v1.2.2`. It never follows +workflow to the installed release, such as `@v1.2.3`. It never follows `@main`. If a recognised managed workflow is older, ordinary setup reports the version and leaves it unchanged. Update only that workflow deliberately: @@ -101,13 +101,13 @@ jobs: contents: read pages: write id-token: write - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.2 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.3 with: versioned: false release: latest ``` -The maintained [single-version example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/single-version) +The maintained [single-version example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/single-version) uses this form. ## Historical site for versioned projects @@ -129,7 +129,7 @@ jobs: contents: read pages: write id-token: write - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.2 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.3 ``` Check a historical release locally after creating its tag and before pushing: @@ -140,7 +140,7 @@ docsprout check-release docsprout build-all --output build/docs-site ``` -The maintained [historical example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/historical) +The maintained [historical example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/historical) uses this form. Follow the [pre-publish checklist](pre-publish-checklist.md) for the exact release order. diff --git a/docs/historical-docs.md b/docs/historical-docs.md index 71de513..00ec5f0 100644 --- a/docs/historical-docs.md +++ b/docs/historical-docs.md @@ -71,6 +71,6 @@ the release check and full build, then push the tag. Use the exact commands in t [pre-publish checklist](pre-publish-checklist.md). The maintained -[historical example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/historical) +[historical example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/historical) demonstrates two small releases. Its automated test builds the same Git history twice and compares every generated byte. diff --git a/docs/homepage-recipes.md b/docs/homepage-recipes.md index 8ff350e..e1f4966 100644 --- a/docs/homepage-recipes.md +++ b/docs/homepage-recipes.md @@ -18,7 +18,7 @@ which is a top-level setting, are `homepage` values. ## See it in DocSprout DocSprout uses the same configuration on this site. Its -[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.2.2/docs/docsprout.json) +[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.2.3/docs/docsprout.json) uses these exact values for its home-page banner and capability cards: ```json diff --git a/docs/migration.md b/docs/migration.md index f1d67fb..fd90dac 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -3,7 +3,7 @@ The 1.x upgrades are deliberately boring: v1.0.0 froze the qualified v0.18.1 surface, and every later 1.x release adds no configuration schema change. Pin the released package and workflow, then run the same checks you already use. -Current guidance targets `v1.2.2`; each section below remains authoritative for +Current guidance targets `v1.2.3`; each section below remains authoritative for its own step. ## DocKit to DocSprout (1.x rebrand) @@ -64,7 +64,7 @@ each published release manifest on immutable tags while upgrading. ## Upgrade directly from any supported 0.x release All released 0.x configurations use schema version 1. Upgrade the pinned package -and workflow to the latest 1.x release (`v1.2.2`), then use this table before +and workflow to the latest 1.x release (`v1.2.3`), then use this table before running `doctor`, `check`, and the appropriate publish build. | Starting release | Required compatibility work | @@ -96,6 +96,17 @@ segments and option-like refs, requires the current source to match `HEAD`, and requires documentation changes to be committed. These checks make existing valid manifests more dependable; they do not change generated routes. +## v1.2.2 to v1.2.3 + +No configuration or content migration is required. Update package and workflow +pins to `v1.2.3`, then rebuild. Reading pages gain linked breadcrumb +navigation, an always-present footer with a back-to-top link, hover heading +anchors, multi-term search highlighting with result counts, and a search +popover anchored to the search control. Generated `site.css`, `site.js` and +`math.js` now carry stable content-hash `?v=` URLs, so rebuilt sites can never +serve stale styles or scripts from a cache. Print keeps the footer in ink and +hides navigation chrome including the sidebar. + ## v1.2.1 to v1.2.2 No configuration or content migration is required. Update package and workflow @@ -284,7 +295,7 @@ the contract qualified by v0.18.1: 1. Pin the package archive or source installation to `v1.0.0` and pin the reusable Pages workflow to `@v1.0.0`; never use `main`. For a new upgrade - today, pin the latest 1.x release (`v1.2.2`) instead. + today, pin the latest 1.x release (`v1.2.3`) instead. 2. Keep `"schema_version": 1` in `docsprout.json`, `layout.json` and `versions.json`. There is no schema rewrite or generated-route migration. 3. If custom CSS still uses pre-v0.18 generic names such as `--bg`, `--text` @@ -416,9 +427,9 @@ safe, readable plain code. Version 1.0 is the formal commitment point for the contracts that v0.18.1 qualified, and later 1.x releases keep that contract. There is no schema change. -Before adopting the latest 1.x release (`v1.2.2`): +Before adopting the latest 1.x release (`v1.2.3`): -1. Upgrade the pinned package and Pages workflow to `v1.2.2` following this +1. Upgrade the pinned package and Pages workflow to `v1.2.3` following this guide's patterns (config-compatible, then rebuild). 2. Remove references to any pre-v0.18 generic theme token names (`--bg`, `--text`, `--interactive`, …) in custom CSS; the `--dk-*` names diff --git a/docs/pre-publish-checklist.md b/docs/pre-publish-checklist.md index 5a9ed20..deedf46 100644 --- a/docs/pre-publish-checklist.md +++ b/docs/pre-publish-checklist.md @@ -62,10 +62,10 @@ This path updates the public site from a branch such as `main`. ## Historical site This path publishes an immutable site for every listed release. In the example -below, replace `1.2.2` with your version. +below, replace `1.2.3` with your version. -1. Add release `1.2.2` and source ref `v1.2.2` to `docs/versions.json`. Set - `current` to `1.2.2`. +1. Add release `1.2.3` and source ref `v1.2.3` to `docs/versions.json`. Set + `current` to `1.2.3`. 2. Run `docsprout check`. 3. Commit all release files, including package metadata, documentation, examples and workflows, on a branch named `release/vX.Y.Z`: @@ -73,7 +73,7 @@ below, replace `1.2.2` with your version. ```bash git add pyproject.toml src/docsprout/__init__.py README.md CHANGELOG.md docs .github examples tests git diff --cached --check - git commit -m "Prepare v1.2.2 documentation" + git commit -m "Prepare v1.2.3 documentation" ``` 4. Push the qualified branch and open a PR against `main`. Wait for every @@ -83,7 +83,7 @@ below, replace `1.2.2` with your version. 5. Check out the merged `main` commit and create the tag on that exact commit: ```bash - git tag -a v1.2.2 -m "v1.2.2" + git tag -a v1.2.3 -m "v1.2.3" ``` 6. Run the release checks and historical build: @@ -100,7 +100,7 @@ below, replace `1.2.2` with your version. 8. Push the annotated tag (the merge commit is already on `main`): ```bash - git push origin v1.2.2 + git push origin v1.2.3 ``` 9. Create the GitHub Release from the tag. Confirm both CI and the Documentation @@ -111,7 +111,7 @@ below, replace `1.2.2` with your version. ## If you find a mistake If the tag exists only on your computer, correct and commit the files, delete -the local tag with `git tag -d v1.2.2`, then create it again on the corrected +the local tag with `git tag -d v1.2.3`, then create it again on the corrected commit. If the tag has been pushed, do not move or replace it. Publish the correction diff --git a/docs/qualification.md b/docs/qualification.md index 13f6de1..07fc17a 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -1,6 +1,6 @@ -# Qualification evidence for DocSprout v1.2.2 +# Qualification evidence for DocSprout v1.2.3 -DocSprout v1.2.2 improves keyboard access and reading-page behavior +DocSprout v1.2.3 improves reading-page navigation, search and style freshness while preserving JSON and Markdown layout compatibility and the v1.0.0 stable contract. The claims below are the contract that CI and the maintained fixtures exercise. Every row names how it is verified. "Supported" means the combination @@ -287,6 +287,18 @@ site configuration; no schema, route or token value changes: distinguish page structure from home-page presentation, and the glossary covers the navigation, theme and command vocabulary. +## v1.2.3 additions + +- Generated-page tests cover linked breadcrumb navigation, the always-present + footer with back-to-top link, hover heading anchors, a search popover + anchored to the search control with multi-term highlighting and result + counts, the mobile navigation landmark and auto-opening inline outline. +- Build tests cover stable content-hash `?v=` asset URLs, print rules that + hide navigation chrome while keeping the footer in ink, and the shared + z-index and sticky-offset token contract. +- The visual fixture checklist records the manual keyboard, phone-width and + progress checks. Browser and screen-reader checks remain manual release steps. + ## v1.2.2 additions - Generated-page tests cover the skip link, focusable article target, compact diff --git a/docs/themes.md b/docs/themes.md index a14ebb1..a4b04ac 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -43,7 +43,7 @@ footer—never underlines, signalling hover with a background tint or colour shift instead, and buttons never underline. The maintained -[single-version example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/single-version) +[single-version example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/single-version) starts in Paper (`"style": "paper"`), so its built opening page shows the warm reading surface before a visitor changes the control. @@ -110,7 +110,7 @@ hexadecimal values: Test custom colours in both Light and Dark mode. Links, selected navigation and keyboard focus must remain easy to see. A preset is safer when you are unsure. The maintained -[minimal example](https://github.com/ikelaiah/docsprout/tree/v1.2.2/examples/minimal) +[minimal example](https://github.com/ikelaiah/docsprout/tree/v1.2.3/examples/minimal) uses the exact teal values shown above; its built links, selected navigation and focus state use that accent. diff --git a/docs/versions.json b/docs/versions.json index bb54e86..69a6c6d 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,7 +1,8 @@ { "schema_version": 1, - "current": "1.2.2", + "current": "1.2.3", "versions": [ + {"release": "1.2.3", "source_ref": "v1.2.3"}, {"release": "1.2.2", "source_ref": "v1.2.2"}, {"release": "1.2.1", "source_ref": "v1.2.1"}, {"release": "1.2.0", "source_ref": "v1.2.0"}, diff --git a/examples/github-pages/.github/workflows/docsprout-pages.yml b/examples/github-pages/.github/workflows/docsprout-pages.yml index 205fc75..94b6ad1 100644 --- a/examples/github-pages/.github/workflows/docsprout-pages.yml +++ b/examples/github-pages/.github/workflows/docsprout-pages.yml @@ -13,7 +13,7 @@ concurrency: jobs: documentation: if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.2 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.3 with: versioned: false release: latest diff --git a/examples/historical/.github/workflows/documentation.yml b/examples/historical/.github/workflows/documentation.yml index 40a6e16..7d365c8 100644 --- a/examples/historical/.github/workflows/documentation.yml +++ b/examples/historical/.github/workflows/documentation.yml @@ -12,7 +12,7 @@ permissions: jobs: documentation: - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.2 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.3 permissions: contents: read pages: write diff --git a/examples/single-version/.github/workflows/documentation.yml b/examples/single-version/.github/workflows/documentation.yml index 20604f1..dda7289 100644 --- a/examples/single-version/.github/workflows/documentation.yml +++ b/examples/single-version/.github/workflows/documentation.yml @@ -12,7 +12,7 @@ permissions: jobs: documentation: - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.2 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.2.3 with: versioned: false release: latest diff --git a/pyproject.toml b/pyproject.toml index 8aa7a24..56448d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "docsprout" -version = "1.2.2" +version = "1.2.3" description = "Versioned static documentation sites for code projects" keywords = ["documentation", "static-site", "markdown", "github-pages"] readme = "README.md" diff --git a/src/docsprout/__init__.py b/src/docsprout/__init__.py index f567a1c..e3ab945 100644 --- a/src/docsprout/__init__.py +++ b/src/docsprout/__init__.py @@ -1,3 +1,3 @@ """DocSprout: versioned static documentation sites for code projects.""" -__version__ = "1.2.2" +__version__ = "1.2.3" diff --git a/src/docsprout/assets.py b/src/docsprout/assets.py index 8ad993f..af44c37 100644 --- a/src/docsprout/assets.py +++ b/src/docsprout/assets.py @@ -1,5 +1,7 @@ """Shared DocSprout browser assets.""" +from hashlib import sha1 + SITE_CSS = r''' :root{ color-scheme:light dark; @@ -30,6 +32,12 @@ --dk-shadow:0 .75rem 2rem color-mix(in srgb,#1e293b 12%,transparent); --dk-focus-offset:.2rem; --dk-control-height:2.5rem; + --dk-z-header:2; + --dk-z-progress:3; + --dk-z-results:4; + --dk-z-skip:5; + --dk-sticky-top:6rem; + --dk-sticky-offset:7rem; --dk-font-ui:system-ui,-apple-system,"Segoe UI",Inter,"Aptos",sans-serif; --dk-font-display:var(--dk-font-ui); --dk-font-body:var(--dk-font-ui); @@ -49,14 +57,16 @@ @media(prefers-color-scheme:dark){html[data-visual-theme="paper"]:not([data-theme]){color-scheme:dark;--dk-bg:#1c1a17;--dk-surface:#29251f;--dk-text:#f7f0e5;--dk-muted:#c9bcaa;--dk-border:#554b3d;--dk-code-bg:#11100e;--dk-raised:#24211c;--dk-focus-ring:#fdba74;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff);--dk-shadow:0 .75rem 2rem color-mix(in srgb,#000 55%,transparent),0 1px 0 color-mix(in srgb,#f7f0e5 7%,transparent)}} @media(prefers-color-scheme:dark){html[data-visual-theme="e-ink"]:not([data-theme]){color-scheme:dark;--dk-bg:#0d0d0d;--dk-surface:#181818;--dk-text:#dadada;--dk-muted:#9b9b9b;--dk-border:#303030;--dk-code-bg:#050505;--dk-code-text:#d6d6d6;--dk-raised:#1e1e1e;--dk-focus-ring:#f5f5f5;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff)}} @media(prefers-color-scheme:dark){html[data-visual-theme="glassmorphic"]:not([data-theme]){color-scheme:dark;--dk-bg:#070b16;--dk-surface:#111b2e;--dk-text:#e9eeff;--dk-muted:#98abd0;--dk-border:#2b3a58;--dk-code-bg:#050810;--dk-raised:#1a2540;--dk-focus-ring:#67e8f9;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff);--dk-shadow:0 .75rem 2rem color-mix(in srgb,#000 55%,transparent),0 1px 0 color-mix(in srgb,#fff 6%,transparent);--dk-mesh-1:15%;--dk-mesh-2:14%;--dk-mesh-3:11%;--dk-mesh-4:9%}} +/* Base: stacking order (--dk-z-*) and sticky offsets (--dk-sticky-*) are defined in :root. */ *{box-sizing:border-box} +html{scroll-padding-top:6.5rem} .visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} -.skip-link{position:fixed;top:.5rem;left:.5rem;z-index:5;transform:translateY(-200%);padding:.5rem .75rem;border:2px solid var(--dk-focus-ring);border-radius:var(--dk-radius);background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-ui);font-weight:700} +.skip-link{position:fixed;top:.5rem;left:.5rem;z-index:var(--dk-z-skip);transform:translateY(-200%);padding:.5rem .75rem;border:2px solid var(--dk-focus-ring);border-radius:var(--dk-radius);background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-ui);font-weight:700} .skip-link:focus{transform:none} body{margin:0;background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-body);font-size:17px;line-height:1.7;font-synthesis:none;font-kerning:normal;font-optical-sizing:auto;font-variant-ligatures:common-ligatures contextual;text-rendering:optimizeLegibility} ::selection{background:color-mix(in srgb,var(--dk-accent) 24%,transparent)} -.reading-progress{position:fixed;inset:0 0 auto;z-index:3;height:3px;background:var(--dk-border);pointer-events:none}.reading-progress span{display:block;width:100%;height:100%;background:var(--dk-accent);transform:scaleX(0);transform-origin:left} -.site-header{position:sticky;top:0;z-index:2;border-bottom:1px solid var(--dk-border);background:var(--dk-bg)} +.reading-progress{position:fixed;inset:0 0 auto;z-index:var(--dk-z-progress);height:3px;background:var(--dk-border);pointer-events:none}.reading-progress span{display:block;width:100%;height:100%;background:var(--dk-accent);transform:scaleX(0);transform-origin:left} +.site-header{position:sticky;top:0;z-index:var(--dk-z-header);border-bottom:1px solid var(--dk-border);background:var(--dk-bg)} :is(.site-header,.sidebar,.toc,.mobile-nav,.capability-strip,.hero-actions,.release-context){font-family:var(--dk-font-ui)} .brand>span{color:var(--dk-text)}.brand em{color:var(--dk-interactive);font-style:normal} .brand-mark{display:block;width:1.35rem;height:1.35rem;flex:0 0 1.35rem} @@ -67,7 +77,7 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid var(--dk-focus-ring);outline-offset:3px} .shell{max-width:var(--dk-shell-width);margin:auto;display:grid;grid-template-columns:minmax(12rem,15rem) minmax(0,var(--dk-content-width)) minmax(10rem,13rem);gap:clamp(1.75rem,3.5vw,3.5rem);padding:clamp(2rem,5vw,4rem) clamp(1rem,3vw,2rem) 3rem} .shell-no-toc{grid-template-columns:minmax(12rem,15rem) minmax(0,var(--dk-content-width));justify-content:center} -.sidebar{position:sticky;top:6rem;align-self:start;max-height:calc(100vh - 7rem);overflow:auto;padding-right:.35rem;font-size:.86rem;line-height:1.45;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} +.sidebar{position:sticky;top:var(--dk-sticky-top);align-self:start;max-height:calc(100vh - var(--dk-sticky-offset));overflow:auto;padding-right:.35rem;font-size:.86rem;line-height:1.45;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} .sidebar a{display:block;padding:.42rem .7rem;border-left:2px solid transparent;border-radius:0 .25rem .25rem 0;color:var(--dk-text);font-size:.88rem;font-weight:500;text-decoration:none;overflow-wrap:anywhere} .sidebar a:hover{background:var(--dk-surface);color:var(--dk-text);text-decoration:none} .sidebar a.active{border-left:2px solid var(--dk-accent);background:color-mix(in srgb,var(--dk-accent) 9%,transparent);color:var(--dk-interactive);font-weight:700;text-decoration:none} @@ -82,7 +92,7 @@ .nav-group-links{display:flex;flex-direction:column;margin:.15rem 0 .35rem .7rem;padding-left:.55rem;border-left:1px solid var(--dk-border)} .nav-group-links a{padding:.34rem .6rem;font-size:.84rem} .prose{min-width:0;max-width:var(--dk-content-width);padding-bottom:var(--dk-space-2);scroll-margin-top:6.5rem} -.page-context{margin:0 0 .85rem;color:var(--dk-muted);font-family:var(--dk-font-ui);font-size:.72rem;font-weight:700;letter-spacing:.08em;line-height:1.4;text-transform:uppercase;overflow-wrap:anywhere} +.page-context{margin:0 0 .85rem;color:var(--dk-muted);font-family:var(--dk-font-ui);font-size:.72rem;font-weight:700;letter-spacing:.08em;line-height:1.4;text-transform:uppercase;overflow-wrap:anywhere}.page-context ol{list-style:none;display:flex;flex-wrap:wrap;align-items:baseline;gap:.35rem 0;margin:0;padding:0}.page-context li{display:inline}.page-context li+li::before{content:"/";margin:0 .6rem;color:var(--dk-muted);opacity:.55;letter-spacing:0}.page-context a{color:var(--dk-muted);text-decoration:none}.page-context a:hover{color:var(--dk-interactive);text-decoration:underline} .prose h1,.prose h2,.prose h3{font-family:var(--dk-font-display);font-weight:720;letter-spacing:-.032em;text-wrap:balance;scroll-margin-top:6.5rem} .prose h1{font-size:clamp(2.3rem,4vw,3rem);line-height:1.12;max-width:none;margin:0 0 1.25rem} .prose h2{margin:3.5rem 0 .8rem;padding-top:.25rem;font-size:1.7rem;line-height:1.2} @@ -101,7 +111,7 @@ html[data-visual-theme="paper"] .prose blockquote{border-left-color:color-mix(in srgb,var(--dk-accent) 38%,var(--dk-border));background:color-mix(in srgb,var(--dk-surface) 62%,transparent)} html[data-visual-theme="paper"] .prose hr{border-top-style:dashed;border-top-color:color-mix(in srgb,var(--dk-muted) 45%,transparent)} /* E-ink keeps structural rules and removes decorative depth and motion. */ -html[data-visual-theme="e-ink"]{--dk-font-ui:var(--dk-font-mono);--dk-font-body:system-ui,sans-serif;--dk-font-display:var(--dk-font-body)} +html[data-visual-theme="e-ink"]{--dk-font-body:system-ui,sans-serif;--dk-font-display:var(--dk-font-body)} html[data-visual-theme="e-ink"] :is(.prose pre,.capability-strip li,.search-results,.hero){box-shadow:none;border-color:var(--dk-border)} html[data-visual-theme="e-ink"] .hero{border-color:var(--dk-text)} html:not([data-visual-theme="glassmorphic"]) .hero::before,html:not([data-visual-theme="glassmorphic"]) .hero::after{display:none} @@ -118,9 +128,9 @@ html[data-visual-theme="e-ink"] ::selection,html[data-visual-theme="e-ink"]::selection{background:color-mix(in srgb,var(--dk-text) 18%,transparent)} .task-list{display:inline-block;width:1.25em;color:var(--dk-interactive);font-weight:700} .prose .capability-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));align-items:stretch;grid-template-rows:minmax(0,1fr);gap:var(--dk-space-3);margin:0 0 2.75rem;padding:0;list-style:none} -.capability-strip li{display:flex;flex-direction:column;align-self:stretch;min-height:0;padding:1.1rem 1.15rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);max-width:none;background:var(--dk-raised);line-height:1.5;transition:border-color .18s ease,box-shadow .18s ease} -.capability-strip strong{display:block;font-family:var(--dk-font-display);font-size:.8rem;font-weight:750;letter-spacing:.01em;line-height:1.35;color:var(--dk-text)} -.capability-strip span{display:block;margin-top:.5rem;color:var(--dk-muted);font-size:.8rem;line-height:1.6} +.capability-strip li{display:flex;flex-direction:column;align-self:stretch;min-height:0;padding:1.15rem 1.2rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);max-width:none;background:var(--dk-raised);line-height:1.5;transition:border-color .18s ease,box-shadow .18s ease,transform .18s ease} +.capability-strip strong{display:block;font-family:var(--dk-font-display);font-size:.88rem;font-weight:750;letter-spacing:.01em;line-height:1.35;color:var(--dk-text)} +.capability-strip span{display:block;margin-top:.5rem;color:var(--dk-muted);font-size:.84rem;line-height:1.6} .release-context{display:flex;gap:.65rem;align-items:baseline;margin:1.5rem 0;padding:.75rem 1rem;border-left:3px solid var(--dk-accent);border-radius:0 .3rem .3rem 0;background:var(--dk-surface);font-size:.82rem}.release-context strong{font-family:var(--dk-font-display);font-size:.76rem;letter-spacing:.08em;text-transform:uppercase}.release-context span{color:var(--dk-muted);font-family:var(--dk-font-mono);font-size:.8rem} .prose a{color:var(--dk-interactive);font-weight:650;text-decoration-thickness:.08em;text-underline-offset:.16em} .prose a:hover{text-decoration-thickness:.13em} @@ -128,18 +138,19 @@ .prose code{font-family:var(--dk-font-mono);font-size:.88em} .prose :not(pre)>code{background:var(--dk-surface);border:1px solid var(--dk-border);padding:.08rem .28rem;border-radius:.2rem;color:var(--dk-text);font-weight:650;line-height:1.4;hyphens:none;overflow-wrap:break-word} .syntax-highlight .tok-property{color:#93c5fd}.syntax-highlight .tok-keyword{color:#c4b5fd}.syntax-highlight .tok-function{color:#67e8f9}.syntax-highlight .tok-string{color:#86efac}.syntax-highlight .tok-number{color:#fcd34d}.syntax-highlight .tok-boolean{color:#f9a8d4}.syntax-highlight .tok-constant{color:#fda4af}.syntax-highlight .tok-comment{color:#94a3b8;font-style:italic}.syntax-highlight .tok-heading{color:#93c5fd;font-weight:700}.syntax-highlight .tok-code{color:#fcd34d} -.copy-code{position:absolute;top:.65rem;right:.65rem;min-height:2rem;padding:.3rem .55rem;border-color:#ffffff3d;border-radius:.25rem;background:#ffffff12;color:inherit;font-family:var(--dk-font-mono);font-size:.7rem;line-height:1.3;letter-spacing:.01em;opacity:.78;transition:opacity .15s ease}.prose pre:hover .copy-code,.copy-code:focus-visible{opacity:1} +.copy-code{position:absolute;top:.65rem;right:.65rem;min-height:2rem;padding:.3rem .55rem;border-color:#ffffff59;border-color:color-mix(in srgb,#fff 35%,transparent);border-radius:.25rem;background:#ffffff12;background:color-mix(in srgb,#fff 7%,transparent);color:inherit;font-family:var(--dk-font-mono);font-size:.7rem;line-height:1.3;letter-spacing:.01em;opacity:.78;transition:opacity .15s ease}.prose pre:hover .copy-code,.copy-code:focus-visible{opacity:1} .table-scroll{overflow:auto;margin:1.5rem 0;border:1px solid var(--dk-border);border-radius:var(--dk-radius);-webkit-overflow-scrolling:touch;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} -.prose table{border-collapse:collapse;min-width:32rem;width:100%;font-size:.9rem;line-height:1.5;font-variant-numeric:tabular-nums} +.prose table{border-collapse:collapse;min-width:min(32rem,100%);width:100%;font-size:.9rem;line-height:1.5;font-variant-numeric:tabular-nums} .prose th,.prose td{border:0;border-bottom:1px solid var(--dk-border);padding:.75rem .9rem;text-align:left;vertical-align:top} .prose tr:last-child td{border-bottom:0}.prose tbody tr:nth-child(even){background:color-mix(in srgb,var(--dk-surface) 62%,transparent)} .prose th{font-family:var(--dk-font-display);font-weight:700;background:var(--dk-surface);font-size:.88rem;letter-spacing:.01em} .admonition{--admonition-accent:var(--dk-accent);display:grid;grid-template-columns:auto minmax(0,1fr);gap:.15rem .75rem;max-width:var(--dk-reading-width);margin:1.6rem 0;padding:1rem 1.1rem;border:1px solid var(--dk-border);border-left:4px solid var(--admonition-accent);border-radius:0 .4rem .4rem 0;background:var(--dk-surface)} .admonition.note{--admonition-accent:#0284c7}.admonition.important{--admonition-accent:#7c3aed}.admonition.tip{--admonition-accent:#0f766e}.admonition.warning{--admonition-accent:#b45309} .banner{display:block;width:100%;max-width:100%;height:auto;max-height:16rem;margin:0 0 1.75rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);object-fit:cover} -.search-results{position:absolute;top:100%;left:max(1rem,calc((100% - var(--dk-shell-width))/2 + 16rem));width:min(34rem,calc(100% - 2rem));max-height:min(60vh,30rem);overflow:auto;padding:.45rem;background:var(--dk-raised);border:1px solid var(--dk-border);border-radius:var(--dk-radius);box-shadow:var(--dk-shadow)} -.search-results a{display:block;padding:.7rem .75rem;border-radius:.25rem;color:var(--dk-text);text-decoration:none}.search-results small{display:block;margin-top:.2rem;color:var(--dk-muted);font-size:.76rem;line-height:1.45}.search-results mark{padding:0 .1em;background:color-mix(in srgb,var(--dk-accent) 22%,transparent);border-radius:.1rem;color:var(--dk-text);font-weight:700}.search-empty{margin:.25rem;padding:.7rem .75rem;color:var(--dk-muted);font-size:.82rem} -.mobile-nav{display:none}.toc{position:sticky;top:6rem;align-self:start;max-height:calc(100vh - 7rem);overflow:auto;padding-left:1rem;border-left:1px solid var(--dk-border);font-size:.8rem;line-height:1.4;color:var(--dk-muted);scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} +/* Search popover: anchored to .search-control, floats above content at --dk-z-results. */ +.search-results{position:absolute;top:calc(100% + .4rem);left:0;right:0;width:auto;max-width:34rem;max-height:min(60vh,30rem);overflow:auto;padding:.45rem;background:var(--dk-raised);border:1px solid var(--dk-border);border-radius:var(--dk-radius);box-shadow:var(--dk-shadow);z-index:var(--dk-z-results)} +.search-results a{display:block;padding:.7rem .75rem;border-radius:.25rem;color:var(--dk-text);text-decoration:none}.search-results small{display:block;margin-top:.2rem;color:var(--dk-muted);font-size:.76rem;line-height:1.45}.search-results mark{padding:0 .1em;background:color-mix(in srgb,var(--dk-accent) 22%,transparent);border-radius:.1rem;color:var(--dk-text);font-weight:700}.search-empty{margin:.25rem;padding:.7rem .75rem;color:var(--dk-muted);font-size:.82rem}.search-count{margin:.25rem .75rem .35rem;color:var(--dk-muted);font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase} +.mobile-nav{display:none}.toc{position:sticky;top:var(--dk-sticky-top);align-self:start;max-height:calc(100vh - var(--dk-sticky-offset));overflow:auto;padding-left:1rem;border-left:1px solid var(--dk-border);font-size:.8rem;line-height:1.4;color:var(--dk-muted);scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} .inline-toc{display:none} .inline-toc summary{padding:.65rem 0;font-family:var(--dk-font-ui);font-size:.86rem;font-weight:700} .inline-toc nav{margin-top:.75rem;padding-top:.65rem;border-top:1px solid var(--dk-border)} @@ -168,8 +179,8 @@ /* Shared surfaces and type; each style changes only its material and signature. */ .hero{position:relative;isolation:isolate;margin:0 0 2rem;padding:clamp(1.5rem,3vw,2.5rem);border:1px solid color-mix(in srgb,var(--dk-accent) 20%,var(--dk-border));border-radius:calc(var(--dk-radius)*2);background:var(--dk-surface);overflow:hidden} .hero::before,.hero::after{content:"";position:absolute;inset:0;z-index:-1} -.hero::after{background-image:radial-gradient(color-mix(in srgb,var(--dk-accent) 26%,transparent) 1px,transparent 1px);background-size:1.15rem 1.15rem;-webkit-mask-image:linear-gradient(155deg,#000 8%,transparent 58%);mask-image:linear-gradient(155deg,#000 8%,transparent 58%)} -.hero h1{font-size:clamp(2.4rem,5vw,3.4rem);letter-spacing:-.04em;margin-bottom:1rem;overflow-wrap:break-word} +.hero::after{background-image:radial-gradient(color-mix(in srgb,var(--dk-accent) 14%,transparent) 1px,transparent 1px);background-size:1.15rem 1.15rem;-webkit-mask-image:linear-gradient(155deg,#000 8%,transparent 58%);mask-image:linear-gradient(155deg,#000 8%,transparent 58%)} +.hero h1{font-size:clamp(2.3rem,4vw,3.1rem);letter-spacing:-.04em;margin-bottom:1rem;overflow-wrap:break-word} .hero-copy{min-width:0} .hero .hero-copy>p{max-width:42rem;color:var(--dk-muted);font-size:1.16rem;line-height:1.62} .hero .release-context{display:inline-flex;align-self:flex-start;margin:0 0 1.15rem} @@ -204,8 +215,8 @@ .syntax-highlight .tok-keyword{font-weight:650} .syntax-highlight .tok-function{font-weight:600} .prose tbody tr:hover{background:color-mix(in srgb,var(--dk-surface) 82%,transparent)} -.admonition>strong{grid-row:1;color:var(--admonition-accent);font-size:.76rem;letter-spacing:.07em;line-height:1.6;text-transform:uppercase} -.admonition>p{grid-column:2;margin:0;line-height:1.6} +.admonition>strong{grid-row:1;color:var(--admonition-accent);font-size:.76rem;letter-spacing:.07em;line-height:1.6;text-transform:uppercase}.admonition>strong::before{content:"";display:inline-block;width:.55em;height:.55em;margin-right:.45em;border-radius:50%;background:var(--admonition-accent);vertical-align:baseline} +.admonition>:not(strong){grid-column:2;min-width:0;margin:0;line-height:1.6}.admonition>:not(strong)+:not(strong){margin-top:.6rem} .search-results a+a{margin-top:.15rem} .search-results a:hover,.search-results a:focus-visible{background:var(--dk-surface);text-decoration:none} .search-results a strong{display:block;font-family:var(--dk-font-display);font-size:.9rem;line-height:1.35} @@ -213,7 +224,9 @@ .toc .toc-level-4{padding-left:1.5rem;font-size:.96em} .toc .toc-level-5{padding-left:1.85rem;font-size:.92em} .toc .toc-level-6{padding-left:2.2rem;font-size:.92em} -@media(hover:hover){.capability-strip li:hover{border-color:var(--dk-interactive)}} +@media(hover:hover){.capability-strip li:hover{border-color:var(--dk-interactive)}html:not([data-visual-theme="e-ink"]) .capability-strip li:hover{transform:translateY(-2px);box-shadow:var(--dk-shadow)}} +.prose :is(h2,h3)[id]{position:relative}.heading-anchor{margin-left:.5rem;color:var(--dk-muted);font-size:.75em;font-weight:400;text-decoration:none;opacity:0}.prose :is(h2,h3):hover .heading-anchor,.prose :is(h2,h3):focus-within .heading-anchor,.heading-anchor:focus-visible{opacity:1}.heading-anchor:hover{color:var(--dk-interactive)} +.site-footer .back-to-top{margin-left:auto;color:var(--dk-muted);font-weight:600;text-decoration:none}.site-footer .back-to-top:hover{color:var(--dk-interactive);text-decoration:underline} /* Glass composites stay within palette.py contrast bounds; blur is optional. */ html[data-visual-theme="glassmorphic"] body{background:radial-gradient(46rem 32rem at 10% 12%,color-mix(in srgb,var(--dk-accent) var(--dk-mesh-1),transparent),transparent 58%),radial-gradient(38rem 30rem at 94% 8%,color-mix(in srgb,var(--dk-accent-secondary) var(--dk-mesh-2),transparent),transparent 55%),radial-gradient(42rem 34rem at 52% 118%,color-mix(in srgb,var(--dk-accent) var(--dk-mesh-3),transparent),transparent 60%),radial-gradient(28rem 24rem at 76% 74%,color-mix(in srgb,var(--dk-accent-secondary) var(--dk-mesh-4),transparent),transparent 55%),var(--dk-bg);background-attachment:fixed} html[data-visual-theme="glassmorphic"] :is(.site-header,.hero,.capability-strip li,.release-context,.search-results,.sidebar,.toc,.mobile-nav){background:var(--dk-glass);border-color:color-mix(in srgb,var(--dk-raised) 30%,var(--dk-border));box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dk-raised) 70%,transparent),0 1.25rem 2.8rem -.55rem color-mix(in srgb,var(--dk-bg) 65%,transparent)} @@ -230,7 +243,7 @@ html[data-visual-theme="paper"] .hero{background:var(--dk-raised);border-width:0 0 1px;border-radius:0} html[data-visual-theme="paper"] .hero .hero-copy{padding:clamp(1rem,2vw,1.5rem) 0} html[data-visual-theme="paper"] .hero .hero-copy>p{font-style:italic} -html[data-visual-theme="paper"] .prose>p:first-of-type::first-letter{float:left;margin:.06em .12em 0 0;font-family:var(--dk-font-display);font-size:3.1em;font-weight:700;line-height:1;color:var(--dk-interactive)} +html[data-visual-theme="paper"] .prose[data-homepage="true"]>p:first-of-type::first-letter{float:left;margin:.06em .12em 0 0;font-family:var(--dk-font-display);font-size:3.1em;font-weight:700;line-height:1;color:var(--dk-interactive)} html[data-visual-theme="e-ink"] .prose h1{border-bottom:3px double var(--dk-text);padding-bottom:.5rem} @supports ((background-clip:text) or (-webkit-background-clip:text)){html[data-visual-theme="glassmorphic"] .hero h1{background:linear-gradient(135deg,var(--dk-interactive),var(--dk-text));-webkit-background-clip:text;background-clip:text;color:transparent}} html[data-visual-theme="glassmorphic"] .hero-emoji h1{background:none;color:var(--dk-text)} @@ -255,6 +268,7 @@ .mobile-nav a.active{color:var(--dk-interactive);font-weight:700} .toc{display:none} .prose h1{font-size:2.15rem} + html{scroll-padding-top:9rem} .prose{scroll-margin-top:9rem} .prose[data-homepage="false"] :is(h1,h2,h3,h4,h5,h6){scroll-margin-top:9rem} .copy-code{opacity:1} @@ -269,6 +283,7 @@ .header-control{flex:1;min-width:0} .header-control>span{font-size:.6rem} .topbar select{width:100%;min-width:0} + html{scroll-padding-top:12rem} .prose{scroll-margin-top:12rem} .prose[data-homepage="false"] :is(h1,h2,h3,h4,h5,h6){scroll-margin-top:12rem} .prose .capability-strip,.prose[data-homepage="true"] .capability-strip{grid-template-columns:1fr} @@ -281,10 +296,24 @@ .header-control select{font-size:.75rem} .prose pre{margin-inline:-.25rem;padding-inline:1rem} } +/* Pre-color-mix fallback: solid surfaces where mixing is unsupported; decoration simply drops. */ +@supports not (color:color-mix(in srgb,red 50%,blue)){ + ::selection{background:#bcd} + .sidebar a.active{background:var(--dk-surface)} + .prose .inline-toc a.is-active{background:var(--dk-surface)} + .search-results mark{background:#fef08a} + .prose tbody tr:nth-child(even){background:var(--dk-surface)} + .prose tbody tr:hover{background:var(--dk-surface)} + .hero{border-color:var(--dk-border)} + .prose pre{border-color:var(--dk-border)} +} +/* Print: navigation chrome off, article and footer in ink. */ @media print{ - .skip-link,.reading-progress,.site-header,.mobile-nav,.toc,.inline-toc,.page-navigation,.hero-actions{display:none} + .skip-link,.reading-progress,.site-header,.sidebar,.mobile-nav,.toc,.inline-toc,.page-navigation,.hero-actions,.search-results,.copy-code{display:none} + .site-footer,.site-footer a{color:#000} .hero::before,.hero::after{display:none} .hero{border-color:#999;background:none} + html[data-visual-theme="glassmorphic"] .hero h1{background:none!important;color:#000!important;-webkit-text-fill-color:#000!important} body{background:#fff;color:#000;font-size:12pt;line-height:1.5} html[data-visual-theme="glassmorphic"] body{background:#fff} html[data-visual-theme="paper"] .prose{padding:0;border:0;background:none;box-shadow:none} @@ -322,6 +351,18 @@ while(index!==-1){element.append(text.slice(offset,index));const mark=document.createElement('mark');mark.textContent=text.slice(index,index+query.length);element.append(mark);offset=index+query.length;index=haystack.indexOf(query,offset)} element.append(text.slice(offset)); } + function appendHighlights(element,text,terms){ + const lower=text.toLowerCase(),unique=[...new Set(terms.filter(Boolean))].sort((a,b)=>b.length-a.length); + let offset=0; + while(offsetbestTerm.length))){best=idx;bestTerm=term}} + if(best===-1){element.append(text.slice(offset));break} + if(best>offset)element.append(text.slice(offset,best)); + const mark=document.createElement('mark');mark.textContent=text.slice(best,best+bestTerm.length);element.append(mark); + offset=best+bestTerm.length; + } + } function excerpt(text,query){const index=text.toLowerCase().indexOf(query);if(index<0)return text.slice(0,90);const start=Math.max(0,index-36),end=Math.min(text.length,index+query.length+54);return `${start?'…':''}${text.slice(start,end)}${end({entry,score:rank(entry,terms)})).filter(match=>Number.isFinite(match.score)).sort((left,right)=>left.score-right.score||left.entry.title.localeCompare(right.entry.title)).slice(0,8); if(!matches.length){const empty=document.createElement('p');empty.className='search-empty';empty.textContent=`No pages match “${input.value.trim()}”.`;results.replaceChildren(empty)} - else results.replaceChildren(...matches.map(({entry})=>{ - const link=document.createElement('a');link.href=entry.url; - const title=document.createElement('strong');appendHighlight(title,entry.title,terms[0]); - const summary=document.createElement('small');summary.textContent=`${entry.section} — `;appendHighlight(summary,excerpt(entry.text,terms[0]),terms[0]); - link.append(title,summary);return link; - })); + else{ + const count=document.createElement('p');count.className='search-count';count.textContent=`${matches.length} result${matches.length===1?'':'s'}`; + results.replaceChildren(count,...matches.map(({entry})=>{ + const link=document.createElement('a');link.href=entry.url; + const title=document.createElement('strong');appendHighlights(title,entry.title,terms); + const summary=document.createElement('small');summary.textContent=`${entry.section} — `;const excerptText=excerpt(entry.text,terms[0]);const span=document.createElement('span');appendHighlights(span,excerptText,terms);summary.append(span); + link.append(title,summary);return link; + })); + } results.hidden=false;input.setAttribute('aria-expanded','true'); } if(input&&results){ @@ -387,13 +431,34 @@ const tocLinks=[...document.querySelectorAll('.toc a[href^="#"],.inline-toc a[href^="#"]')]; if(tocLinks.length&&'IntersectionObserver'in globalThis){ const headingIds=new Set(tocLinks.map(link=>link.getAttribute('href').slice(1))); - const activate=id=>tocLinks.forEach(link=>{const current=link.getAttribute('href').slice(1)===id;link.classList.toggle('is-active',current);if(current)link.setAttribute('aria-current','location');else link.removeAttribute('aria-current')}); + const activate=id=>tocLinks.forEach(link=>{const current=link.getAttribute('href').slice(1)===id;link.classList.toggle('is-active',current);if(current){link.setAttribute('aria-current','location');const inline=link.closest('.inline-toc');if(inline&&!inline.open)inline.open=true}else link.removeAttribute('aria-current')}); const observer=new IntersectionObserver(entries=>{const visible=entries.find(entry=>entry.isIntersecting);if(visible)activate(visible.target.id)},{rootMargin:'0px 0px -70% 0px'}); headingIds.forEach(id=>{const heading=document.getElementById(id);if(heading)observer.observe(heading)}); } + document.querySelectorAll('main.prose :is(h2,h3)[id]').forEach(heading=>{ + if(heading.querySelector('.heading-anchor'))return; + const anchor=document.createElement('a');anchor.className='heading-anchor';anchor.href=`#${heading.id}`; + anchor.setAttribute('aria-label',`Link to ${heading.textContent.trim()}`);anchor.textContent='#'; + heading.append(anchor); + }); })(); ''' MATH_JS = r''' (()=>{function render(node,displayMode){if(!globalThis.katex){node.textContent=node.dataset.tex||'';return}try{globalThis.katex.render(node.dataset.tex||'',node,{displayMode,throwOnError:false,strict:'ignore'})}catch(_){node.textContent=node.dataset.tex||''}}document.querySelectorAll('.math-inline').forEach(node=>render(node,false));document.querySelectorAll('.math-display').forEach(node=>render(node,true))})(); ''' + + +def _content_hash(value: str) -> str: + """Short content hash for cache-busting generated asset URLs. + + Identical content keeps an identical URL (long-lived caches stay valid); + any style or script change mints a new URL, so browsers and proxies can + never serve a stale stylesheet or script after a rebuild. + """ + return sha1(value.encode("utf-8")).hexdigest()[:12] + + +SITE_CSS_HASH = _content_hash(SITE_CSS) +SITE_JS_HASH = _content_hash(SITE_JS) +MATH_JS_HASH = _content_hash(MATH_JS) diff --git a/src/docsprout/build.py b/src/docsprout/build.py index de0329b..d15a57d 100644 --- a/src/docsprout/build.py +++ b/src/docsprout/build.py @@ -12,7 +12,7 @@ import unicodedata from urllib.parse import quote, urlsplit -from .assets import MATH_JS, SITE_CSS, SITE_JS +from .assets import MATH_JS, MATH_JS_HASH, SITE_CSS, SITE_CSS_HASH, SITE_JS, SITE_JS_HASH from .config import load_config, page_source_path, page_source_reference from .errors import DocSproutError from .markdown import render_markdown @@ -117,6 +117,32 @@ def _page_context(page: Page) -> str: return html.escape(location) +def _page_context_nav(*, page: Page, config, current_route: str) -> str: + crumbs: list[str] = [] + if page.section: + target = next((item for item in config.pages if item.section == page.section and item.subsection is None), None) + if target is None: + target = next((item for item in config.pages if item.section == page.section), None) + if target is not None and target.path != page.path: + href = html.escape(_relative(current_route, _route(target.path, config.home_document)), quote=True) + crumbs.append(f'
  • {html.escape(page.section)}
  • ') + else: + crumbs.append(f"
  • {html.escape(page.section)}
  • ") + if page.subsection: + target = next( + (item for item in config.pages if item.section == page.section and item.subsection == page.subsection), + None, + ) + if target is not None and target.path != page.path: + href = html.escape(_relative(current_route, _route(target.path, config.home_document)), quote=True) + crumbs.append(f'
  • {html.escape(page.subsection)}
  • ') + else: + crumbs.append(f"
  • {html.escape(page.subsection)}
  • ") + if not crumbs: + return "" + return f'' + + def _hero_title_has_emoji(body: str) -> bool: """Return True when the hero heading contains a colour emoji glyph. @@ -233,7 +259,7 @@ def _shell(*, body: str, headings: tuple[tuple[int, str, str], ...], page: Page, ) toc = f'

    On this page

    {toc_links}' if toc_links else '

    Documentation

    Browse the sections in the navigation.

    ' if not homepage: - context = f'
    {_page_context(page)}
    ' + context = _page_context_nav(page=page, config=config, current_route=current_route) if toc_links: inline_toc = f'
    On this page
    ' before_title, title_end, after_title = body.partition("") @@ -241,18 +267,22 @@ def _shell(*, body: str, headings: tuple[tuple[int, str, str], ...], page: Page, body = context + body page_navigation = _page_navigation(page=page, config=config, current_route=current_route, include_context=not homepage) footer_links = "".join(f'{html.escape(label)}' for label, url in config.project_links) - footer = f'
    {html.escape(config.footer or config.name)}{footer_links}
    ' if config.footer or footer_links else "" + footer = ( + f'
    {html.escape(config.footer or config.name)}' + f"{footer_links}" + 'Back to top
    ' + ) header_controls = f'''
    ''' main_context = ' data-homepage="true"' if homepage else ' data-homepage="false"' theme_bootstrap = """""" custom_css_link = f'' if custom_css else "" home_route = html.escape(_relative(current_route, "index.html"), quote=True) search_index_route = html.escape(_relative(current_route, "search-index.json"), quote=True) - site_css_route = html.escape(_relative(current_route, "assets/site.css"), quote=True) + site_css_route = html.escape(f"{_relative(current_route, 'assets/site.css')}?v={SITE_CSS_HASH}", quote=True) katex_css_route = html.escape(_relative(current_route, "assets/katex/katex.min.css"), quote=True) katex_js_route = html.escape(_relative(current_route, "assets/katex/katex.min.js"), quote=True) - math_js_route = html.escape(_relative(current_route, "assets/math.js"), quote=True) - site_js_route = html.escape(_relative(current_route, "assets/site.js"), quote=True) + math_js_route = html.escape(f"{_relative(current_route, 'assets/math.js')}?v={MATH_JS_HASH}", quote=True) + site_js_route = html.escape(f"{_relative(current_route, 'assets/site.js')}?v={SITE_JS_HASH}", quote=True) favicon = "data:image/svg+xml," + quote(BRAND_SVG, safe="") document_head = ( f'' '' 'Type to search. Use the arrow keys to move' - " through results, Enter to open, and Escape to close." + ' through results, Enter to open, and Escape to close.' + '' f"{header_controls}" - '' + '' ) - mobile_navigation = f'
    Browse documentation{nav}
    ' + mobile_navigation = f'
    Browse documentation
    ' shell_class = "shell" if homepage or toc_links else "shell shell-no-toc" toc_aside = f'' if homepage or toc_links else "" document_body = ( diff --git a/tests/qualification_installed.py b/tests/qualification_installed.py index eafc170..5ad27b0 100644 --- a/tests/qualification_installed.py +++ b/tests/qualification_installed.py @@ -5,7 +5,7 @@ source tree. Every path used here is a fresh temporary directory, so a passing run is evidence that the package does not depend on repository-only files. - python qualification_installed.py --expected-version 1.2.2 + python qualification_installed.py --expected-version 1.2.3 The script is deliberately self-contained (stdlib only) so it can be copied or run from any location without importing anything from the repository. diff --git a/tests/test_build.py b/tests/test_build.py index 1f890af..2b4c296 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,8 +1,10 @@ import json from pathlib import Path +import re import tempfile import unittest +from docsprout.assets import MATH_JS_HASH, SITE_CSS_HASH, SITE_JS_HASH from docsprout.build import build_site from docsprout.errors import DocSproutError @@ -27,6 +29,38 @@ def test_publishes_local_markdown_images_with_their_page(self) -> None: self.assertIn('Architecture', page) self.assertEqual("", (root / "site" / "assets" / "content" / "images" / "architecture.svg").read_text(encoding="utf-8")) + def test_generated_asset_urls_carry_a_stable_content_hash(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + (docs / "guides").mkdir(parents=True) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "guides" / "advanced.md").write_text("# Advanced\n", encoding="utf-8") + (docs / "docsprout.json").write_text(json.dumps({"schema_version": 1, "project": {"name": "Demo"}}), encoding="utf-8") + (docs / "layout.json").write_text(json.dumps({ + "schema_version": 1, + "navigation": [{"title": "Docs", "pages": [ + {"title": "Home", "path": "index.md"}, + {"title": "Advanced", "path": "guides/advanced.md"}, + ]}], + }), encoding="utf-8") + + build_site(root=root, output=root / "site", release="dev") + + home = (root / "site" / "index.html").read_text(encoding="utf-8") + self.assertIn(f'href="assets/site.css?v={SITE_CSS_HASH}"', home) + self.assertIn(f'src="assets/site.js?v={SITE_JS_HASH}"', home) + self.assertIn(f'src="assets/math.js?v={MATH_JS_HASH}"', home) + nested = (root / "site" / "guides" / "advanced.html").read_text(encoding="utf-8") + self.assertIn(f'href="../assets/site.css?v={SITE_CSS_HASH}"', nested) + for digest in (SITE_CSS_HASH, SITE_JS_HASH, MATH_JS_HASH): + self.assertRegex(digest, r"^[0-9a-f]{12}$") + self.assertEqual(1, home.count(f"?v={digest}")) + rebuild = re.sub(r"\?v=[0-9a-f]{12}", "?v=", home) + build_site(root=root, output=root / "second", release="dev") + twice = (root / "second" / "index.html").read_text(encoding="utf-8") + self.assertEqual(rebuild, re.sub(r"\?v=[0-9a-f]{12}", "?v=", twice)) + def test_publishes_nested_page_assets_under_a_non_colliding_content_path(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -105,7 +139,7 @@ def test_builds_the_maintained_visual_fixture(self) -> None: self.assertIn('href="assets/custom.css"', home) self.assertGreater( home.index('href="assets/custom.css"'), - home.index('href="assets/site.css"'), + home.index("assets/site.css?v="), ) def test_copies_an_identity_logo_into_the_header(self) -> None: @@ -179,8 +213,9 @@ def test_builds_modern_navigation_search_and_theme_tokens(self) -> None: self.assertIn("