From 71e9289f995ecc4703e8810eabbbb6471edb320e Mon Sep 17 00:00:00 2001 From: Luka Sherman Date: Sat, 26 Sep 2026 22:47:58 -0700 Subject: [PATCH 1/3] extracted and published mkdocs-audience-toggle --- README.md | 10 +- STRUCTURE.md | 51 ++-- docs/index.md | 48 ++-- docs/javascripts/essentials_toggle.js | 339 -------------------------- docs/javascripts/theme_toggle.js | 193 +++++++++++++++ docs/libraries/index.md | 12 +- docs/organization/classes.md | 16 +- docs/organization/functions.md | 16 +- docs/practices/errors.md | 2 +- docs/practices/style.md | 16 +- docs/resources/files.md | 6 +- docs/start/workspace.md | 4 +- docs/stylesheets/extra.css | 158 ++++++------ docs/types/basics.md | 2 +- docs/types/collections.md | 12 +- mkdocs.yml | 35 ++- requirements.txt | 3 + tests/test_accessibility_browser.py | 2 +- tests/test_accessibility_keyboard.py | 4 +- tests/test_content_mode_toggle.py | 182 ++++++++++++++ tests/test_essentials_toggle.py | 161 ------------ 21 files changed, 586 insertions(+), 686 deletions(-) delete mode 100644 docs/javascripts/essentials_toggle.js create mode 100644 docs/javascripts/theme_toggle.js create mode 100644 tests/test_content_mode_toggle.py delete mode 100644 tests/test_essentials_toggle.py diff --git a/README.md b/README.md index 8d6200c..3abc69b 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,12 @@ A diagram renderer, which draws flowcharts and diagrams from a plain-text descri ### Essentials / Advanced toggle -A two-option [switch](docs/javascripts/essentials_toggle.js) that lets a reader hide everything beyond a first-pass beginner curriculum. Content is opted into hiding by marking it `data-advanced="true"`: +A two-option switch, provided by the [mkdocs-audience-toggle](https://github.com/lukasherman/mkdocs-audience-toggle) plugin (configured under `plugins:` in `mkdocs.yml`), that lets a reader hide everything beyond a first-pass beginner curriculum. Content is opted into hiding by marking it `data-fcm-hide="essentials"`: - On a `##`/`###` heading inside a content page (e.g. functions.md's `## Decorators`), it hides that heading plus every sibling up to the next heading of the same or higher level, and removes the matching entry from the `toc.integrate` sidebar — so there's no dead nav link to something that's hidden. -- On a homepage card-grid row, it hides just that row; `data-advanced="card"` hides an entire homepage card instead, for a whole linked page rather than one section. +- On a homepage card-grid row, it hides just that row. A whole homepage card hides too, once its first paragraph (the only one attr_list can attach the marker to) carries the marker — extra.css has a small `:has()` rule that extends that into hiding the entire `
  • `, since the plugin itself only hides the exact element marked. -Each marking is independent — there's no shared list of "advanced" topics to keep in sync, just the attribute at each spot in the Markdown. State persists in `localStorage` and applies on every page (also settable via a `?simplified=true`/`false` URL param, for sharing a pre-set link). If a visible link points at a heading that's currently hidden (e.g. collections.md's cheat-sheet table linking to `#tuples`), following it flips the toggle back to Advanced and reveals the target instead of landing on nothing. +Each marking is independent — there's no shared list of "advanced" topics to keep in sync, just the attribute at each spot in the Markdown. State persists in `localStorage` and applies on every page (also settable via a `?mode=essentials`/`advanced` URL param, for sharing a pre-set link). If a visible link points at a heading that's currently hidden (e.g. collections.md's cheat-sheet table linking to `#tuples`), following it flips the toggle back to Advanced and reveals the target instead of landing on nothing. Some examples of content that is hidden while in "Essentials" mode, while a student is first learning to program: @@ -226,9 +226,9 @@ The standard Python test runner, which discovers `test_*` functions across the r focus order, the output live region); and keyboard navigation (skip link, a visible focus ring on every tab stop, no positive tabindex, palette toggle reachable). It's the heaviest part of the suite — needs `playwright install chromium` above and launches a real browser. -- `tests/test_essentials_toggle.py` is a browser test (same Playwright setup) for the +- `tests/test_content_mode_toggle.py` is a browser test (same Playwright setup) for the Essentials/Advanced toggle described above: the default (Advanced) state, that - `?simplified=true` hides marked content and carries onto a page's own heading + TOC entry, + `?mode=essentials` hides marked content and carries onto a page's own heading + TOC entry, and the link-recovery behavior for a visible link into hidden content. ### [Playwright](https://playwright.dev/) diff --git a/STRUCTURE.md b/STRUCTURE.md index 816942a..76d1727 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -196,35 +196,38 @@ this reason. anchor/link it can't resolve; treat a clean build as the actual pass/fail check for this list, since hand-checked slugs are easy to get subtly wrong (trailing punctuation, duplicate-heading suffixes, etc). -- **Marking content "advanced" for the Simplify toggle** — the header's "Essentials" / "Advanced" - segmented control (both labels always visible, on every page) hides content marked - `data-advanced="true"`, at one of two granularities. Each spot that should hide is marked - directly, in its own markdown source — there's no derived/shared list, so a new advanced entry - needs tagging in every place it should disappear from: +- **Marking content "advanced" for the Essentials/Advanced toggle** — the header's segmented + control (both labels always visible, on every page) is provided by the + `mkdocs-audience-toggle` plugin (configured under `plugins:` in `mkdocs.yml`; see + CLAUDE.md's "Planned extraction" section) and hides content marked `data-fcm-hide="essentials"`, at + one of two granularities. Each spot that should hide is marked directly, in its own markdown + source — there's no derived/shared list, so a new advanced entry needs tagging in every place + it should disappear from: - **A homepage keyword-link row** — the bolded keyword plus its row of related links (e.g. `functions.md#decorators` or `collections.md#sets`, in `index.md`) — append - `{: data-advanced="true" }` on its own line directly after the row, at the same indentation, - with no blank line before it (attr_list attaches it to that paragraph, which - `.simplify-active [data-advanced]` then hides). + `{: data-fcm-hide="essentials" }` on its own line directly after the row, at the same indentation, + with no blank line before it (attr_list attaches it to that paragraph, which the plugin then + hides directly). - **The matching heading on the actual content page** — e.g. `functions.md`'s - `## Decorators { data-advanced="true" }` — append `{ data-advanced="true" }` directly on the - heading line (same attr_list convention as `data-card-link="skip"` above). This hides that + `## Decorators { data-fcm-hide="essentials" }` — append `{ data-fcm-hide="essentials" }` directly on + the heading line (same attr_list convention as `data-card-link="skip"` above). This hides that heading, everything up to the next heading of the same or higher level, and its integrated-TOC sidebar entry, on that page specifically. Tag the homepage row and the - content-page heading independently — `docs/javascripts/essentials_toggle.js` doesn't infer one - from the other, by design (simpler and more robust than deriving a map at runtime). - - **A whole homepage card** (e.g. the OpenCV card) — append `{: data-advanced="card" }` the + content-page heading independently — the plugin doesn't infer one from the other, by design + (simpler and more robust than deriving a map at runtime). + - **A whole homepage card** (e.g. the OpenCV card) — append `{: data-fcm-hide="essentials" }` the same way, right after the card's first paragraph (the icon + title link, e.g. - `[__OpenCV__](...)`). `.simplify-active .grid.cards > ul > li:has(> p[data-advanced="card"])` - in `extra.css` walks up from that paragraph to hide the whole enclosing `
  • `. This one has - no content-page equivalent — it marks a whole linked page, not a section within one, so - there's nothing on that page itself to hide. - Which value to use, and what counts as advanced/niche vs. core, is a per-editor judgment call - — there's no test enforcing it either way. See `docs/javascripts/essentials_toggle.js` for the - toggle mechanism. A page can still show a *link* to a hidden section (e.g. `collections.md`'s - own cheat-sheet table links to `#tuples` even though the "Tuples" heading is hidden) — - following such a link automatically switches back to Complete and reveals the target, so this - doesn't need special-casing when adding new advanced content. + `[__OpenCV__](...)`) — attr_list can only attach it there, not to the enclosing `
  • `, so the + plugin alone would only hide that one line. `html[data-fcm-mode="essentials"] .grid.cards > ul + > li:has(> p[data-fcm-hide~="essentials"])` in `extra.css` walks up from that paragraph to hide + the whole enclosing `
  • ` too. This one has no content-page equivalent — it marks a whole + linked page, not a section within one, so there's nothing on that page itself to hide. + Which spots to mark, and what counts as advanced/niche vs. core, is a per-editor judgment call + — there's no test enforcing it either way. See the `mkdocs-audience-toggle` plugin's own + README for the toggle mechanism itself. A page can still show a *link* to a hidden section (e.g. + `collections.md`'s own cheat-sheet table links to `#tuples` even though the "Tuples" heading is + hidden) — following such a link automatically switches back to Advanced and reveals the target, + so this doesn't need special-casing when adding new advanced content. ### Admonitions (`??? type "..."`) @@ -249,7 +252,7 @@ Pick the existing type that matches the branch, don't invent new ones without a | `??? info` | Defining a term/concept adjacent to the page but not the topic itself. | | `??? failure` | The negative counterpart to a `success` branch — "this didn't work, here's what to do about it" (e.g. workspace.md's "download Python here" branch when `python --version` doesn't show 3.x.x). | | `??? ai` | Opinion/meta content specifically about learning with or around AI (e.g. index.md's FAQ tabs on whether/how to use AI while learning) — not used for teaching content about Python itself. | -| `??? efficiency` | A runtime/space aside naming the cost behind a choice already shown in prose (e.g. list vs. set membership, `sort()` vs. `sorted()`) — usually a Big O difference, occasionally a constant-factor one (`.get()` vs. two hash lookups, vectorized NumPy vs. a Python loop) where it's still worth flagging but doesn't change the O(...) class. Wrap it in `
    ` on a page that participates in the Essentials/Advanced toggle (skip it on a page that doesn't, like the library reference pages), and close with a link to [style.md's "Efficiency"](docs/practices/style.md#efficiency) section. Formalizes a tradeoff the surrounding prose already states in plain language; doesn't introduce the tradeoff for the first time. | +| `??? efficiency` | A runtime/space aside naming the cost behind a choice already shown in prose (e.g. list vs. set membership, `sort()` vs. `sorted()`) — usually a Big O difference, occasionally a constant-factor one (`.get()` vs. two hash lookups, vectorized NumPy vs. a Python loop) where it's still worth flagging but doesn't change the O(...) class. Wrap it in `
    ` on a page that participates in the Essentials/Advanced toggle (skip it on a page that doesn't, like the library reference pages), and close with a link to [style.md's "Efficiency"](docs/practices/style.md#efficiency) section. Formalizes a tradeoff the surrounding prose already states in plain language; doesn't introduce the tradeoff for the first time. | | `!!! example` | An always-open side-by-side comparison the reader is meant to see without a click, not a branch — e.g. "how to loop each type," showing every collection type's loop pattern in one visible table. | Default to collapsed (`???`), not always-open (`!!!`) — an always-open admonition competes with diff --git a/docs/index.md b/docs/index.md index 5dcad12..528b268 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,14 +111,14 @@ hide: [`ls`](start/workspace.md#using-the-terminal) [`pwd`](start/workspace.md#using-the-terminal) [`shortcuts`](start/workspace.md#using-the-terminal) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`virtual environments`**](start/workspace.md#virtual-environments): [`activate`](start/workspace.md#virtual-environments) [`pip`](start/workspace.md#virtual-environments) [`requirements.txt`](start/workspace.md#virtual-environments) [`venv`](start/workspace.md#virtual-environments) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } - :material-cube-outline:{ .lg .middle } [__Foundations__](start/foundations.md) @@ -287,7 +287,7 @@ hide: [`sum`](types/collections.md#arithmetic_1) [`tuple`](types/collections.md#create_2) [`unpacking`](types/collections.md#packing-and-unpacking) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`sets`**](types/collections.md#sets): [`add`](types/collections.md#update_1) @@ -308,7 +308,7 @@ hide: [`sum`](types/collections.md#arithmetic_2) [`update`](types/collections.md#update_1) [`| & - ^`](types/collections.md#combine) - {: data-advanced="true" } + {: data-fcm-hide="essentials" }
    @@ -395,7 +395,7 @@ hide: [`keyword-only`](organization/functions.md#keyword-only) [`positional-only`](organization/functions.md#positional-only) [`type hints`](organization/functions.md#type-hints) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`calling a function`**](organization/functions.md#calling-a-function): [`arguments`](organization/functions.md#arguments) @@ -408,7 +408,7 @@ hide: [`local vs global`](organization/functions.md#local-vs-global-variables) [**`recursion`**](organization/functions.md#recursion) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`decorators`**](organization/functions.md#decorators): [`arguments`](organization/functions.md#accepting-arguments) @@ -416,13 +416,13 @@ hide: [`original function`](organization/functions.md#returning-the-original-function) [`stacking`](organization/functions.md#advanced-uses) [`wrapping`](organization/functions.md#wrapping-the-call) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`generators`**](organization/functions.md#generators): [`generator expressions`](organization/functions.md#generator-expressions) [`memory`](organization/functions.md#memory-efficiency) [`yield`](organization/functions.md#yield-vs-return) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } - :material-package-variant:{ .lg .middle } [__Classes__](organization/classes.md) @@ -439,7 +439,7 @@ hide: [`@classmethod`](organization/classes.md#classmethod) [`@property`](organization/classes.md#property) [`@staticmethod`](organization/classes.md#staticmethod) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`inheritance`**](organization/classes.md#inheritance): [`adding attributes and methods`](organization/classes.md#adding-attributes-and-methods) @@ -448,29 +448,29 @@ hide: [`super()`](organization/classes.md#using-super) [`multiple inheritance`](organization/classes.md#multiple-inheritance) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`polymorphism`**](organization/classes.md#polymorphism): [`inheritance`](organization/classes.md#polymorphism-via-inheritance) [`duplicate method names`](organization/classes.md#duplicate-method-names) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`encapsulation`**](organization/classes.md#encapsulation): [`@property`](organization/classes.md#controlled-access-with-property) [`double underscore`](organization/classes.md#double-underscore) [`single underscore`](organization/classes.md#single-underscore) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`operator overloading`**](organization/classes.md#operator-overloading): [`__add__`](organization/classes.md#arithmetic-with-__add__) [`__eq__ and __lt__`](organization/classes.md#comparing-with-__eq__-and-__lt__) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`dataclasses`**](organization/classes.md#dataclasses) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`abstract base classes`**](organization/classes.md#abstract-base-classes) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } @@ -550,7 +550,7 @@ hide: [`indentation`](practices/style.md#indentation) [`order`](practices/style.md#file-order) [`quote style`](practices/style.md#quote-style) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`Linters, formatters`**](practices/style.md#linters-and-formatters) @@ -560,14 +560,14 @@ hide: [`truthy checks`](practices/style.md#truthy-checks) [`enumerate()`](practices/style.md#enumerate-instead-of-range) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`Efficiency`**](practices/style.md#efficiency) [`big O`](practices/style.md#big-o-notation) [`common optimizations`](practices/style.md#common-optimizations) [`time`](practices/style.md#time-and-space) [`space`](practices/style.md#time-and-space) - {: data-advanced="true" } + {: data-fcm-hide="essentials" } [**`Polished UX`**](practices/style.md#polished-ux): [`input validation`](practices/style.md#input-validation) @@ -629,7 +629,7 @@ hide: - :material-format-list-group:{ .lg .middle } [__collections__](libraries/collections.md) [:material-language-python:](libraries/collections.md){ .pt-lib-badge .pt-lib-badge--builtin title="Built-in — included with Python" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Specialized containers with advanced functionality. @@ -678,13 +678,13 @@ hide: - :material-matrix:{ .lg .middle } [__NumPy__](libraries/numpy.md) [:material-download-outline:](libraries/numpy.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Fast numeric arrays, with math applied to a whole array at once instead of item by item. - :material-table:{ .lg .middle } [__pandas__](libraries/pandas.md) [:material-download-outline:](libraries/pandas.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Tabular data: rows and columns, like a spreadsheet, built on top of NumPy. @@ -761,7 +761,7 @@ hide: -
    +
    #### Testing { .pt-homepage-heading }
    @@ -774,14 +774,14 @@ hide:
    -
    +
    #### Computer vision { .pt-homepage-heading }
    - :material-face-recognition:{ .lg .middle } [__OpenCV__](libraries/opencv.md) [:material-download-outline:](libraries/opencv.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Real-time image and video analysis, built directly on NumPy arrays: color spaces, edge detection, face detection. diff --git a/docs/javascripts/essentials_toggle.js b/docs/javascripts/essentials_toggle.js deleted file mode 100644 index 4d39b4f..0000000 --- a/docs/javascripts/essentials_toggle.js +++ /dev/null @@ -1,339 +0,0 @@ -(function () { - // "Essentials / Advanced" toggle: hides data-advanced content. On a - // homepage row, data-advanced="true" hides that row; the same attribute - // on a content page's own heading (e.g. functions.md's `## Decorators`) - // hides that section plus its TOC entry — marked independently in each - // place, no shared map. data-advanced="card" hides a whole homepage - // card (see extra.css); no content-page equivalent, since it marks a - // linked page rather than a section. Shows on every page; state - // persists via localStorage. - const STORAGE_KEY = "pt-simplify-active"; - - // pt-lib--N sizes each library box for its full card count; hiding cards - // in Essentials mode leaves boxes too wide. Recompute the visible count - // into --pt-lib-span so extra.css can override pt-lib--N while active. - function updateLibrarySpans() { - document.querySelectorAll(".pt-category--wide").forEach(function (box) { - const cards = box.querySelectorAll(".grid.cards > ul > li"); - let visible = 0; - cards.forEach(function (li) { - if (getComputedStyle(li).display !== "none") visible++; - }); - if (visible > 0) box.style.setProperty("--pt-lib-span", Math.min(visible, 4)); - }); - } - - // Hides/restores a heading and its whole section — every sibling up to - // the next heading of the same or higher level. - function setSectionHidden(heading, hidden) { - heading.hidden = hidden; - const level = Number(heading.tagName[1]); - let el = heading.nextElementSibling; - while (el && !(/^H[1-6]$/.test(el.tagName) && Number(el.tagName[1]) <= level)) { - el.hidden = hidden; - el = el.nextElementSibling; - } - - // Many pages wrap a whole ## section in
    for - // its own card-style border/background (raw HTML in the markdown, not - // generated). Hiding the heading and its siblings above leaves that - // wrapper behind as an empty card, so hide it too when the heading is - // its first child. - const wrapper = heading.parentElement; - if (wrapper && wrapper.classList.contains("pfg-section") && wrapper.firstElementChild === heading) { - wrapper.hidden = hidden; - } - } - - // Hide the matching TOC
  • too, so there's no dead link to hidden - // content. Material renders a heading's link twice — once (inert, - // visibility:collapse) inside the primary nav's copy of the current - // page's TOC, and once for real in the secondary sidebar — querySelectorAll - // + forEach covers both without needing to know which is which. - function setTocEntryHidden(id, hidden) { - // href gets rewritten to a full URL after hydration; match by suffix. - document.querySelectorAll('a.md-nav__link[href$="#' + id + '"]').forEach(function (link) { - const item = link.closest(".md-nav__item"); - if (item) item.hidden = hidden; - }); - } - - function applyAdvancedHeadings(active) { - document.querySelectorAll('.md-typeset [data-advanced="true"]').forEach(function (el) { - if (!/^H[1-6]$/.test(el.tagName)) return; - setSectionHidden(el, active); - if (el.id) setTocEntryHidden(el.id, active); - }); - } - - // Disappearing confirmation toast, shared by the Essentials/Advanced - // toggle and the light/dark toggle — neither toggle's own button shows - // what just changed, only the current state, so a click otherwise gives - // no feedback about its actual effect. - // - // `iconAttrs` is the dataset to put on the icon span, e.g. {mode: - // "simplified"} or {scheme: "slate"} — matched in extra.css by - // .pt-mode-icon[data-mode] / [data-scheme] to the same icons the - // triggering toggle itself uses. `body` is optional; pass "" to show a - // one-line toast (the light/dark toggle's own message is self- - // explanatory, unlike the Essentials/Advanced one). - let toastTimer = null; - function showToast(label, iconAttrs, body) { - let toast = document.getElementById("pt-toast"); - if (!toast) { - toast = document.createElement("div"); - toast.id = "pt-toast"; - toast.className = "pt-toast"; - // status + polite: announced to screen readers without interrupting - // whatever they're already reading, same as a visual toast doesn't - // steal focus. - toast.setAttribute("role", "status"); - toast.setAttribute("aria-live", "polite"); - document.body.appendChild(toast); - } - - toast.innerHTML = ""; - - const title = document.createElement("div"); - title.className = "pt-toast__title"; - const icon = document.createElement("span"); - icon.className = "pt-mode-icon"; - Object.keys(iconAttrs).forEach(function (key) { - icon.dataset[key] = iconAttrs[key]; - }); - icon.setAttribute("aria-hidden", "true"); - title.append(icon, " " + label); - toast.append(title); - - if (body) { - const bodyEl = document.createElement("div"); - bodyEl.className = "pt-toast__body"; - bodyEl.textContent = body; - toast.append(bodyEl); - } - - // The header's own height isn't fixed across breakpoints (taller with - // the tab bar on tablet/desktop) or over time (Material can hide/reveal - // it on scroll), so position below it fresh on every call rather than - // hardcoding an offset in CSS. - const header = document.querySelector(".md-header"); - const headerBottom = header ? header.getBoundingClientRect().bottom : 0; - toast.style.top = Math.max(headerBottom, 0) + 12 + "px"; - - // Retrigger the transition even if a toast is already showing (rapid - // clicks between the two options, or switching from one toggle to the - // other): drop the class, force layout, then re-add it, instead of - // just extending the existing timer. - toast.classList.remove("pt-toast--visible"); - void toast.offsetWidth; - toast.classList.add("pt-toast--visible"); - - clearTimeout(toastTimer); - toastTimer = setTimeout(function () { - toast.classList.remove("pt-toast--visible"); - }, 1400); - } - - function applyState(container, active) { - document.body.classList.toggle("simplify-active", active); - updateLibrarySpans(); - applyAdvancedHeadings(active); - container.dataset.active = active ? "simplified" : "advanced"; - container.querySelectorAll(".pt-simplify-option").forEach(function (option) { - option.setAttribute("aria-pressed", String(option.dataset.mode === container.dataset.active)); - }); - } - - // Two always-visible options with a sliding highlight, not one button - // whose text changes. - function getOrCreateToggle() { - let container = document.getElementById("pt-simplify-toggle"); - if (container) return container; - - const paletteForm = document.querySelector('[data-md-component="palette"]'); - if (!paletteForm) return null; - - container = document.createElement("div"); - container.id = "pt-simplify-toggle"; - container.className = "pt-simplify-toggle"; - container.setAttribute("role", "group"); - container.setAttribute("aria-label", "Content level"); - - const highlight = document.createElement("span"); - highlight.className = "pt-simplify-highlight"; - highlight.setAttribute("aria-hidden", "true"); - - const essentials = document.createElement("button"); - essentials.type = "button"; - essentials.className = "pt-simplify-option"; - essentials.dataset.mode = "simplified"; - essentials.title = "Show only what you need to write your first programs"; - const essentialsLabel = document.createElement("span"); - essentialsLabel.className = "pt-simplify-label"; - essentialsLabel.textContent = "Essentials"; - essentials.append(essentialsLabel); - - const advanced = document.createElement("button"); - advanced.type = "button"; - advanced.className = "pt-simplify-option"; - advanced.dataset.mode = "advanced"; - advanced.title = "Show all site content"; - const advancedLabel = document.createElement("span"); - advancedLabel.className = "pt-simplify-label"; - advancedLabel.textContent = "Advanced"; - advanced.append(advancedLabel); - - container.append(highlight, advanced, essentials); - paletteForm.insertAdjacentElement("beforebegin", container); - - container.addEventListener("click", function (event) { - const option = event.target.closest(".pt-simplify-option"); - if (!option) return; - const next = option.dataset.mode === "simplified"; - const wasActive = container.dataset.active === "simplified"; - if (next === wasActive) return; - localStorage.setItem(STORAGE_KEY, String(next)); - applyState(container, next); - showToast( - next ? "Essentials" : "Advanced", - { mode: next ? "simplified" : "advanced" }, - next ? "Just the basics, start here!" : "Viewing all content." - ); - }); - - return container; - } - - // Light/dark, same two-option format as above — replaces Material's - // native single-knob switch, whose knob was the only clickable spot. - // Native radios stay in the DOM (hidden); their own JS still applies - // and persists the scheme, we just flip `checked` and dispatch change. - function getOrCreateThemeToggle() { - let container = document.getElementById("pt-theme-toggle"); - if (container) return container; - - const paletteForm = document.querySelector('[data-md-component="palette"]'); - if (!paletteForm) return null; - - const lightRadio = paletteForm.querySelector('input[data-md-color-scheme="default"]'); - const darkRadio = paletteForm.querySelector('input[data-md-color-scheme="slate"]'); - if (!lightRadio || !darkRadio) return null; - - paletteForm.hidden = true; - - container = document.createElement("div"); - container.id = "pt-theme-toggle"; - container.className = "pt-simplify-toggle pt-theme-toggle"; - container.setAttribute("role", "group"); - container.setAttribute("aria-label", "Color theme"); - - const highlight = document.createElement("span"); - highlight.className = "pt-simplify-highlight"; - highlight.setAttribute("aria-hidden", "true"); - - const light = document.createElement("button"); - light.type = "button"; - light.className = "pt-simplify-option pt-theme-option"; - light.dataset.scheme = "default"; - light.title = "Switch to light mode"; - light.setAttribute("aria-label", "Switch to light mode"); - - const dark = document.createElement("button"); - dark.type = "button"; - dark.className = "pt-simplify-option pt-theme-option"; - dark.dataset.scheme = "slate"; - dark.title = "Switch to dark mode"; - dark.setAttribute("aria-label", "Switch to dark mode"); - - container.append(highlight, dark, light); - paletteForm.insertAdjacentElement("beforebegin", container); - - container.addEventListener("click", function (event) { - const option = event.target.closest(".pt-theme-option"); - if (!option) return; - const scheme = option.dataset.scheme; - const radio = scheme === "slate" ? darkRadio : lightRadio; - if (radio.checked) return; - radio.checked = true; - radio.dispatchEvent(new Event("change", { bubbles: true })); - applyThemeState(container); - showToast(scheme === "slate" ? "Lights off" : "Lights on", { scheme: scheme }, ""); - }); - - // Sync to whatever scheme Material's own JS actually lands on, not - // just what we clicked. - lightRadio.addEventListener("change", function () { - applyThemeState(container); - }); - darkRadio.addEventListener("change", function () { - applyThemeState(container); - }); - - return container; - } - - function applyThemeState(container) { - const scheme = document.body.getAttribute("data-md-color-scheme"); - container.dataset.active = scheme === "slate" ? "dark" : "light"; - container.querySelectorAll(".pt-theme-option").forEach(function (option) { - option.setAttribute("aria-pressed", String(option.dataset.scheme === scheme)); - }); - } - - // A visible link can point at a hidden section (e.g. collections.md's - // cheat-sheet table links to #tuples while "Tuples" itself is hidden) — - // reveal the target instead of landing on nothing. - // - // Uses hashchange rather than a click listener: a real mouse click on an - // anchor races with, and in Chromium beats, a capturing click handler — - // it worked for a scripted .click() in manual testing but silently - // failed for an actual pointer click. hashchange fires after the - // navigation commits either way. - function revealHashTargetIfHidden() { - if (!location.hash || !document.body.classList.contains("simplify-active")) return; - - const target = document.getElementById(location.hash.slice(1)); - if (!target || !target.hidden) return; - - const container = document.getElementById("pt-simplify-toggle"); - if (!container) return; - localStorage.setItem(STORAGE_KEY, "false"); - applyState(container, false); - } - - function setUpAdvancedLinkRecovery() { - if (window.__ptHashRecoveryBound) return; - window.__ptHashRecoveryBound = true; - window.addEventListener("hashchange", revealHashTargetIfHidden); - } - - function setUpSimplifyToggle() { - const container = getOrCreateToggle(); - if (!container) return; - - setUpAdvancedLinkRecovery(); - - const themeToggle = getOrCreateThemeToggle(); - if (themeToggle) applyThemeState(themeToggle); - - // ?simplified=true/false on a link forces and saves that state, e.g. - // sharing a pre-simplified link. - const override = new URLSearchParams(window.location.search).get("simplified"); - if (override !== null) localStorage.setItem(STORAGE_KEY, override !== "false" ? "true" : "false"); - - applyState(container, localStorage.getItem(STORAGE_KEY) === "true"); - - // Also covers loading a URL whose hash already points at a hidden - // section, not just navigating there via a same-page click. - revealHashTargetIfHidden(); - } - - // navigation.instant swaps page content via JS without a full reload, so - // DOMContentLoaded only fires once. document$ is Material's own - // observable that emits on every page change, instant or not. - if (window.document$) { - window.document$.subscribe(setUpSimplifyToggle); - } else { - document.addEventListener("DOMContentLoaded", setUpSimplifyToggle); - } -})(); diff --git a/docs/javascripts/theme_toggle.js b/docs/javascripts/theme_toggle.js new file mode 100644 index 0000000..50bf86b --- /dev/null +++ b/docs/javascripts/theme_toggle.js @@ -0,0 +1,193 @@ +(function () { + // Light/dark toggle: two always-visible options with a sliding highlight, + // next to the palette toggle. Replaces Material's native single-knob + // switch, whose knob was the only clickable spot. Native radios stay in + // the DOM (hidden); their own JS still applies and persists the scheme, + // we just flip `checked` and dispatch change. + // + // The Essentials/Advanced content toggle used to live in this file too; + // it's now the mkdocs-audience-toggle plugin (see mkdocs.yml and + // CLAUDE.md's "Planned extraction" section) and no longer touches this + // code. This file keeps only the one bit that plugin can't own: + // recomputing --pt-lib-span (the add-on-library boxes' grid span) once + // the plugin hides some of their cards — see updateLibrarySpans below. + + // pt-lib--N sizes each library box for its full card count; hiding cards + // in Essentials mode leaves boxes too wide. Recompute the visible count + // into --pt-lib-span so extra.css can override pt-lib--N while active. + function updateLibrarySpans() { + document.querySelectorAll(".pt-category--wide").forEach(function (box) { + const cards = box.querySelectorAll(".grid.cards > ul > li"); + let visible = 0; + cards.forEach(function (li) { + if (getComputedStyle(li).display !== "none") visible++; + }); + if (visible > 0) box.style.setProperty("--pt-lib-span", Math.min(visible, 4)); + }); + } + + // The plugin hides content (and sets html[data-fcm-mode]) synchronously + // from its own script; a MutationObserver callback always fires as a + // separate microtask after that synchronous work finishes, so this stays + // correctly ordered regardless of which script's DOMContentLoaded/ + // document$ subscriber happens to run first. + function setUpLibrarySpanRecompute() { + if (window.__ptLibSpanObserverBound) return; + window.__ptLibSpanObserverBound = true; + new MutationObserver(updateLibrarySpans).observe(document.documentElement, { + attributeFilter: ["data-fcm-mode"], + }); + updateLibrarySpans(); + } + + // Disappearing confirmation toast — shows what a click on the light/dark + // toggle just changed, since the button itself only shows the current + // state, not what changed. + // + // `iconAttrs` is the dataset to put on the icon span, e.g. {scheme: + // "slate"} — matched in extra.css by .pt-mode-icon[data-scheme] to the + // same sun/moon icons the toggle itself uses. `body` is optional; pass "" + // for a one-line toast (this toggle's own message is self-explanatory). + let toastTimer = null; + function showToast(label, iconAttrs, body) { + let toast = document.getElementById("pt-toast"); + if (!toast) { + toast = document.createElement("div"); + toast.id = "pt-toast"; + toast.className = "pt-toast"; + // status + polite: announced to screen readers without interrupting + // whatever they're already reading, same as a visual toast doesn't + // steal focus. + toast.setAttribute("role", "status"); + toast.setAttribute("aria-live", "polite"); + document.body.appendChild(toast); + } + + toast.innerHTML = ""; + + const title = document.createElement("div"); + title.className = "pt-toast__title"; + const icon = document.createElement("span"); + icon.className = "pt-mode-icon"; + Object.keys(iconAttrs).forEach(function (key) { + icon.dataset[key] = iconAttrs[key]; + }); + icon.setAttribute("aria-hidden", "true"); + title.append(icon, " " + label); + toast.append(title); + + if (body) { + const bodyEl = document.createElement("div"); + bodyEl.className = "pt-toast__body"; + bodyEl.textContent = body; + toast.append(bodyEl); + } + + // The header's own height isn't fixed across breakpoints (taller with + // the tab bar on tablet/desktop) or over time (Material can hide/reveal + // it on scroll), so position below it fresh on every call rather than + // hardcoding an offset in CSS. + const header = document.querySelector(".md-header"); + const headerBottom = header ? header.getBoundingClientRect().bottom : 0; + toast.style.top = Math.max(headerBottom, 0) + 12 + "px"; + + // Retrigger the transition even if a toast is already showing (rapid + // clicks): drop the class, force layout, then re-add it, instead of + // just extending the existing timer. + toast.classList.remove("pt-toast--visible"); + void toast.offsetWidth; + toast.classList.add("pt-toast--visible"); + + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { + toast.classList.remove("pt-toast--visible"); + }, 1400); + } + + function getOrCreateThemeToggle() { + let container = document.getElementById("pt-theme-toggle"); + if (container) return container; + + const paletteForm = document.querySelector('[data-md-component="palette"]'); + if (!paletteForm) return null; + + const lightRadio = paletteForm.querySelector('input[data-md-color-scheme="default"]'); + const darkRadio = paletteForm.querySelector('input[data-md-color-scheme="slate"]'); + if (!lightRadio || !darkRadio) return null; + + paletteForm.hidden = true; + + container = document.createElement("div"); + container.id = "pt-theme-toggle"; + container.className = "pt-simplify-toggle pt-theme-toggle"; + container.setAttribute("role", "group"); + container.setAttribute("aria-label", "Color theme"); + + const highlight = document.createElement("span"); + highlight.className = "pt-simplify-highlight"; + highlight.setAttribute("aria-hidden", "true"); + + const light = document.createElement("button"); + light.type = "button"; + light.className = "pt-simplify-option pt-theme-option"; + light.dataset.scheme = "default"; + light.title = "Switch to light mode"; + light.setAttribute("aria-label", "Switch to light mode"); + + const dark = document.createElement("button"); + dark.type = "button"; + dark.className = "pt-simplify-option pt-theme-option"; + dark.dataset.scheme = "slate"; + dark.title = "Switch to dark mode"; + dark.setAttribute("aria-label", "Switch to dark mode"); + + container.append(highlight, dark, light); + paletteForm.insertAdjacentElement("beforebegin", container); + + container.addEventListener("click", function (event) { + const option = event.target.closest(".pt-theme-option"); + if (!option) return; + const scheme = option.dataset.scheme; + const radio = scheme === "slate" ? darkRadio : lightRadio; + if (radio.checked) return; + radio.checked = true; + radio.dispatchEvent(new Event("change", { bubbles: true })); + applyThemeState(container); + showToast(scheme === "slate" ? "Lights off" : "Lights on", { scheme: scheme }, ""); + }); + + // Sync to whatever scheme Material's own JS actually lands on, not + // just what we clicked. + lightRadio.addEventListener("change", function () { + applyThemeState(container); + }); + darkRadio.addEventListener("change", function () { + applyThemeState(container); + }); + + return container; + } + + function applyThemeState(container) { + const scheme = document.body.getAttribute("data-md-color-scheme"); + container.dataset.active = scheme === "slate" ? "dark" : "light"; + container.querySelectorAll(".pt-theme-option").forEach(function (option) { + option.setAttribute("aria-pressed", String(option.dataset.scheme === scheme)); + }); + } + + function setUp() { + const themeToggle = getOrCreateThemeToggle(); + if (themeToggle) applyThemeState(themeToggle); + setUpLibrarySpanRecompute(); + } + + // navigation.instant swaps page content via JS without a full reload, so + // DOMContentLoaded only fires once. document$ is Material's own + // observable that emits on every page change, instant or not. + if (window.document$) { + window.document$.subscribe(setUp); + } else { + document.addEventListener("DOMContentLoaded", setUp); + } +})(); diff --git a/docs/libraries/index.md b/docs/libraries/index.md index 1883887..5822d41 100644 --- a/docs/libraries/index.md +++ b/docs/libraries/index.md @@ -19,7 +19,7 @@ Libraries allow us to apply Python to real tasks. These are a few popular ones, - :material-format-list-group:{ .lg .middle } [__collections__](collections.md) [:material-language-python:](collections.md){ .pt-lib-badge .pt-lib-badge--builtin title="Built-in — included with Python" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Specialized containers: counting items, grouping with defaults, named tuples, fast queues. @@ -191,7 +191,7 @@ Libraries allow us to apply Python to real tasks. These are a few popular ones, - :material-matrix:{ .lg .middle } [__NumPy__](numpy.md) [:material-download-outline:](numpy.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Fast numeric arrays, with math applied to a whole array at once instead of item by item. @@ -204,7 +204,7 @@ Libraries allow us to apply Python to real tasks. These are a few popular ones, - :material-table:{ .lg .middle } [__pandas__](pandas.md) [:material-download-outline:](pandas.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Tabular data: rows and columns, like a spreadsheet, built on top of NumPy. @@ -432,7 +432,7 @@ Libraries allow us to apply Python to real tasks. These are a few popular ones, -
    +
    #### Testing { .pt-homepage-heading }
    @@ -456,14 +456,14 @@ Libraries allow us to apply Python to real tasks. These are a few popular ones,
    -
    +
    #### Computer vision { .pt-homepage-heading }
    - :material-face-recognition:{ .lg .middle } [__OpenCV__](opencv.md) [:material-download-outline:](opencv.md){ .pt-lib-badge .pt-lib-badge--third-party title="Third-party — install separately with pip" } - {: data-advanced="card" } + {: data-fcm-hide="essentials" } Real-time image and video analysis, built directly on NumPy arrays: color spaces, edge detection, face detection. diff --git a/docs/organization/classes.md b/docs/organization/classes.md index ced01e4..cc376e1 100644 --- a/docs/organization/classes.md +++ b/docs/organization/classes.md @@ -134,7 +134,7 @@ print(burmese.species) # "burmese" — a separate copy, not shared For a value every object should share instead of holding its own copy, see [class attributes](#class-attributes) below. -
    +
    ??? efficiency "For efficiency, use __slots__ when creating many instances" | | Time | Space (n instances) | @@ -320,7 +320,7 @@ print(burmese.kingdom) # "Animalia" — unaffected
    -## Method decorators { data-advanced="true" } +## Method decorators { data-fcm-hide="essentials" } Python provides 3 built-in [decorators](functions.md#decorators) for methods that change how the method is called and add functionality: @@ -450,7 +450,7 @@ snake.describe() # "a 5 ft ball python" — Snake's own version boa.describe() # "a heavy-bodied constrictor" — Boa's version replaces it ``` -### Multiple inheritance { data-advanced="true" } +### Multiple inheritance { data-fcm-hide="essentials" } A class can list more than one parent, comma-separated — it inherits the combined attributes and methods of all of them. When two parents define the same method, Python searches left to right through the parents listed and uses the first match — this search order is called the **MRO** (method resolution order). @@ -569,7 +569,7 @@ print(cobra.warning()) # "handle with extreme caution" — Venomous is listed
    -## Polymorphism { data-advanced="true" } +## Polymorphism { data-fcm-hide="essentials" } **Polymorphism** ("many forms") means the same method or function name behaves differently depending on which object it's called on — so you can call `.describe()` on any snake-like object without needing to know exactly which one it is. @@ -649,7 +649,7 @@ for s in (snake, boa): print(s.describe())
    -## Encapsulation { data-advanced="true" } +## Encapsulation { data-fcm-hide="essentials" } **Encapsulation** restricts direct access to an object's data, so it can only be read or changed through the class's own methods. Python doesn't enforce this the way some other languages do — it's a naming convention the caller is trusted to respect, not a hard restriction. @@ -707,7 +707,7 @@ ball.length_ft = -1 # ValueError — blocked by the setter
    -## Operator overloading { data-advanced="true" } +## Operator overloading { data-fcm-hide="essentials" } Defining a dunder method lets a built-in operator (`==`, `<`, `+`, ...) work on your own objects — the same mechanism as [`__str__()`](#defining-a-class) and [`__repr__()`](#defining-a-class), just for operators instead of printing. @@ -762,7 +762,7 @@ print(ball + burmese) # 21 — combined length
    -## Dataclasses { data-advanced="true" } +## Dataclasses { data-fcm-hide="essentials" } `@dataclass` generates `__init__()` and `__repr__()` automatically from a list of typed attributes, instead of writing them by hand. @@ -796,7 +796,7 @@ Use it for a class that's mostly just holding data, with little or no custom beh
    -## Abstract base classes { data-advanced="true" } +## Abstract base classes { data-fcm-hide="essentials" } An **abstract base class** defines methods that every subclass must implement, using `abc.ABC` and `@abstractmethod`. Trying to create an object from a class that hasn't implemented all of them raises a `TypeError` immediately, instead of failing later when the missing method actually gets called. diff --git a/docs/organization/functions.md b/docs/organization/functions.md index 14105a1..b169bc7 100644 --- a/docs/organization/functions.md +++ b/docs/organization/functions.md @@ -195,7 +195,7 @@ def describe(**details): describe(species="ball", length_ft=5) ``` -#### Type hints { data-advanced="true" } +#### Type hints { data-fcm-hide="essentials" } A type hint on a parameter like `species: str` annotates the type of value it's expected to receive. Python doesn't enforce it, but it can be helpful for you to keep track of it and a separate type checker (like `mypy`) can check for you. @@ -204,7 +204,7 @@ def describe(species: str, length_ft: float): return f"a {length_ft} ft {species} python" ``` -#### Combining categories { data-advanced="true" , data-card-link="skip" } +#### Combining categories { data-fcm-hide="essentials" , data-card-link="skip" } A single signature can mix kinds of parameters, but must be in this order: @@ -223,7 +223,7 @@ describe("ball", 5, 6, venomous=True, habitat="captive") # species = "ball", lengths = (5, 6), venomous = True, details = {"habitat": "captive"} ``` -#### Positional-only { data-advanced="true" } +#### Positional-only { data-fcm-hide="essentials" } A `/` in the parameter list marks every parameter before it **positional-only** — it can only be passed by position, never by name. Most parameters don't need this restriction. It mainly shows up in library code, where locking a parameter to positional-only lets the author rename it later without breaking callers who passed it by keyword. @@ -235,7 +235,7 @@ describe("ball", 5) # by position — works describe(species="ball", length_ft=5) # TypeError — species is positional-only ``` -#### Keyword-only { data-advanced="true" } +#### Keyword-only { data-fcm-hide="essentials" } A `*` in the parameter list marks every parameter after it **keyword-only** — it can only be passed by name, never by position. Keyword-only parameters suit options that would be unclear as a bare positional value — `venomous=True` reads clearly at the call site, `True` alone wouldn't. @@ -518,7 +518,7 @@ def show_species():
    -## Recursion { data-advanced="true" } +## Recursion { data-fcm-hide="essentials" } A function can call itself — this is called **recursion**, an alternative to a loop for problems that break down into smaller versions of themselves. @@ -560,7 +560,7 @@ Every recursive function needs two parts: print("liftoff") ``` -
    +
    ??? efficiency "For efficiency, use a loop instead of recursion to save memory" | | Time | Space | @@ -600,7 +600,7 @@ Every recursive function needs two parts:
    -## Decorators { data-advanced="true" } +## Decorators { data-fcm-hide="essentials" } **`@decorator`** lets you add behavior to a function without editing the function's own code — write the behavior once, then apply it to as many functions as you want. It's written as `@decorator_name`, placed directly above a `def`, and takes one function in, returning a function out[^callable]. @@ -784,7 +784,7 @@ print(total_length(5, 12, 8)) # prints "called with (5, 12, 8)", then 25 —
    -## Generators { data-advanced="true" } +## Generators { data-fcm-hide="essentials" } A **generator** is a function that pauses and resumes instead of running start to finish and returning once. Calling it doesn't run the body — it returns a **generator object** that produces values one at a time, only as they're asked for. diff --git a/docs/practices/errors.md b/docs/practices/errors.md index 8022176..bdd4969 100644 --- a/docs/practices/errors.md +++ b/docs/practices/errors.md @@ -380,7 +380,7 @@ finally: print(f"found it: {length} ft") # runs only if try succeeded ``` -
    +
    ??? efficiency "For efficiency, use try/except when success is the common case" | | Time | Space | diff --git a/docs/practices/style.md b/docs/practices/style.md index 0f6af68..0f5e4e3 100644 --- a/docs/practices/style.md +++ b/docs/practices/style.md @@ -25,7 +25,7 @@ Code that works isn't automatically code that's easy to read and maintain. Python runs styled and unstyled code identically, so following PEP 8 doesn't make a script more *correct* — it makes it more *predictable* to read. Anyone who's used Python before recognizes the shape of PEP 8-styled code, so sticking to it means less friction reading someone else's code, and less friction when someone else reads yours. -### File order { data-advanced="true" } +### File order { data-fcm-hide="essentials" } A Python file conventionally follows the same layout, top to bottom — a linter won't flag this on its own the way it does most of PEP 8, since it's a convention about where things go rather than a formatting rule.[^order-pep8] @@ -78,7 +78,7 @@ length_ft = 4.5 # clear at a glance A short name is fine when its scope is short too — `for s in species:` is common, since `s` only exists for the one line inside the loop. -### Constants { data-advanced="true" } +### Constants { data-fcm-hide="essentials" } A **constant** is a variable whose value isn't meant to change while the program runs — written in `ALL_CAPS` by convention, so it's easy to tell apart from a regular variable at a glance. Defining one instead of repeating a raw number (a "magic number") gives that number a name explaining what it means. @@ -93,7 +93,7 @@ if length_ft > MAX_TYPICAL_LENGTH_FT: Constants are usually defined near the top of a file, so they're easy to find and adjust later — see [File Order](#file-order) above. -### Quote style { data-advanced="true" } +### Quote style { data-fcm-hide="essentials" } Python treats `'single'` and `"double"` quotes identically for strings — PEP 8 doesn't prefer one over the other, just pick one as your default and stick with it throughout a file, rather than mixing both without reason. (This site uses double quotes.) The one except‌ion: switch to the other quote character for a string that itself contains a quote, rather than escaping it with a backslash. @@ -147,7 +147,7 @@ species = "ball python" length_ft = 4.5 ``` -### Indentation { data-advanced="true" } +### Indentation { data-fcm-hide="essentials" } Python uses indentation, not braces, to mark a block — PEP 8's rule is 4 spaces per level, never tabs (mixing the two causes real errors, not just style complaints). @@ -193,7 +193,7 @@ def describe(species, length_ft=4.5): # PEP 8 ... ``` -### Comments { data-advanced="true" } +### Comments { data-fcm-hide="essentials" } An inline comment needs at least two spaces before the `#` and one space after it; a block comment on its own line follows the same one-space-after rule. @@ -262,7 +262,7 @@ def add_sighting(species, log=None): # Pythonic — a fresh list every call return log ``` -### Truthy checks instead of len(x) > 0 { #truthy-checks data-advanced="true" } +### Truthy checks instead of len(x) > 0 { #truthy-checks data-fcm-hide="essentials" } Test a collection directly — a non-empty list is already truthy. @@ -274,7 +274,7 @@ if species: # Pythonic — a non-empty list is already truthy print("found some") ``` -### enumerate() instead of range(len(...)) { #enumerate-instead-of-range data-advanced="true" } +### enumerate() instead of range(len(...)) { #enumerate-instead-of-range data-fcm-hide="essentials" } Loop with both the index and the item at once, instead of indexing into the list by hand. @@ -303,7 +303,7 @@ if length_ft is None: # Pythonic — `is` is the correct tool for
    -## Efficiency { data-advanced="true" } +## Efficiency { data-fcm-hide="essentials" } Correct code produces the right output. diff --git a/docs/resources/files.md b/docs/resources/files.md index 0ada77c..4045fc8 100644 --- a/docs/resources/files.md +++ b/docs/resources/files.md @@ -216,7 +216,7 @@ with open("notes.txt", "r") as file: print(line.strip()) ``` -
    +
    ??? efficiency "For efficiency, loop over a file instead of reading it all at once" | | Time | Space | @@ -232,7 +232,7 @@ with open("notes.txt", "r") as file:
    -#### Seek and tell { data-advanced="true" } +#### Seek and tell { data-fcm-hide="essentials" } `.tell()` returns the current position in the file, as a character count from the start. `.seek(position)` moves back to a given position, letting you re-read part of a file without closing and reopening it. @@ -343,7 +343,7 @@ with open("notes.txt", "r") as file: print(file.read()) ``` -#### "x" create { data-advanced="true" } +#### "x" create { data-fcm-hide="essentials" } `"x"` is for when overwriting an existing file would be a mistake — it creates the file, but raises `FileExistsError` instead of silently replacing something already there. Like `"w"`, it's write-only — reading from that same file object raises an error, so reading it back means reopening it in `"r"` mode afterward. diff --git a/docs/start/workspace.md b/docs/start/workspace.md index cff8b31..65be327 100644 --- a/docs/start/workspace.md +++ b/docs/start/workspace.md @@ -147,7 +147,7 @@ That's it! You've written and run your first Python program. From here, you can
    -## Using the terminal { data-advanced="true" } +## Using the terminal { data-fcm-hide="essentials" } The terminal is a text-based way to navigate your computer's files and run programs. @@ -232,7 +232,7 @@ It's good for running Python files that are already finished — either your own
    -## Virtual environments { data-advanced="true" } +## Virtual environments { data-fcm-hide="essentials" } Sometimes you'll want to install [external libraries](../libraries/index.md) for your project. A **virtual environment** keeps each project's installed libraries in their own separate folder instead of installing them onto your computer. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 275317b..9dfb6b5 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -659,7 +659,7 @@ input:checked + .md-consent__settings { mask-image: var(--md-admonition-icon--python); } -/* ---- Custom "efficiency" admonition for runtime/space asides (data-advanced only) ---- */ +/* ---- Custom "efficiency" admonition for runtime/space asides (data-fcm-hide="essentials" only) ---- */ :root { --md-admonition-icon--efficiency: url('data:image/svg+xml;charset=utf-8,'); @@ -1057,9 +1057,15 @@ input:checked + .md-consent__settings { margin: 0.3rem 0 0.1rem; } -/* "Essentials / Advanced" toggle (docs/javascripts/essentials_toggle.js) - — both options always visible, sliding highlight behind the active - one, next to the palette toggle. Shows on every page. */ +/* Light/dark toggle track (docs/javascripts/theme_toggle.js) — both + options always visible, sliding highlight behind the active one, next + to the palette toggle. Shows on every page. + + The Essentials/Advanced content toggle used to share this same + .pt-simplify-* markup/CSS (two named options, one active at a time). It's + now the mkdocs-audience-toggle plugin's own #fcm-toggle, styled + below via that plugin's --fcm-* variables instead — see "Content mode + toggle" further down. */ .pt-simplify-toggle { position: relative; display: inline-flex; @@ -1094,7 +1100,6 @@ input:checked + .md-consent__settings { transition: transform 0.2s ease; } -.pt-simplify-toggle[data-active="simplified"] .pt-simplify-highlight, .pt-simplify-toggle[data-active="light"] .pt-simplify-highlight { border-radius: 1rem 0 0 1rem; transform: translateX(100%); @@ -1121,33 +1126,18 @@ input:checked + .md-consent__settings { /* Active option's text sits on the currentColor highlight, so it flips to the bg color for contrast; inactive stays plain currentColor. */ -.pt-simplify-toggle[data-active="simplified"] .pt-simplify-option[data-mode="simplified"], -.pt-simplify-toggle[data-active="advanced"] .pt-simplify-option[data-mode="advanced"], .pt-simplify-toggle[data-active="light"] .pt-simplify-option[data-scheme="default"], .pt-simplify-toggle[data-active="dark"] .pt-simplify-option[data-scheme="slate"] { color: var(--pt-bg); } -/* Icon for each option (Material Symbols "psychiatry" / "park", viewBox - 0 -960 960 960), same mask-image technique as .pt-theme-option's - sun/moon. On desktop it leads the "Essentials"/"Advanced" word — a - ::before, same as .pt-theme-option's own icon and the toast's - .pt-mode-icon (see below), so all three read consistently as - "icon, then text" — matching Material's own "back to top" button - (`.md-top`: icon SVG, then the "Back to top" label). The option - button itself is a flex row (see .pt-simplify-option above), so - the icon centers vertically against the text the same way .pt-theme- - option's icon-only button centers its own icon — no vertical-align - fudging needed, and it can't drift out of sync between the two variants. - Below ~45em (matches this file's other mobile breakpoints) the words - get tight, so the label is visually clipped and the icon alone - represents the option — it stays in the DOM (not aria-hidden) so the - button's accessible name is unchanged for screen readers either way. - Sized smaller (0.7rem) than the toast's own .pt-mode-icon (1.2rem, - matching Material's "back to top" icon) — this icon sits inside the - 1.2rem-tall toggle track itself, so it stays the toggle's own icon - size rather than the toast's. */ -.pt-simplify-option[data-mode]::before, +/* Reusable sun/moon icon sizing, shared between .pt-theme-option's own + ::before (see below) and the toast's real .pt-mode-icon element + (docs/javascripts/theme_toggle.js's showToast) — same mask-image + technique, so both read as the exact same icon. Sized smaller (0.7rem) + than the toast's own override (1.2rem, matching Material's "back to + top" icon) since this one sits inside the 1.2rem-tall toggle track + itself. */ .pt-mode-icon { content: ""; display: inline-block; @@ -1163,10 +1153,6 @@ input:checked + .md-consent__settings { mask-position: center; } -.pt-simplify-option[data-mode]::before { - margin-right: 0.3rem; -} - /* Toast-only override: matches the size of Material's own "back to top" icon (`.md-icon svg`, 1.2rem), per the shared rule's comment above. */ .pt-mode-icon { @@ -1174,43 +1160,12 @@ input:checked + .md-consent__settings { height: 1.2rem; } -.pt-simplify-option[data-mode="simplified"]::before, -.pt-mode-icon[data-mode="simplified"] { - -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M450-130v-309h-20q-64 0-120.5-24.5T209-533q-44-45-66.5-104T120-760v-80h78.32Q260-840 317-815.5 374-791 419-746q33 34 54.5 76t30.5 89q7.65-11.9 16.82-22.95Q530-615 540-626q45-45 102-69.5T761.67-720H840v80q0 64-23.98 123T748-413q-45 45-101.56 69T528-320h-18v190h-60Zm1-370q0-61-20-113.5t-55-89q-35-36.5-86-57T180-780q0 63 18.5 115.5T252-575q42 45 90.5 60T451-500Zm59 120q60 0 111-19.5t86-56q35-36.5 54-89T780-660q-60 0-111 20.5T583-583q-43 45-58 94t-15 109Zm0 0Zm-59-120Z'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M450-130v-309h-20q-64 0-120.5-24.5T209-533q-44-45-66.5-104T120-760v-80h78.32Q260-840 317-815.5 374-791 419-746q33 34 54.5 76t30.5 89q7.65-11.9 16.82-22.95Q530-615 540-626q45-45 102-69.5T761.67-720H840v80q0 64-23.98 123T748-413q-45 45-101.56 69T528-320h-18v190h-60Zm1-370q0-61-20-113.5t-55-89q-35-36.5-86-57T180-780q0 63 18.5 115.5T252-575q42 45 90.5 60T451-500Zm59 120q60 0 111-19.5t86-56q35-36.5 54-89T780-660q-60 0-111 20.5T583-583q-43 45-58 94t-15 109Zm0 0Zm-59-120Z'/%3E%3C/svg%3E"); -} - -.pt-simplify-option[data-mode="advanced"]::before, -.pt-mode-icon[data-mode="advanced"] { - -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M538-80H423v-149H120l189-274h-95l266-377 266 377h-94l188 274H538v149ZM236-289h189-90 290-89 189-489Zm0 0h489L536-563h89L480-769 335-563h90L236-289Z'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M538-80H423v-149H120l189-274h-95l266-377 266 377h-94l188 274H538v149ZM236-289h189-90 290-89 189-489Zm0 0h489L536-563h89L480-769 335-563h90L236-289Z'/%3E%3C/svg%3E"); -} - -@media (max-width: 45em) { - .pt-simplify-option[data-mode]::before { - margin-right: 0; - } - - .pt-simplify-toggle:has(.pt-simplify-option[data-mode]) .pt-simplify-option { - padding: 0 0.4rem; - } - - .pt-simplify-option[data-mode] .pt-simplify-label { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - } -} - -/* Several of Material's own rules (.md-header__option, .md-typeset - details, .md-option:checked + label) beat the plain [hidden] { display: - none } UA rule on specificity, so an element we hide via JS (.hidden = - true) can silently stay visible — hit this three times now (the palette - form, admonitions under a hidden heading, the old knob). One global - override instead of chasing each element type. +/* Several of Material's own rules (.md-header__option, .md-option:checked + + label) beat the plain [hidden] { display: none } UA rule on + specificity, so an element we hide via JS (.hidden = true) can silently + stay visible — hit this before (the palette form, hidden here via + theme_toggle.js). One global override instead of chasing each element + type. Excludes Material's own [data-md-component="sidebar"] (the mobile nav drawer, reused as .md-sidebar--primary): Material toggles its `hidden` attribute itself and reveals it via its own more-specific (but @@ -1223,6 +1178,37 @@ input:checked + .md-consent__settings { display: none !important; } +/* Content mode toggle (Essentials/Advanced), from the + mkdocs-audience-toggle plugin configured in mkdocs.yml — see + CLAUDE.md's "Planned extraction" section. The plugin's own CSS + (audience_toggle.css) provides the track/highlight/option/icon + layout and mobile label-collapse; this site only overrides its --fcm-* + styling hooks to match the .pt-simplify-toggle look above, plus the + header spacing the plugin doesn't set for itself. */ +#fcm-toggle { + margin: 0.4rem 0.2rem; + --fcm-track-bg: var(--pt-bg); + --fcm-active-fg: var(--pt-bg); +} + +/* The plugin's own confirmation toast (shown on switching content mode) — + matched to .pt-toast's look above (the light/dark toggle's own toast) + instead of the plugin's plain default, so both read as the same site + chrome element. */ +#fcm-toast { + border-radius: 1.6rem; + background-color: var(--md-default-bg-color); + color: var(--md-default-fg-color--light); + font-weight: 400; + line-height: 1.4; + box-shadow: var(--md-shadow-z2); + transition: opacity 0.7s ease, transform 0.7s ease; +} + +#fcm-toast.fcm-toast--visible { + transition: opacity 0.15s ease, transform 0.15s ease; +} + /* Light/dark toggle — shares .pt-simplify-toggle/-highlight/-option, but each option is a fixed choice (sun = light, moon = dark) rather than one element showing whichever mode is current. Native Material markup @@ -1256,7 +1242,7 @@ input:checked + .md-consent__settings { } /* Same sun/moon icons, reusable on a real element (not a pseudo-element) — - used by the toast (docs/javascripts/essentials_toggle.js's showToast) + used by the toast (docs/javascripts/theme_toggle.js's showToast) for the light/dark toggle's own "Lights on"/"Lights off" message, keeping it visually consistent with .pt-theme-option's icon. */ .pt-mode-icon[data-scheme="default"] { @@ -1269,9 +1255,8 @@ input:checked + .md-consent__settings { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480-120Zm0-60q109 0 190-67.5T771-406q-25 11-53.67 16.5Q688.67-384 660-384q-114.69 0-195.34-80.66Q384-545.31 384-660q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180-480q0 125 87.5 212.5T480-180Zm-4-297Z'/%3E%3C/svg%3E"); } -/* Disappearing confirmation toast for the Essentials/Advanced and - light/dark toggles (docs/javascripts/essentials_toggle.js's showToast). - Sits just below the header — right where the toggle that triggered it +/* Disappearing confirmation toast for the light/dark toggle + (docs/javascripts/theme_toggle.js's showToast). Sits just below the header — right where the toggle that triggered it lives — rather than at the bottom of the screen, so it's immediately next to the control the reader just clicked. `top` itself is set inline by showToast() (the header's own height varies: ~48px on mobile, taller @@ -1303,7 +1288,7 @@ input:checked + .md-consent__settings { the page content behind it. */ pointer-events: none; /* Fade only — no slide/transform. The fade-out itself is slow and - gentle; showToast() in essentials_toggle.js keeps the fully-visible + gentle; showToast() in theme_toggle.js keeps the fully-visible hold time short instead, so the toast still clears the screen quickly overall. */ transition: opacity 0.7s ease; @@ -1326,10 +1311,9 @@ input:checked + .md-consent__settings { } /* Only add space below the title when there's actually a body line under - it (the Essentials/Advanced toast has one; the light/dark toast's title - is self-explanatory and passes no body — see showToast() in - essentials_toggle.js) — otherwise this margin just reads as extra - padding at the bottom of a one-line toast. */ + it — the light/dark toast's own title is self-explanatory and always + passes no body (see showToast() in theme_toggle.js), so this only + matters if a future caller adds one. */ .pt-toast__title:not(:last-child) { margin-bottom: 0.2rem; } @@ -1338,13 +1322,15 @@ input:checked + .md-consent__settings { font-weight: 400; } -.simplify-active [data-advanced] { - display: none; -} - -/* data-advanced="card" hides the entire card, not just the marked paragraph - — walk up from whichever paragraph carries it to the enclosing
  • . */ -.simplify-active .grid.cards > ul > li:has(> p[data-advanced="card"]) { +/* The mkdocs-audience-toggle plugin hides a marked element directly + (see mkdocs.yml) — but a homepage card's data-fcm-hide="essentials" marker + sits on its first paragraph (the icon/title line attr_list attaches to, + not the
  • — python-markdown's attr_list can't target a list item that + has more than one paragraph), so the plugin only hides that one line, + leaving the rest of the card behind as an empty box. Hide the whole + card here instead, keyed off the same marker + the mode the plugin + itself sets on . */ +html[data-fcm-mode="essentials"] .grid.cards > ul > li:has(> p[data-fcm-hide~="essentials"]) { display: none; } @@ -1394,10 +1380,10 @@ input:checked + .md-consent__settings { .pt-category--wide.pt-lib--5 { grid-column: span 4; } /* caps at the grid's own width, same as 4 */ /* pt-lib--N above is sized for the full card count; Essentials mode - hides some, leaving boxes too wide. essentials_toggle.js recomputes the + hides some, leaving boxes too wide. theme_toggle.js recomputes the visible count into --pt-lib-span, overriding pt-lib--N here (same specificity, later in source) while active. */ - .simplify-active .pt-category--wide { + html[data-fcm-mode="essentials"] .pt-category--wide { grid-column: span var(--pt-lib-span, 4); } } diff --git a/docs/types/basics.md b/docs/types/basics.md index c093200..2170f5f 100644 --- a/docs/types/basics.md +++ b/docs/types/basics.md @@ -432,7 +432,7 @@ Strings use the same index and slice syntax as lists. `0` is the first character "-".join(["burmese", "python"]) # "burmese-python" ``` -
    +
    ??? efficiency "For efficiency, use .join() instead of += in a loop" | | Time | Space | diff --git a/docs/types/collections.md b/docs/types/collections.md index 5654eb6..28fa66f 100644 --- a/docs/types/collections.md +++ b/docs/types/collections.md @@ -447,7 +447,7 @@ class diagram panel See the [collections library page](../libraries/collections.md) for the rest of `deque`'s methods (`rotate()`, `maxlen=`, and more) and for the other list-adjacent tools it adds. -
    +
    ??? efficiency "For efficiency, use append()/pop() instead of insert(0, x)/pop(0)" | | Time | Space | @@ -538,7 +538,7 @@ flowchart LR snake.get("weight_lbs", 0) # 0 — key is missing, so the default is returned instead of None ``` -
    +
    ??? efficiency "For efficiency, use .get() instead of checking in first" | | Time | Space | @@ -703,7 +703,7 @@ flowchart LR snakes["burmese"]["length_ft"] # 16 ``` -
    +
    ??? efficiency "For efficiency, use a dict instead of a list to look up by key" | | Time | Space | @@ -820,7 +820,7 @@ flowchart LR
    -## Tuples { data-advanced="true" } +## Tuples { data-fcm-hide="essentials" } A tuple stores multiple items, in order, written in parentheses. They are **immutable** so the items can't be changed once its created. @@ -1093,7 +1093,7 @@ The **negative index** starts counting down from the end instead, starting at `-
    -## Sets { data-advanced="true" } +## Sets { data-fcm-hide="essentials" } A set stores multiple items, in no particular order, inside a single variable — written with curly braces. @@ -1329,7 +1329,7 @@ These check a relationship between two sets and hand back a `bool`, rather than list(set(species)) # ["burmese", "ball", "boa"] — order not guaranteed ``` -
    +
    ??? efficiency "For efficiency, use a set instead of a list for membership checks" | | Time | Space | diff --git a/mkdocs.yml b/mkdocs.yml index be0409c..ddf61d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,39 @@ plugins: # here or the site silently loses its search feature. - search - nested-tabs + # Essentials/Advanced content toggle — see extra.css's "Essentials/Advanced + # toggle" section for the --fcm-* variable overrides that give it this + # site's look, and STRUCTURE.md / CLAUDE.md for the data-fcm-hide="essentials" + # marking convention. "Advanced" is listed first and marked default: true + # so it keeps showing everything on first visit, matching this site's + # prior behavior. + - audience_toggle: + aria_label: Content level + collapse_labels: true + query_param: mode + wrapper_class: + - pfg-section + modes: + - name: advanced + label: Advanced + default: true + description: Show all site content + announcement: Viewing all content. + icon: >- + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 + 960'%3E%3Cpath d='M538-80H423v-149H120l189-274h-95l266-377 266 377h-94l188 274H538v149ZM236-289h189-90 + 290-89 189-489Zm0 0h489L536-563h89L480-769 335-563h90L236-289Z'/%3E%3C/svg%3E") + - name: essentials + label: Essentials + description: Show only what you need to write your first programs + announcement: Just the basics, start here! + icon: >- + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 + 960'%3E%3Cpath d='M450-130v-309h-20q-64 0-120.5-24.5T209-533q-44-45-66.5-104T120-760v-80h78.32Q260-840 + 317-815.5 374-791 419-746q33 34 54.5 76t30.5 89q7.65-11.9 16.82-22.95Q530-615 540-626q45-45 + 102-69.5T761.67-720H840v80q0 64-23.98 123T748-413q-45 45-101.56 69T528-320h-18v190h-60Zm1-370q0-61-20-113.5t-55-89q-35-36.5-86-57T180-780q0 + 63 18.5 115.5T252-575q42 45 90.5 60T451-500Zm59 120q60 0 111-19.5t86-56q35-36.5 54-89T780-660q-60 + 0-111 20.5T583-583q-43 45-58 94t-15 109Zm0 0Zm-59-120Z'/%3E%3C/svg%3E") nav: - All: index.md @@ -156,7 +189,7 @@ extra_javascript: - javascripts/external_links.js - javascripts/homepage_header_title.js - javascripts/cheatsheet_button.js - - javascripts/essentials_toggle.js + - javascripts/theme_toggle.js - javascripts/a11y_patches.js - https://unpkg.com/mermaid@11/dist/mermaid.min.js - javascripts/mermaid_config.js diff --git a/requirements.txt b/requirements.txt index 72a82d9..b242836 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ mkdocs-material # Extracted nested-tabs header row — see CLAUDE.md's "Planned extraction" # section. Published to PyPI; was a local editable install before 2026-09-23. mkdocs-nested-tabs==0.2.0 +# Extracted Essentials/Advanced content toggle — see CLAUDE.md's "Planned +# extraction" section. Published to PyPI; was a local editable install before 2026-09-26. +mkdocs-audience-toggle==0.1.0 pytest playwright pytest-playwright diff --git a/tests/test_accessibility_browser.py b/tests/test_accessibility_browser.py index 4c8a7a3..a0c4da0 100644 --- a/tests/test_accessibility_browser.py +++ b/tests/test_accessibility_browser.py @@ -37,7 +37,7 @@ def _select_scheme(page, scheme): """Flip Material's palette to `scheme` ('default' = light, 'slate' = dark). - docs/javascripts/essentials_toggle.js replaces the visible light/dark control with its + docs/javascripts/theme_toggle.js replaces the visible light/dark control with its own buttons and hides Material's native radio entirely, so Playwright can't click it as a normal user control. Clicking it directly still fires the same input change that Material's own JS (and our buttons) rely on to apply and persist the scheme. diff --git a/tests/test_accessibility_keyboard.py b/tests/test_accessibility_keyboard.py index 2935acb..8568457 100644 --- a/tests/test_accessibility_keyboard.py +++ b/tests/test_accessibility_keyboard.py @@ -60,9 +60,9 @@ def test_no_positive_tabindex(page, site_url, path): def test_palette_toggle_is_keyboard_reachable(page, site_url): """The dark/light toggle must be operable without a mouse. docs/javascripts/ - essentials_toggle.js replaces Material's native radios+labels with its own + theme_toggle.js replaces Material's native radios+labels with its own `.pt-theme-option` buttons (a two-segment sun/moon control, matching the - Essentials/Complete toggle) and hides the native form — so this checks the + Essentials/Advanced content-mode toggle) and hides the native form — so this checks the *replacement* buttons are real, labeled, visible controls and that Tab reaches one, rather than the native radios (which are now deliberately hidden).""" page.goto(site_url) diff --git a/tests/test_content_mode_toggle.py b/tests/test_content_mode_toggle.py new file mode 100644 index 0000000..e789ae8 --- /dev/null +++ b/tests/test_content_mode_toggle.py @@ -0,0 +1,182 @@ +"""The Essentials/Advanced content-mode toggle, provided by the +mkdocs-audience-toggle plugin (configured in mkdocs.yml) rather than by +site-local JS — see CLAUDE.md's "Planned extraction" section. + +Covers: the default (Advanced) state, that ?mode=essentials both hides marked +content and carries the state onto a content page's own heading + TOC entry, and +the link-recovery behavior — a page can still show a *link* to a hidden section +(e.g. collections.md's cheat-sheet table links to #tuples even though the +"Tuples" heading below it is hidden in Essentials mode); clicking it should flip +back to Advanced and reveal the target rather than silently doing nothing. + +Browser tier — same setup as test_accessibility_browser.py (`playwright install chromium`). +""" + + +def test_advanced_content_visible_by_default(page, site_url): + page.goto(site_url) + mode = page.evaluate("() => document.documentElement.getAttribute('data-fcm-mode')") + assert mode == "advanced", "Advanced should be the default mode" + + # A row-level marker: the "sets" keyword-link row under Collections. + hidden = page.evaluate( + """() => { + const row = document.querySelector('p[data-fcm-hide~="essentials"]'); + return row ? getComputedStyle(row).display === 'none' : null; + }""" + ) + assert hidden is False, "a data-fcm-hide=\"essentials\" row should be visible in Advanced" + + +def test_mode_query_param_hides_marked_row(page, site_url): + page.goto(f"{site_url}/?mode=essentials") + mode = page.evaluate("() => document.documentElement.getAttribute('data-fcm-mode')") + assert mode == "essentials", "?mode=essentials should activate Essentials mode" + + hidden = page.evaluate( + """() => { + const row = document.querySelector('p[data-fcm-hide~="essentials"]'); + return row ? getComputedStyle(row).display === 'none' : null; + }""" + ) + assert hidden is True, "a data-fcm-hide=\"essentials\" row should be hidden once in Essentials mode" + + +def test_essentials_state_carries_to_content_page_heading_and_toc(page, site_url): + """functions.md's own '## Decorators { data-fcm-hide="essentials" }' heading (and its + integrated-TOC entry) should hide too — carried over from the homepage's marker via + localStorage, with no need to visit the homepage first in this same test.""" + page.goto(f"{site_url}/organization/functions/?mode=essentials") + + result = page.evaluate( + """() => { + const heading = document.getElementById('decorators'); + const tocLink = document.querySelector('a.md-nav__link[href$="#decorators"]'); + const tocItem = tocLink ? tocLink.closest('.md-nav__item') : null; + return { + headingDisplay: heading ? getComputedStyle(heading).display : null, + tocItemDisplay: tocItem ? getComputedStyle(tocItem).display : null, + }; + }""" + ) + assert result["headingDisplay"] == "none", "Decorators heading should be hidden" + assert result["tocItemDisplay"] == "none", "Decorators' TOC entry should be hidden too" + + +def test_admonition_inside_a_hidden_section_is_actually_hidden(page, site_url): + """Regression (see extra.css's [hidden] override and its history): Material's + `.md-typeset details { display: flow-root }` used to beat a plain `[hidden] { + display: none }` UA rule on specificity, so JS-driven hiding of an admonition + inside a hidden section didn't actually hide it. The plugin sidesteps that class + of bug entirely by hiding elements with an inline `style.display = "none"` + instead of the `hidden` attribute — inline style always wins on specificity.""" + page.goto(f"{site_url}/types/collections/?mode=essentials") + + hidden_and_shown = page.evaluate( + """() => [...document.querySelectorAll('.md-typeset details')] + .filter((d) => d.style.display === 'none') + .map((d) => getComputedStyle(d).display !== 'none')""" + ) + assert hidden_and_shown, "expected at least one admonition inside a hidden section" + assert not any(hidden_and_shown), ( + "an admonition has style.display = 'none' but still computes a visible display" + ) + + +def test_pfg_section_wrapper_is_hidden_with_its_heading(page, site_url): + """Regression: many pages wrap a whole ## section in raw + `
    ` for its own card-style border/background (not + generated — written directly in the markdown). Configured via the plugin's own + `wrapper_class: [pfg-section]` option (mkdocs.yml) — without it, only the + heading and its flow siblings *inside* that wrapper would hide, leaving the + wrapper itself on screen as an empty bordered card.""" + page.goto(f"{site_url}/types/collections/?mode=essentials") + + result = page.evaluate( + """() => { + const heading = document.getElementById('tuples'); + const wrapper = heading.closest('.pfg-section'); + return { + wrapperFound: !!wrapper, + wrapperDisplay: wrapper ? getComputedStyle(wrapper).display : null, + }; + }""" + ) + assert result["wrapperFound"], "expected #tuples to sit inside a .pfg-section wrapper" + assert result["wrapperDisplay"] == "none", "the wrapper is still rendering as an empty card" + + +def test_whole_homepage_card_hides_with_its_first_paragraph(page, site_url): + """Regression: attr_list can only attach data-fcm-hide to a card's first paragraph + (the icon/title line), not the surrounding
  • — python-markdown's attr_list + can't target a list item with more than one paragraph. extra.css hides the + whole card with a :has() rule keyed off that same marker plus the plugin's own + html[data-fcm-mode] — see the "mkdocs-audience-toggle plugin hides..." + comment in extra.css.""" + page.goto(f"{site_url}/?mode=essentials") + + card_display = page.evaluate( + """() => { + const marked = document.querySelector('.grid.cards > ul > li > p[data-fcm-hide~="essentials"]'); + const card = marked ? marked.closest('li') : null; + return card ? getComputedStyle(card).display : null; + }""" + ) + assert card_display == "none", "a card marked via its first paragraph should fully hide" + + +def test_link_to_hidden_section_recovers_to_advanced(page, site_url): + """collections.md's own cheat-sheet table (near the top) links to #tuples even + while the "Tuples" heading itself is hidden by data-fcm-hide="essentials" — clicking + that visible link should flip the toggle back to Advanced and reveal the + section, rather than landing on a hidden target and doing nothing.""" + page.goto(f"{site_url}/types/collections/?mode=essentials") + + tuples_link = page.locator('table a[href$="#tuples"]') + assert tuples_link.count() > 0, "expected the cheat-sheet table's #tuples link to exist" + + before = page.evaluate("() => getComputedStyle(document.getElementById('tuples')).display") + assert before == "none", "Tuples section should start hidden while in Essentials mode" + + tuples_link.first.click() + + # hashchange (which drives the recovery) always fires as a separate queued + # task, never synchronously with the click — so the reveal can still be + # pending right after .click() returns. Wait for it instead of assuming it + # already happened (this was flaky in CI for exactly that reason). + page.wait_for_function( + "() => getComputedStyle(document.getElementById('tuples')).display !== 'none'" + ) + + after = page.evaluate( + """() => ({ + tuplesDisplay: getComputedStyle(document.getElementById('tuples')).display, + mode: document.documentElement.getAttribute('data-fcm-mode'), + toggleActive: document.getElementById('fcm-toggle')?.dataset.active, + stored: localStorage.getItem('fcm-mode'), + })""" + ) + assert after["tuplesDisplay"] != "none", "clicking the link should reveal the Tuples section" + assert after["mode"] == "advanced", "clicking the link should flip the mode to Advanced" + assert after["toggleActive"] == "advanced", "the toggle's own state should flip to Advanced" + assert after["stored"] == "advanced", "the flip should persist to localStorage" + + +def test_link_recovery_ignores_toc_links_to_visible_sections(page, site_url): + """Sanity check the recovery handler isn't overly broad: clicking an ordinary link to + a section that's already visible shouldn't touch the content mode at all.""" + page.goto(f"{site_url}/types/collections/?mode=essentials") + + # Material renders a page's TOC twice: once for real in the secondary + # (right-hand) sidebar, and once inert (visibility:collapse) inside the + # primary nav's copy of the current page's entry — scope to the visible + # one so .first doesn't land on the inert copy and time out. + lists_link = page.locator('.md-sidebar--secondary a.md-nav__link[href$="#lists"]') + assert lists_link.count() > 0 + + lists_link.first.click() + + still_essentials = page.evaluate( + "() => document.documentElement.getAttribute('data-fcm-mode')" + ) + assert still_essentials == "essentials", "a link to an already-visible section should not flip the mode" diff --git a/tests/test_essentials_toggle.py b/tests/test_essentials_toggle.py deleted file mode 100644 index e1fde94..0000000 --- a/tests/test_essentials_toggle.py +++ /dev/null @@ -1,161 +0,0 @@ -"""The "Essentials / Advanced" Simplify toggle -(docs/javascripts/essentials_toggle.js, docs/stylesheets/extra.css). - -Covers: the default (Advanced) state, that ?simplified=true both hides marked -content and carries the state onto a content page's own heading + TOC entry, and the -link-recovery behavior — a page can still show a *link* to a hidden section (e.g. -collections.md's cheat-sheet table links to #tuples even though the "Tuples" heading -below it is hidden in Essentials mode); clicking it should flip back to Advanced and -reveal the target rather than silently doing nothing. - -Browser tier — same setup as test_accessibility_browser.py (`playwright install chromium`). -""" - - -def test_advanced_content_visible_by_default(page, site_url): - page.goto(site_url) - is_simplified = page.evaluate("() => document.body.classList.contains('simplify-active')") - assert not is_simplified, "Simplify should not be active by default" - - # A row-level marker: the "sets" keyword-link row under Collections. - hidden = page.evaluate( - """() => { - const row = document.querySelector('p[data-advanced="true"]'); - return row ? getComputedStyle(row).display === 'none' : null; - }""" - ) - assert hidden is False, "a data-advanced row should be visible when not simplified" - - -def test_simplified_query_param_hides_marked_row(page, site_url): - page.goto(f"{site_url}/?simplified=true") - is_simplified = page.evaluate("() => document.body.classList.contains('simplify-active')") - assert is_simplified, "?simplified=true should activate Simplify mode" - - hidden = page.evaluate( - """() => { - const row = document.querySelector('p[data-advanced="true"]'); - return row ? getComputedStyle(row).display === 'none' : null; - }""" - ) - assert hidden is True, "a data-advanced row should be hidden once simplified" - - -def test_simplified_state_carries_to_content_page_heading_and_toc(page, site_url): - """functions.md's own '## Decorators { data-advanced="true" }' heading (and its - integrated-TOC entry) should hide too — carried over from the homepage's marker via - localStorage, with no need to visit the homepage first in this same test.""" - page.goto(f"{site_url}/organization/functions/?simplified=true") - - result = page.evaluate( - """() => { - const heading = document.getElementById('decorators'); - const tocLink = document.querySelector('a.md-nav__link[href$="#decorators"]'); - const tocItem = tocLink ? tocLink.closest('.md-nav__item') : null; - return { - headingHidden: heading ? heading.hidden : null, - tocItemHidden: tocItem ? tocItem.hidden : null, - }; - }""" - ) - assert result["headingHidden"] is True, "Decorators heading should be hidden" - assert result["tocItemHidden"] is True, "Decorators' TOC entry should be hidden too" - - -def test_admonition_inside_a_hidden_section_is_actually_hidden(page, site_url): - """Regression: Material's `.md-typeset details { display: flow-root }` beats the - plain `[hidden] { display: none }` UA rule on specificity, so setting `.hidden = true` - on an admonition inside a hidden section didn't actually hide it — it stayed on - screen as a bordered box even though its heading was gone. Fixed with a blanket - `[hidden] { display: none !important }` in extra.css.""" - page.goto(f"{site_url}/types/collections/?simplified=true") - - hidden_and_shown = page.evaluate( - """() => [...document.querySelectorAll('.md-typeset details')] - .filter((d) => d.hidden) - .map((d) => getComputedStyle(d).display !== 'none')""" - ) - assert hidden_and_shown, "expected at least one admonition inside a hidden section" - assert not any(hidden_and_shown), ( - "an admonition has .hidden = true but still computes a visible display" - ) - - -def test_pfg_section_wrapper_is_hidden_with_its_heading(page, site_url): - """Regression: many pages wrap a whole ## section in raw - `
    ` for its own card-style border/background (not - generated — written directly in the markdown). setSectionHidden only hid the - heading and its flow siblings, which sit *inside* that wrapper — the wrapper - itself was never touched, so it stayed on screen as an empty bordered card - once everything inside it was hidden.""" - page.goto(f"{site_url}/types/collections/?simplified=true") - - result = page.evaluate( - """() => { - const heading = document.getElementById('tuples'); - const wrapper = heading.closest('.pfg-section'); - return { - wrapperFound: !!wrapper, - wrapperHidden: wrapper ? wrapper.hidden : null, - wrapperDisplay: wrapper ? getComputedStyle(wrapper).display : null, - }; - }""" - ) - assert result["wrapperFound"], "expected #tuples to sit inside a .pfg-section wrapper" - assert result["wrapperHidden"] is True, "the .pfg-section wrapper should be hidden too" - assert result["wrapperDisplay"] == "none", "the wrapper is still rendering as an empty card" - - -def test_link_to_hidden_section_recovers_to_advanced(page, site_url): - """collections.md's own cheat-sheet table (near the top) links to #tuples even - while the "Tuples" heading itself is hidden by data-advanced — clicking that visible - link should flip the toggle back to Advanced and reveal the section, rather than - landing on a hidden target and doing nothing.""" - page.goto(f"{site_url}/types/collections/?simplified=true") - - tuples_link = page.locator('table a[href$="#tuples"]') - assert tuples_link.count() > 0, "expected the cheat-sheet table's #tuples link to exist" - - before = page.evaluate("() => document.getElementById('tuples').hidden") - assert before is True, "Tuples section should start hidden while simplified" - - tuples_link.first.click() - - # hashchange (which drives the recovery) always fires as a separate queued - # task, never synchronously with the click — so the reveal can still be - # pending right after .click() returns. Wait for it instead of assuming it - # already happened (this was flaky in CI for exactly that reason). - page.wait_for_function("() => document.getElementById('tuples').hidden === false") - - after = page.evaluate( - """() => ({ - tuplesHidden: document.getElementById('tuples').hidden, - simplifyActive: document.body.classList.contains('simplify-active'), - toggleActive: document.getElementById('pt-simplify-toggle')?.dataset.active, - stored: localStorage.getItem('pt-simplify-active'), - })""" - ) - assert after["tuplesHidden"] is False, "clicking the link should reveal the Tuples section" - assert after["simplifyActive"] is False, "clicking the link should turn Simplify off" - assert after["toggleActive"] == "advanced", "the toggle's own state should flip to Advanced" - assert after["stored"] == "false", "the flip should persist to localStorage" - - -def test_link_recovery_ignores_toc_links_to_visible_sections(page, site_url): - """Sanity check the recovery handler isn't overly broad: clicking an ordinary link to - a section that's already visible shouldn't touch Simplify state at all.""" - page.goto(f"{site_url}/types/collections/?simplified=true") - - # Material renders a page's TOC twice: once for real in the secondary - # (right-hand) sidebar, and once inert (visibility:collapse) inside the - # primary nav's copy of the current page's entry — scope to the visible - # one so .first doesn't land on the inert copy and time out. - lists_link = page.locator('.md-sidebar--secondary a.md-nav__link[href$="#lists"]') - assert lists_link.count() > 0 - - lists_link.first.click() - - still_simplified = page.evaluate( - "() => document.body.classList.contains('simplify-active')" - ) - assert still_simplified, "a link to an already-visible section should not flip the toggle" From 98ab05942cb3120279c40f2b42198a6cc3e6c0e2 Mon Sep 17 00:00:00 2001 From: Luka Sherman Date: Sat, 26 Sep 2026 23:03:02 -0700 Subject: [PATCH 2/3] move audience toggle ahead of theme --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index ddf61d9..f8e476a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,8 @@ plugins: # prior behavior. - audience_toggle: aria_label: Content level + # theme_toggle.js creates #pt-theme-toggle before this plugin's script runs. + insert_selector: "#pt-theme-toggle" collapse_labels: true query_param: mode wrapper_class: From 94f027319e87efeae7245a5ef0de4ffce79e4371 Mon Sep 17 00:00:00 2001 From: Luka Sherman Date: Sat, 26 Sep 2026 23:03:56 -0700 Subject: [PATCH 3/3] add new pypi to readme --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3abc69b..4aeedef 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ A diagram renderer, which draws flowcharts and diagrams from a plain-text descri ### Essentials / Advanced toggle -A two-option switch, provided by the [mkdocs-audience-toggle](https://github.com/lukasherman/mkdocs-audience-toggle) plugin (configured under `plugins:` in `mkdocs.yml`), that lets a reader hide everything beyond a first-pass beginner curriculum. Content is opted into hiding by marking it `data-fcm-hide="essentials"`: +A two-option switch, provided by the [mkdocs-audience-toggle](#mkdocs-audience-toggle) plugin (configured under `plugins:` in `mkdocs.yml`), that lets a reader hide everything beyond a first-pass beginner curriculum. Content is opted into hiding by marking it `data-fcm-hide="essentials"`: - On a `##`/`###` heading inside a content page (e.g. functions.md's `## Decorators`), it hides that heading plus every sibling up to the next heading of the same or higher level, and removes the matching entry from the `toc.integrate` sidebar — so there's no dead nav link to something that's hidden. - On a homepage card-grid row, it hides just that row. A whole homepage card hides too, once its first paragraph (the only one attr_list can attach the marker to) carries the marker — extra.css has a small `:has()` rule that extends that into hiding the entire `
  • `, since the plugin itself only hides the exact element marked. @@ -157,6 +157,31 @@ plugins: I extracted it because it fills a real, previously-requested gap — someone asked for exactly this in a [Material for MkDocs discussion](https://github.com/squidfunk/mkdocs-material/discussions/4765) and the maintainer's answer was horizontal scroll, not an expanded layout — and nothing on PyPI already does it (checked against the existing nav/dropdown/sidebar plugins first). It reads a site's `nav:` tree directly at runtime, so it needs no plugin-specific configuration for the common case, and falls back to Material's own theme variables for styling so it looks reasonable on any palette out of the box. This site is its first real consumer — see `mkdocs.yml`'s `plugins:` list and `extra.css`'s `--md-nested-tabs-*` overrides for how it's wired in here. +### [mkdocs-audience-toggle](https://pypi.org/project/mkdocs-audience-toggle/) + +A header toggle that switches between content modes, such as Essentials and Advanced, and hides any content marked for the modes it shouldn't appear in. It started as this site's own Essentials/Advanced JavaScript, and I rewrote it as a published plugin that supports any number of modes, each with its own label and optional icon. + +```bash +pip install mkdocs-audience-toggle +``` + +```yaml +plugins: + - audience_toggle: + modes: + - name: essentials + label: Essentials + - name: advanced + label: Advanced + default: true +``` + +```markdown +## Decorators {: data-fcm-hide="essentials" } +``` + +Material has no built-in way to tailor a page to different readers. The plugin hides a marked heading together with its whole section and its table of contents entry. It switches to the nearest mode that shows the content when a link points to something hidden. It also collapses to icons or moves to its own row on narrow screens. It includes its own Playwright and axe-core tests. See the [plugin's README](https://github.com/luka-sherman/mkdocs-audience-toggle) for all options. This site's setup is under `audience_toggle` in `mkdocs.yml`, with color overrides in `extra.css`'s `#fcm-toggle` rule. + ## Theme ### Custom CSS