The website build — searchable pages from the same Markdown¶
The whole repo publishes as a searchable website at https://masiarek.github.io/star-voting-library/ — every teaching page, glossary entry, and generated per-election page, with instant full-text search (the search box is the reason the site exists; GitHub's own rendering never gives readers one).
There is no separate docs source. The site is built from the repo root itself (mkdocs.yml + the mkdocs-same-dir plugin), so the same Markdown that GitHub renders is what the site serves — nothing is copied, nothing can drift. Non-Markdown files (.yaml sources, _tabulated .txt mirrors, images) are carried through unchanged, so "run this file" links keep working.
How it deploys¶
.github/workflows/docs.yml builds the site on every push to master and deploys it to GitHub Pages. One-time setup (repo admin): Settings → Pages → Build and deployment → Source: GitHub Actions**. After that it's fully automatic.
Local preview¶
uv run --group docs mkdocs serve
(or ... mkdocs build, which writes the static site into site/ — gitignored, never commit it). The docs toolchain is pinned in pyproject.toml's docs dependency group and resolved by uv.lock, so the preview and CI run the exact same versions. Note: mkdocs-same-dir and mkdocs-redirects are deliberately capped at the last releases that don't pull in the MkDocs-impersonating properdocs package — investigate before raising those pins.
The conventions that make it work¶
- Every content folder has a
README.md(the existing house rule). MkDocs turns each one into that folder'sindex.html, which is what makes the repo's folder-style links (../center_squeeze_bv2137/) resolve on the website too. - Plain
.htmlURLs (use_directory_urls: false). The repo's cross-links were authored for GitHub's file-relative rendering; pretty directory URLs would shift every page one level deeper and 404 the folder-style links. Don't flip this back without fixing hundreds of links. - The homepage opens on the library, not on the pitch (changed 2026-08-04). The big "Vote your heart. No more spoilers." headline, the CTA buttons and a four-stop "New to STAR?" path were all above the fold; a visitor met the sales copy before learning what the site was. The CTA buttons went first (they duplicated the path's own stops), the tagline and the path moved into their own section further down, and on 2026-08-04 that section was removed outright — the ballot image is the one graphic that stayed up top, and routing readers is Start Here's job. The old grab-bag "Learn more" list — eight loosely-related links, mostly
07_Conceptspages — was replaced by "Browse the library" at the bottom: the same ten sections as the left sidebar, in the same order, one line each. - Keep any future homepage markup dual-renderable. The retired path row was a
<div class="star-path" markdown="1">inreadme.md— a card grid on the site viasite_extra.css, and on GitHub, where the class is ignored, a plain stacked list. That degradation was deliberate:readme.mdis one source with two front doors, so anything added to it has to read well in both. (Both the div and its CSS block are gone with the section.) - Hidden from the sidebar, still built (
not_in_navinmkdocs.yml):CLAUDE.md,CONTRIBUTING.md,THIRD_PARTY_LICENSES.md, andreadme.md(its content is already the homepage). Links to them keep working; they just no longer appear in the left nav ahead of the teaching content. - The generated case pages are hidden from the sidebar too (
*_pages/, added 2026-08-02). There are 456 of them against ~650 hand-written pages, so in the nav they were the majority of the tree — and they are the one part of the library nobody navigates by scrolling: you reach a case from its folder README (all 82 are index-complete, enforced bycheck_repo_hygiene.py) or fromCATALOG.md, both of which say what a case teaches where the sidebar could only show its filename. Same reasoning that already keeps them out of the search index. Measured effect: a case page went from 65 nav links to 16 and shrank ~41% (52 KB → 31 KB), and the whole built site went 132 MB → 117 MB — the nav is emitted into every page, so trimming it pays on all ~1,770 of them. Cost: MkDocs derives the footer's prev/next arrows from the nav, so case pages lose them;build_yaml_pages.pyalready ends every page with a "More cases in this set" list of every sibling plus up-links, so sibling traversal moved into the content rather than disappearing. Keep that footer block if you keep this exclusion. - Excluded from the site: dot-dirs,
site/,AGENTS.md(a three-line pointer toCLAUDE.md— kept off the site),_demo_dropbox/staging, and generated ballot printouts — seeexclude_docsinmkdocs.yml. - Sidebar section labels are cased by a build hook (
mkdocs_hooks.py, wired in viahooks:). Section labels are the one title nobody authors — MkDocs derives them from the folder name, turning_into a space and capitalizing the result only when the folder name is all lowercase. So01_STARarrives as "01 STAR" untouched whilerr_tiebreaksbecame "Rr tiebreaks" andstar_vs_rr_divergencebecame "Star vs rr divergence": one acronym, three renderings, down a single sidebar. Renaming the folders would fix it at the source and is what the numbered top-level folders already do — but it moves every URL underneath them, and this repo's URLs are permanent (see the redirect rule below); a build-time label costs noredirect_mapsentry. Page titles are untouched: those come from each file's own# H1. Four rules, applied in this order:unpad()— a leading_becomes a leading space, and.capitalize()then lands on that space and does nothing, so every_mainfolder shipped a blank-looking " main" indented out of line with its siblings. They now read "Main"; the underscore keeps doing its sorting job on disk. (The 2026-08-02 reorganization renamed the five method folders'_mainto02_Examples, so only the two undermethod_comparisons/still rely on this.)SECTIONS— whole-label replacements, for folder names that aren't a cased version of what the reader should see. Currently the five SCREAMING_SNAKE divergence buckets undermethod_comparisons/divergence_review/cases/, which now carry the same human namesbuild_divergence_index.pyalready prints for them. Those are copied from itsBUCKET_TITLErather than imported, because the docs build must not depend on the engine's tooling being importable —test_nav_labels.pyfails if the copy ever drifts, and pins the casing rules with the cases that were actually wrong on the live site.PHRASES/TERMS— whole-word acronyms and proper nouns (rr,irv,star,rcv,stv,bv,condorcet,borda,iia,sntv), so the folders above read "RR tiebreaks", "RR vs IRV plurality", "STAR vs RR divergence".PHRASESruns first and is also where punctuation a folder name can't carry gets put back — "Ranked Robin", "Split Cycle", "Post-it", "Hands-on". Every one of those matches the H1 of the folder's ownREADME.md, which is the naming authority; check there before inventing a label. Adding an entry is a single line — keep it narrow, it matches whole words (STARVote,starvoteand the election-ID folders are safe, sincebv2138never matchesbv, but an ordinary English "star" in a folder name would get shouted at too).capitalize_first()— the capitalize step is all-or-nothing, so one capital anywhere in the folder name suppresses it for the whole label. That is whyreporting_BVandsilly_two_cand_STARused to start lowercase under sentence-cased siblings; only the first character moves, and the deliberate capitals further in survive.
- A hook is deliberately not a plugin: it is one file in the repo and adds nothing to the
docsdependency group, souv.lockand theproperdocspin audit stay untouched.
Search ranking is tuned — don't restore the defaults (2026-08-07)¶
The search box is the reason the site exists, and Material's stock settings rank badly at this repo's size. Three knobs are set explicitly in mkdocs.yml; each was measured by rebuilding the index offline with lunr and recording where the obviously-wanted page landed for 15 real queries. Median rank went 20 → 4. The comments in mkdocs.yml carry the per-knob reasoning; the short version:
separator— the default[\s\-]+splits on whitespace and hyphens only, so punctuation stays welded to the word. A trailing comma — what you get pasting a phrase out of a sentence — makesBallot expressiveness,a different token fromBallot expressiveness, and the page falls from #25 to #264. We split on ordinary punctuation instead, keeping decimals intact (\.(?!\d)). This is Material's own recommended separator minus its camelCase clause, which measured identical here and would have chopped upSTARVote/BetterVoting/RankedRobin.pipeline— addedtrimmer(worth 3 places on "exhausted ballots").stemmeris deliberately absent: it was measured and it is harmful here (median 4 → 10; "exhausted ballots" stops matching its own page). Material appends a trailing wildcard to every query term, so prefix matching already does stemming's job and stemming on top of it mangles the prefixes.fieldsboosts — the default tag boost is 1,000,000× against title's 1,000×, which assumes tags are rare and distinctive. Here they are neither: 9 broad tags over 507 pages. Because of that trailing wildcard, a search forballotprefix-matches theballotstag and lifts all 94 tagged pages above everything else — including pages with the word in their title. Tags now sit at 100, restoring the sane order title > tags > text.
The failure this fixes is subtle and worth recognizing: the page was always indexed and always published — it was simply buried. Two separate reading sessions filed it as "search is broken" before it was traced. If someone reports a page that "isn't in search," check its rank before checking the build; and note that generic query words hurt more than they help, since Range Voting finds the Range page at #1 while Range Voting Voting Method was pushing it to #8.
Known nits (accepted for v1)¶
- Anchor slugs differ from GitHub's for headings with
&/em-dashes (GitHub's#properties--criteriastyle). Those links land at the top of the correct page instead of the exact section — about 30 across the repo. - Search index covers the ~650 hand-written pages. The generated
*_pagesare already excluded from it —build_yaml_pages.pystampssearch: exclude: trueinto every page it writes, because the case dumps were 44% of a 9 MB index and crowded the teaching pages out of the results. They are excluded from the nav for the same reason; the two exclusions should stay in sync. - ~1,200
unrecognized relative linkINFO lines — benign, don't "fix" them. These are folder links (666 written…/dir/, 546 as bare…/dir) rather than file links, so MkDocs declines to rewrite them and leaves them as-is, suggesting…/dir/README.mdinstead. They are not broken. GitHub Pages serves a directory URL from itsindex.html(and 301s the no-slash form to the slash form), and GitHub's own file view resolves a folder link to that folder's README — which is exactly why the repo writes them this way, since links must work in both places. Spot-checked live 2026-07-29: 12 of 12 sampled across both forms returned HTTP 200, andcheck_repo_hygiene.pypasses them too. Rewriting 1,200 links toREADME.mdform would be a very large diff for no user-visible gain, so the house answer is: leave them, and filter these lines out when reading build output (mkdocs build 2>&1 | grep -v "unrecognized relative link"). The messages are INFO, not WARNING — the build stays warning-free. - Raw
.yamlpages show×where the file says×— the browser's fault, not the file's (diagnosed 2026-08-09). GitHub Pages serves.yamlas barecontent-type: text/yamlwith nocharset, so the browser falls back to its locale default (windows-1252) and decodes the two UTF-8 bytes of×(U+00D7 =C3 97) as two separate characters:C3→Ã,97→—. Hencecount_separator: "×"on a file whose bytes are correct — verified byte-for-byte off the live URL.×is the glyph that gets noticed, but the em dash is the bigger exposure. Of the repo's 661.yamlfiles, 24 distinct non-ASCII characters appear, and the two that dominate are—(U+2014, in 590 files) and×(U+00D7, in 475, from the housecount_separator) — so it is mostly prose inscenario_descriptionthat mangles, not the options block. A 3-byte character garbles worse than a 2-byte one:—becomes—,→becomes→,≥becomes≥. Worth knowing that the repo's own bytes are clean — a sweep for stored double-encoding found none, and every accented character in the tree is a real one (Château du Baffy,Matthäus,Solórzano,Muñoz,Phragmén). The mangling is only in the browser, and only on.yaml:.txt,.md→.htmland.jsonall get an explicitcharset=utf-8from Pages and render fine, so the_tabulatedmirror and the generated case page are both clean. Reported upstream 2026-08-09 — GitHub community discussion #204454; tracked with the rest of our outbound reports inupstream_bug_reports.md. The case rests on GitHub's inconsistency with itself:.html,.txt,.json,.jsand.cssall getcharset=utf-8from the same Pages host, and the same.yamlfile gets one fromraw.githubusercontent.com— and github/pages-gem#52 reported this identical failure for.jsback in Feb 2014, which was fixed. Two things to keep straight if you follow it up: the ask is specificallytext/yaml; charset=utf-8and notapplication/yaml(RFC 9512 registers that one with optional parameters "N/A" and encoding considerations "binary", so it carries no charset and would make browsers download the file instead of showing it — worse for us than the mojibake); and the way to check whether it's fixed iscurl -sIon any.yamlon the site, not a glance at the discussion. Not fixable from here in the meantime — GitHub Pages allows no header control (no_headersfile), and the alternatives are worse than the symptom: a UTF-8 BOM on 474 files would ride into every_tabulatedecho, and renaming the extension would move permanent URLs. The house answer is the linking convention that already exists — lead with the generated.mdpage, demote the raw.yamlto the "run this file" link — plus this note, so the next person who sees×doesn't go looking for a mojibake'd source file. - The build is warning-free as of 2026-07-29. It used to emit 5 warnings for
img/REPLACE_*.png— uncaptured screenshot placeholders on the Ranked Robin case pages — which rendered as broken images on the site; those shots are now captured (05_Ranked_Robin/*/img/<bvid>_*.png). IfREPLACE_*warnings reappear, they mean the same thing: a page references a screenshot nobody took yet. Placeholders that are commented out (as onteaching_runoff_reversal.mdand the twoSTAR_reportingpages) don't warn and don't break the site — they're inert until someone captures the image.
The rename (2026-07-16)¶
This repo was renamed from masiarek/YAML to masiarek/star-voting-library on 2026-07-16, before the first Pages deploy — so the site URL was born correct and no stale Pages links exist. All github.com/masiarek/YAML/... deep links (in Google Docs, Slack, the Substack posts) keep working via GitHub's automatic redirects. Two standing rules: never create a new repo named YAML under this account (it would sever those redirects), and if the repo is ever renamed again, update site_url / repo_url / repo_name in mkdocs.yml — the Pages URL moves on rename and old Pages URLs do not redirect.