Skip to content

FEAT: Collapse solutions by default under solution_follow_exercise - #86

Open
mmcky wants to merge 6 commits into
mainfrom
feature/solution-collapsed
Open

FEAT: Collapse solutions by default under solution_follow_exercise#86
mmcky wants to merge 6 commits into
mainfrom
feature/solution-collapsed

Conversation

@mmcky

@mmcky mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Member

Adds a solution_collapsed configuration option that renders solutions folded by default, and makes collapsing part of what the solution_follow_exercise exercise style means.

Closes #85. Addresses the first of the two options proposed in #84; the appendix option is tracked separately in #87.

Motivation

Reader feedback on book-dp1, which uses exercise_style = "solution_follow_exercise":

Currently solutions to exercises sit immediately after exercises, making it too tempting to look at the solution. Would it be possible to have them folded by default and open in a drop-down, similar to the lectures?

The layout that is best for authoring (solution next to exercise) is not the one that is best for reading. This lets authors keep writing solutions inline and get a folded presentation at publish time, without touching the source.

⚠️ Behaviour change

Projects using exercise_style = "solution_follow_exercise" will now render solutions collapsed. That style is precisely the layout the feedback is about, so the fold is treated as part of the style rather than as a separate switch authors have to discover.

The opt-out is one line:

exercise_style = "solution_follow_exercise"
solution_collapsed = False

Nothing changes for projects that do not set exercise_style, and no source files need editing either way. Documented as a Changed ⚠️ entry in the CHANGELOG and in new v1.3.0 release notes, both leading with how to restore the previous rendering.

The tri-state, and why it is necessary

solution_collapsed Behaviour
unset (default) Follow the exercise style — collapsed under solution_follow_exercise, expanded otherwise
True Always collapse
False Never collapse

The None default is load-bearing rather than stylistic. Sphinx cannot distinguish "unset" from "explicitly set to False" through the public config API, so with a plain False default there would be no way to switch the style's implied collapsing back off — the opt-out above would be impossible to express.

Resolution lives in utils.solutions_are_collapsed(config) rather than being written back into the config at config-inited. That keeps the raw tri-state readable (so the warning can tell whether collapsing was asked for or implied), avoids mutating config other extensions may read, and needs no rebuild-trigger handling of its own, since exercise_style is already registered "env".

How it works

Collapsing adds the dropdown class, which sphinx-togglebutton already consumes — its default togglebutton_selector is .toggle, .admonition.dropdown, and the solution renders as <div class="solution dropdown admonition" id="...">.

The injection point is a single block in SolutionDirective.run(). SolutionStartDirective subclasses it and ends in return super().run(), so gated {solution-start} / {solution-end} pairs are covered by the same code path. The class survives MergeGatedSolutions, which rebuilds the class list on merge, because that rebuild is a per-element substring replace and dropdown contains no solution-start substring. That is the code path the #82 class-list bug lived in, so it has a dedicated test.

Behaviour matrix

Case Result
Default, no exercise_style Unchanged — class="solution admonition"
exercise_style = "solution_follow_exercise" Collapsed
...plus solution_collapsed = False Not collapsed — explicit choice wins
solution_collapsed = True Collapsed, whatever the style
Gated {solution-start} / {solution-end} Collapsed too
Directive has :class: my-class Preserved alongside dropdown
Directive already has :class: dropdown Not duplicated
Directive has :class: toggle-shown Stays expanded while the rest collapses
LaTeX/PDF build Unaffected — renders inline as before
Collapsing in effect, no sphinx-togglebutton Warning, rather than silently rendering expanded
suppress_warnings = ["exercise.solution_collapsed"] Warning silenced, class still applied

LaTeX is provably unaffected: the LaTeXTranslator branch of visit_solution_node emits only \phantomsection\label{...} plus a constant \begin{sphinxadmonition}{note}, and never reads node["classes"].

The warning adapts to how collapsing was enabled

An author who opted in explicitly is told to add the extension. An author who only set exercise_style, and never asked for collapsing, is also told how to switch it off:

exercise_style='solution_follow_exercise' collapses solutions by default, but 'sphinx_togglebutton' is not loaded, so they will render expanded. Add 'sphinx_togglebutton' to your extensions, or set solution_collapsed = False to keep solutions expanded.

Jupyter Book ships sphinx_togglebutton in its default extension list, so Jupyter Book projects will not see this. The check runs at builder-inited, is gated on app.builder.format == "html" so it adds no noise to LaTeX builds, and is typed exercise.solution_collapsed so it can be suppressed rather than locking projects out of -W builds.

sphinx-design deliberately does not count as a provider. Its dropdown is a directive emitting .sd-dropdown, and its stylesheet has no rule for a bare dropdown class. Since Jupyter Book loads sphinx-design by default, treating it as a provider would suppress the warning for exactly the projects that need it.

One caveat is documented rather than fixed: collapsing hides content by zeroing its height rather than removing it, so outputs that measure themselves at load time (plotly, bokeh, ipywidgets, altair) render at zero size inside a collapsed solution. Static images including matplotlib are fine, and :class: toggle-shown is the per-directive escape hatch.

Tests

Eighteen tests in tests/test_solution_collapsed.py covering each row of the matrix, both warning texts, the gated path from both RST and MyST source, and the tri-state interaction with exercise_style. Two fixtures in the mybook test root exercise the explicit-:class: dropdown de-duplication case and the gated path; both are appended at the end of the toctree and the gated one's exercise is :nonumber:, so neither can shift the global exercise numbers baked into the committed regression fixtures.

sphinx_togglebutton is added to the testing extra so the no-warning path can be covered; that test skips when it is unavailable.

Verified locally:

Docs

A Collapse All Solutions section in docs/source/syntax.md with the tri-state value table, both conf.py and Jupyter Book _config.yml forms, the LaTeX fallback note, the interactive-output caveat and the :class: toggle-shown escape hatch. The Solution Title Styling section now lists collapsing among what the style does, with the opt-out. New docs/source/releases/v1.3.0.md, added to the release-notes toctree.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 24, 2026 02:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@mmcky

mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Note on the red CI: pre-existing, unrelated to this branch

The tests (*, 6) and some (*, 7) jobs fail with:

sphinx.errors.ThemeError: ... ImportError: cannot import name '_get_toctree_ancestors' from 'sphinx.environment.adapters.toctree'

raised from pydata_sphinx_theme/toctree.py. This is dependency drift, not a regression from this branch:

  • Every failing test is in tests/test_gateddirective.py, which this branch does not modify. All tests in tests/test_solution_collapsed.py pass — the run log shows tests/test_solution_collapsed.py .......... even inside the jobs that fail overall.
  • The trigger is the test root: tests/books/test-gateddirective/conf.py sets html_theme = "sphinx_book_theme", which pulls in pydata-sphinx-theme. A recent pydata-sphinx-theme calls _get_toctree_ancestors, which does not exist in Sphinx 6/7. The other test roots use alabaster and are unaffected.
  • Reproduced locally: with pydata-sphinx-theme==0.15.4 the stock test_gateddirective.py gives 12 passed; after upgrading the theme the same unmodified tests give 6 failed, 6 passed.
  • Main's last CI run was 2025-11-17, before the theme released the change, so main has not been exercised against current dependencies.

Because of this, the gated test on this branch was moved off the gateddirective root onto a new self-contained fixture in the mybook root (alabaster), which is unaffected and much lighter — the gateddirective root executes matplotlib code cells. The fixture was checked to genuinely exercise the merge path rather than fall back: the rendered solution carries class="solution dropdown admonition", the title resolves to a hyperlinked "Solution to ...", the intervening content is merged into the admonition, and no solution-end marker survives.

The underlying breakage is left untouched here — it needs either a pydata-sphinx-theme/sphinx pin or a theme change in that test root, and that is a separate call for the maintainer.

Verified locally on this branch: 127 tests pass on Sphinx 8.2.3, and tests/test_solution_collapsed.py passes on Sphinx 6.2.1, 7.4.7 and 8.2.3.

@mmcky

mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

The CI breakage described above now has a fix in #88, which is green across all 13 checks. Once that merges I'll rebase this branch onto it, at which point this PR's CI should go fully green too.

The pre-existing bugs noted in #85 are now filed as #89 (gated directives crash when the start directive is suppressed), #90 (gated merge mangles :class: values containing -start) and #91 (cross-document {ref} to a solution renders truncated link text). None are touched by this PR.

@mmcky mmcky changed the title FEAT: Add solution_collapsed option to fold solutions by default FEAT: Collapse solutions by default under solution_follow_exercise Aug 24, 2026
@mmcky

mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Verified the rebase will be clean: merging #88 into this branch locally and running the full suite under the exact failing CI combination (Python 3.13, Sphinx 6.2.1, pydata-sphinx-theme 0.20.0) gives 132 passed, 1 skipped — the skip being the sphinx-togglebutton test in an env that lacks it.

The only failures on this PR's current CI are the four test_gateddirective.py tests that #88 fixes; test_solution_collapsed.py shows ................. (all 17) and test_exercise_style.py / test_order_validation.py both pass in the same job.

mmcky and others added 6 commits August 24, 2026 13:47
Adds a `solution_collapsed` boolean configuration option that renders every
solution directive folded by default, so readers opt in to seeing the answer.
This addresses reader feedback on books that use
`exercise_style = "solution_follow_exercise"`, where an inline solution sitting
directly under its exercise is hard to look away from.

The option adds the `dropdown` class to solution nodes, which is the class
sphinx-togglebutton already consumes (its default selector is
`.toggle, .admonition.dropdown`). It is equivalent to writing
`:class: dropdown` on every solution, and is applied in
`SolutionDirective.run()`, which `SolutionStartDirective` inherits, so gated
`solution-start` / `solution-end` pairs are covered by the same code path.

Details:

- Registered with the "env" rebuild trigger, matching `hide_solutions` and
  `exercise_style`, because the class is injected at read time and baked into
  the pickled doctree.
- Directive-level `:class:` values are preserved, and an explicit
  `:class: dropdown` is not duplicated.
- `:class: toggle-shown` keeps an individual solution expanded while the rest
  of the project is collapsed.
- Non-HTML builders are unaffected: the LaTeX branch of `visit_solution_node`
  never reads `node["classes"]`, so solutions render inline as before.
- An HTML build with the option enabled but no extension providing the
  `dropdown` class emits a warning, rather than silently rendering every
  solution expanded.
- Default is `False`, so existing projects and all committed regression
  fixtures are unchanged.

Adds `sphinx_togglebutton` to the `testing` extra so the no-warning path can
be covered; the test skips when it is unavailable.

Refs #84

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `gateddirective` test root sets `html_theme = "sphinx_book_theme"`, which
is currently broken on Sphinx 6 and 7 in CI: a newer pydata-sphinx-theme calls
`_get_toctree_ancestors`, which does not exist in those Sphinx versions, so
every test using that root fails with a ThemeError. Five pre-existing
`test_gateddirective.py` tests fail the same way; that breakage is unrelated to
this branch and is left alone here.

Adds a self-contained gated fixture to the `mybook` test root, which uses
alabaster and is unaffected, and points the gated collapsed test at it. The new
fixture is also much lighter than the `gateddirective` one, which executes
matplotlib code cells.

Verified the fixture genuinely exercises the merge path rather than falling
back: the rendered solution carries `class="solution dropdown admonition"`, the
title resolves to "Solution to Exercise 7 (A gated example)", the intervening
content is merged into the admonition, and no solution-end marker survives.

Also adds a default-off counterpart so the gated path is covered in both
directions.

Refs #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three fixes from an adversarial review of the initial implementation.

1. Drop `sphinx_design` from `TOGGLE_EXTENSIONS`. It does not provide the
   `dropdown` class: its dropdown is a directive emitting `.sd-dropdown`, and
   its stylesheet ships no rule for a bare `dropdown` class (verified — the
   compiled CSS contains only `.sd-dropdown`). Listing it suppressed the
   warning in exactly the case the warning exists for, and because Jupyter Book
   loads sphinx-design by default, a Jupyter Book project without
   sphinx-togglebutton would have silently rendered every solution expanded
   with no diagnostic.

2. Give the warning `type="exercise"` / `subtype="solution_collapsed"`. It was
   untyped, so it could not be suppressed and made `-W` builds fail outright
   for projects that supply their own `.admonition.dropdown` CSS instead of
   loading the extension — a setup the check's own docstring anticipates.
   `suppress_warnings = ["exercise.solution_collapsed"]` now silences it while
   still applying the class.

3. Document that collapsing hides content by zeroing its height rather than
   removing it, so outputs that measure themselves at load time (plotly, bokeh,
   ipywidgets, altair) render at zero size inside a collapsed solution. This
   matters for exactly the executable-book audience the option targets. Static
   images including matplotlib are unaffected; `:class: toggle-shown` is the
   per-directive escape hatch.

Also hardens the test fixtures: the two new toctree entries move to the end of
`test-mybook/index.rst` and the gated fixture's exercise becomes `:nonumber:`,
so neither can shift the global exercise numbers baked into ~40 committed
regression fixtures. A comment records the constraint for future entries.

Adds a test that the warning is suppressible while the class is still applied.

Verified: 127 tests pass; the new tests pass on Sphinx 6.2.1, 7.4.7 and 8.2.3;
docs build adds no new warnings.

Refs #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These six .sphinx9 baselines were generated accidentally while reproducing an
unrelated CI failure in a scratch environment that had been upgraded past the
supported Sphinx range. pytest-regressions writes a new baseline when it finds
no file for the running version's suffix, so they were created as a side effect
rather than deliberately.

They do not belong in the tree: the project supports sphinx>=6.1,<9, so there
is no CI job that would ever read them, and they record output produced with a
theme/Sphinx combination the suite does not target.

Refs #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Folds collapsing into what the `solution_follow_exercise` exercise style
means, rather than leaving it as an unrelated switch authors must find.

That style places each solution directly beneath its exercise, which is exactly
the layout the reader feedback in #84 was about: an adjacent, fully visible
solution is too tempting to look at. Treating the fold as part of the style
means the option authors already reach for does the right thing by default.

`solution_collapsed` becomes tri-state to make this expressible:

  unset (None)  follow exercise_style - collapsed under solution_follow_exercise
  True          always collapse
  False         never collapse

The `None` default is load-bearing, not stylistic. Sphinx cannot distinguish
"unset" from "explicitly set to False" through the public config API, so with
the previous `False` default there would have been no way to switch the style's
implied collapsing back off.

Resolution lives in `utils.solutions_are_collapsed(config)` rather than being
written back into the config at `config-inited`. That keeps the raw tri-state
readable, so the warning can tell whether collapsing was asked for or implied,
avoids mutating config other extensions may read, and needs no rebuild-trigger
handling of its own since `exercise_style` is already registered "env".

The missing-togglebutton warning now adapts. An author who opted in explicitly
is told to add the extension; an author who only set `exercise_style` and never
asked for collapsing is told how to switch it off as well:

  exercise_style='solution_follow_exercise' collapses solutions by default, but
  'sphinx_togglebutton' is not loaded, so they will render expanded. Add
  'sphinx_togglebutton' to your extensions, or set solution_collapsed = False
  to keep solutions expanded.

This is a behaviour change for projects on `solution_follow_exercise`, so it is
documented as one: a "Changed" entry in the CHANGELOG leading with how to
restore the old rendering, and new v1.3.0 release notes opening with the change
and the one-line opt-out. The syntax guide gains a value table for the
tri-state, and the Solution Title Styling section now lists collapsing among
what the style does. Jupyter Book ships sphinx_togglebutton in its default
extension list, so Jupyter Book projects need no change.

Six new tests cover the interaction: the style collapsing by default, explicit
False overriding it, explicit True agreeing with it without duplicating the
class, both warning texts, and silence when the implied collapse is switched
off.

Verified: 133 tests pass; the collapsed and exercise_style suites pass together
on Sphinx 6.2.1, 7.4.7 and 8.2.3; docs build adds no new warnings.

Refs #84, #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gated collapsed tests use the RST `.. solution-start::` form in the
`mybook` test root. This adds the MyST ```{solution-start}``` counterpart in
the `gateddirective` root, so the directive-option parsing both parsers feed
into is exercised from each side.

That root was unusable while its `sphinx_book_theme` setting was breaking CI on
Sphinx 6 and 7, which is why the earlier gated test was written against
`mybook` instead. #88 has since switched it to alabaster, so it is available
again.

The `mybook` fixture stays as the primary gated test: it is self-contained, it
sits alongside the rest of the collapsed suite so confoverrides stay
consistent, and it avoids executing the matplotlib code cells the
`gateddirective` root builds.

Verified: 134 tests pass.

Refs #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmcky
mmcky force-pushed the feature/solution-collapsed branch from f01fd7b to 1a78590 Compare August 24, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add solution_collapsed option to render solutions folded by default

2 participants