User story
As a screen-reader user reading readme2demo's README — or reading a tutorial.md that readme2demo generated for someone else's project — I want every image to carry alt text that says what that specific image shows, so that I get the same information sighted readers do instead of hearing "Contributors, Contributors" or the same generic sentence on every tutorial ever generated.
What
Two halves, one PR.
(a) Hand-written docs — fix thin and duplicated alt text.
| Where |
Current alt |
Problem |
README.md:3 |
tests |
Badge state is invisible; announces as a bare word. Suggest CI tests status. |
README.md:185 |
Contributors |
It's a shields.io badge rendering a count, not the people. Suggest Contributor count. |
README.md:188 |
Contributors |
Identical alt to the image three lines above it. Two adjacent images, same announcement, no way to tell them apart. Suggest Contributor avatars. |
examples/README.md:32 |
readme2demo self-demo |
Restates the filename; says nothing about what plays. |
examples/README.md:36 |
toolhive demo |
Same, and "demo" is filler next to a link already labelled demo.gif. |
Already fine, leave alone: README.md:4 (License: MIT), README.md:5 (Python 3.10+), README.md:12 and docs/index.md:12 (both readme2demo running on its own repo — verified demo — genuinely descriptive).
(b) Generated artifacts — the product bug.
src/readme2demo/templates/tutorial.md.j2:46 hardcodes one static string:

It is not empty, but it is identical in every tutorial readme2demo has ever produced, for every repo. examples/toolhive/tutorial.md:121 is byte-for-byte that string — in a tutorial that is entirely about ToolHive.
That's out of step with the rest of the file. Directly above it, the SEO/GEO shape CLAUDE.md tells us to preserve is repo-specific by design: seo_title() (tutorial.py:165-169) renders How to install and run stacklok/toolhive — verified tutorial, and seo_description() (tutorial.py:172-180) derives from the run's own intro. Alt text is the one piece of that shape still generic — and alt text is indexed content, so this is an SEO regression as well as an a11y one.
Fix: interpolate a variable already passed to the template. title is passed at tutorial.py:207; repo_url at tutorial.py:214. Something like:

Pick whichever of title / _repo_name(repo_url) reads better and degrades sanely when repo_url is empty (guide-only runs pass repo_url="" — seo_title already handles that by falling back).
Explicitly out of scope (do not grow the PR):
- Embedding the GIF in
step_by_step.md. It currently embeds no image at all — tutorial.py:625-631 only appends the prose line Watch every step execute in demo.mp4 / demo.gif alongside this file. There is no alt-text bug there; adding an image is a separate product decision.
- Adding
video / VideoObject to howto.jsonld (tutorial.py:248-270 has no video key). Worth a follow-up issue, not this one.
- WCAG 2.2.2 (Pause/Stop/Hide) for the auto-looping hero GIF at
README.md:12. Real concern, much bigger fix (poster frame + click-to-play), file separately.
- Any change to
distill.py, normalize.py, engines/, or commands.sh / demo.tape generation.
Why
New category for this board: accessibility. Nothing under the 93 open issues covers it (see search results in the notes), so this is the first one — hence the proposed accessibility label.
Half (b) is a real user-facing defect, not polish: readme2demo's entire pitch is that it publishes good docs on your behalf. Every tutorial it has generated carries the same context-free image description. The tool that fixes other people's docs should not be the one shipping the generic alt text.
Half (a) is a genuine incident-shaped bug in the small: README.md:185 and README.md:188 are two different images, three lines apart, with byte-identical alt text — a screen reader announces "Contributors" twice with nothing distinguishing them. That pair arrived with the contributors section touched in #180.
Pointers
Verified by reading each file at the commit on main:
README.md:3-5 — three badges: alt tests, License: MIT, Python 3.10+.
README.md:12 — hero, alt readme2demo running on its own repo — verified demo. Good already.
README.md:185 — [](...)
README.md:188 — <img src="https://contrib.rocks/image?repo=alphacrack/readme2demo" alt="Contributors" /> — same alt as :185.
docs/index.md:12 — alt readme2demo running on its own repo — verified demo. Good already.
examples/README.md:32,36 — alt readme2demo self-demo / toolhive demo.
src/readme2demo/templates/tutorial.md.j2:43-49 — the {% if has_video %} Demo block containing the hardcoded alt string.
src/readme2demo/tutorial.py:205 — has_video = (run_dir / "demo.gif").exists() or (run_dir / "demo.mp4").exists() — a filesystem check, not LLM output.
src/readme2demo/tutorial.py:206-228 — template.render(...); title and repo_url are already in scope.
src/readme2demo/tutorial.py:165-180 — seo_title / seo_description, the repo-specific SEO shape the alt text should match.
examples/toolhive/tutorial.md:121 — the committed proof that the generic string ships as-is.
tests/test_tutorial.py:409 test_tutorial_md_front_matter_and_provenance — renders the full template and asserts the front matter and provenance footer, but never creates demo.gif/demo.mp4 in tmp_path, so has_video is False and the Demo block is currently not covered by any test.
Grounding implication: none. Alt text is prose/markup. This changes no command that reaches tutorial.md, step_by_step.md, commands.sh, or demo.tape; it touches only the {% if has_video %} block, gated by a filesystem check rather than model output, and leaves enforce_commands untouched. Calling it out so a reviewer doesn't have to re-derive it.
Acceptance criteria
Notes for contributors
Honest prerequisites: pure Python + Markdown + one Jinja line. No Docker, no API key, no network. You will not run the pipeline.
pip install -e ".[dev]"
python -m pytest tests/ -q # ~1.5s, no docker/network/API
ruff check src/ tests/
The only slightly fiddly part is the test: test_tutorial_md_front_matter_and_provenance at tests/test_tutorial.py:409 is your template, but note it does not create demo.gif, so the block you're changing never renders there. Your new test needs (tmp_path / "demo.gif").write_bytes(b"") (or .touch()) before run_tutorial(...) to flip has_video to True. Everything else in that test — the monkeypatch.setattr(llm_mod, "complete_json", ...) stub that avoids a real LLM call — you can copy verbatim.
Please don't regenerate anything under examples/ — those are committed run outputs kept as proof, and hand-editing them breaks the provenance story.
User story
What
Two halves, one PR.
(a) Hand-written docs — fix thin and duplicated alt text.
README.md:3testsCI tests status.README.md:185ContributorsContributor count.README.md:188ContributorsContributor avatars.examples/README.md:32readme2demo self-demoexamples/README.md:36toolhive demoAlready fine, leave alone:
README.md:4(License: MIT),README.md:5(Python 3.10+),README.md:12anddocs/index.md:12(bothreadme2demo running on its own repo — verified demo— genuinely descriptive).(b) Generated artifacts — the product bug.
src/readme2demo/templates/tutorial.md.j2:46hardcodes one static string:It is not empty, but it is identical in every tutorial readme2demo has ever produced, for every repo.
examples/toolhive/tutorial.md:121is byte-for-byte that string — in a tutorial that is entirely about ToolHive.That's out of step with the rest of the file. Directly above it, the SEO/GEO shape CLAUDE.md tells us to preserve is repo-specific by design:
seo_title()(tutorial.py:165-169) rendersHow to install and run stacklok/toolhive — verified tutorial, andseo_description()(tutorial.py:172-180) derives from the run's own intro. Alt text is the one piece of that shape still generic — and alt text is indexed content, so this is an SEO regression as well as an a11y one.Fix: interpolate a variable already passed to the template.
titleis passed attutorial.py:207;repo_urlattutorial.py:214. Something like:Pick whichever of
title/_repo_name(repo_url)reads better and degrades sanely whenrepo_urlis empty (guide-only runs passrepo_url=""—seo_titlealready handles that by falling back).Explicitly out of scope (do not grow the PR):
step_by_step.md. It currently embeds no image at all —tutorial.py:625-631only appends the prose lineWatch every step execute in demo.mp4 / demo.gif alongside this file.There is no alt-text bug there; adding an image is a separate product decision.video/VideoObjecttohowto.jsonld(tutorial.py:248-270has no video key). Worth a follow-up issue, not this one.README.md:12. Real concern, much bigger fix (poster frame + click-to-play), file separately.distill.py,normalize.py,engines/, orcommands.sh/demo.tapegeneration.Why
New category for this board: accessibility. Nothing under the 93 open issues covers it (see search results in the notes), so this is the first one — hence the proposed
accessibilitylabel.Half (b) is a real user-facing defect, not polish: readme2demo's entire pitch is that it publishes good docs on your behalf. Every tutorial it has generated carries the same context-free image description. The tool that fixes other people's docs should not be the one shipping the generic alt text.
Half (a) is a genuine incident-shaped bug in the small:
README.md:185andREADME.md:188are two different images, three lines apart, with byte-identical alt text — a screen reader announces "Contributors" twice with nothing distinguishing them. That pair arrived with the contributors section touched in #180.Pointers
Verified by reading each file at the commit on
main:README.md:3-5— three badges: alttests,License: MIT,Python 3.10+.README.md:12— hero, altreadme2demo running on its own repo — verified demo. Good already.README.md:185—[](...)README.md:188—<img src="https://contrib.rocks/image?repo=alphacrack/readme2demo" alt="Contributors" />— same alt as :185.docs/index.md:12— altreadme2demo running on its own repo — verified demo. Good already.examples/README.md:32,36— altreadme2demo self-demo/toolhive demo.src/readme2demo/templates/tutorial.md.j2:43-49— the{% if has_video %}Demo block containing the hardcoded alt string.src/readme2demo/tutorial.py:205—has_video = (run_dir / "demo.gif").exists() or (run_dir / "demo.mp4").exists()— a filesystem check, not LLM output.src/readme2demo/tutorial.py:206-228—template.render(...);titleandrepo_urlare already in scope.src/readme2demo/tutorial.py:165-180—seo_title/seo_description, the repo-specific SEO shape the alt text should match.examples/toolhive/tutorial.md:121— the committed proof that the generic string ships as-is.tests/test_tutorial.py:409test_tutorial_md_front_matter_and_provenance— renders the full template and asserts the front matter and provenance footer, but never createsdemo.gif/demo.mp4intmp_path, sohas_videoisFalseand the Demo block is currently not covered by any test.Grounding implication: none. Alt text is prose/markup. This changes no command that reaches
tutorial.md,step_by_step.md,commands.sh, ordemo.tape; it touches only the{% if has_video %}block, gated by a filesystem check rather than model output, and leavesenforce_commandsuntouched. Calling it out so a reviewer doesn't have to re-derive it.Acceptance criteria
README.md:3alt describes the badge as a CI status, not the bare wordtests.README.md:185andREADME.md:188carry distinct, descriptive alt text (count badge vs. avatar grid).examples/README.md:32,36alt text describes what the demo shows, not the filename.templates/tutorial.md.j2:46interpolates a per-run value so two tutorials for different repos produce different alt text.repo_url="") still render sensible alt text — no empty segment, no stray dash.tests/test_tutorial.pycreatesdemo.gifintmp_pathbefore callingrun_tutorial, then asserts the renderedtutorial.mdalt text contains the repo/tutorial identifier. Carry a"""Regression: ..."""docstring per the house convention.examples/toolhive/tutorial.mdis left as-is (committed run output — regenerating it is not part of this PR).python -m pytest tests/ -qgreen;ruff check src/ tests/clean.Notes for contributors
Honest prerequisites: pure Python + Markdown + one Jinja line. No Docker, no API key, no network. You will not run the pipeline.
The only slightly fiddly part is the test:
test_tutorial_md_front_matter_and_provenanceattests/test_tutorial.py:409is your template, but note it does not createdemo.gif, so the block you're changing never renders there. Your new test needs(tmp_path / "demo.gif").write_bytes(b"")(or.touch()) beforerun_tutorial(...)to fliphas_videotoTrue. Everything else in that test — themonkeypatch.setattr(llm_mod, "complete_json", ...)stub that avoids a real LLM call — you can copy verbatim.Please don't regenerate anything under
examples/— those are committed run outputs kept as proof, and hand-editing them breaks the provenance story.