Imported from PeterC66/claude-skills (
make-bus-leaflet/SKILL.md). Install upstream withnpx skills add PeterC66/claude-skills --skill make-bus-leaflet. Copyright stays with the author.
Make the two bus-route leaflet images for a town
Names for the parts. C:\u3a St Ives\Using AI\Buses\Documentation\README - Glossary of terms.md is the agreed vocabulary — every element of both sheets, keyed by callout code (I1-I27 internal, E1-E15 external) to two annotated St Ives examples. Use those names in build notes and when reporting or acting on a defect, and if this skill gains a new drawn element, add it to the glossary in the same session.
What this produces
Per town, up to four deliverables:
- Internal map —
Buses within <Town>: a geo-anchored, hand-simplified street schematic. Each route a colour, traced through its real in-town stops out to the town edge (so a pass-through route shows which way it leaves, not a stub); bus-stop ticks; ~25–40 point-of-interest pictograms (supermarkets by name, GPs, pharmacies, library, museum, schools, leisure, parks, town hall, industrial estate); 1–3 key linear features (river / main road / railway / canal — see references/linear-features.md); rotated road-name labels; a right-hand Services panel and a Key. Optional v3 clarity flags (internalBundle,internalTermini— see references/s3-config.md) fan co-running routes into closely-parallel lines and label where each route leaves town; both default off (byte-identical when omitted). - External map —
Buses from <Town> to nearby places: a tube-map of every service that leaves town, drawn straight to its terminus with intermediate towns; town hubs; operator legend. The busway variant additionally supports a right Services description panel, intermediate-place lozenges, two-arm routes (one route number, two branches), a Bus Station↔P&R link, and label de-collision — all config-driven (see references/s3-config.md).- Optional third image —
internal-schematic.svg/.jpg(opt-in viaroutes.jsoninternalSchematic{}, needsinternalRoadstoo): a tube-map-style octolinear version of the internal map (every corridor straightened to 0/45/90° legs, the style of the hand-made St Ives leaflet). Generated byassets/schematize_internal.js, which schematizes the S2 geometry into a workspace and reuses the unmodifiedgen_internal.js— zero effect on the other outputs when the key is absent. See references/schematic-engine.md. - Optional fourth image —
internal-diagram.svg/.jpg(opt-in viaroutes.jsoninternalDiagram{}, needsinternalRoadstoo): a fully abstract tube-map DIAGRAM — topology only (corridors collapsed to long straight octolinear runs, tube-map leg-length normalisation, no road skeleton, curated stops, interchange lozenges, one-way loop arrows), the abstraction level of the hand-drawn St Ives leaflet itself. Generated byassets/diagram_internal.js(same workspace pattern); auto-draft + persistent hand-tuning via junction PINS (diagram-layout.json, authored withassets/diagram_edit.js) anddiagram-overrides.json. See references/diagram-engine.md.
- Optional third image —
- Disagreement audit —
disagreements.docx(+disagreements.jsonsidecar, + adisagreements.pdfconversion for customer-facing delivery — seegen_disagreements.py'sconvert_to_pdf()): a full audit trail of every route checked against both bustimes.org and the operator's own website — rows that agree are marked, rows that disagree are flagged with the resolution taken. The BusMaps.uk portal surfaces only the PDF to customers (the docx stays the internal editable source of truth); its importer auto-detects it from_latest/disagreements.pdf, so runrefresh_latest.jsbefore importing an area map. - Verification report (Stage S6, run on demand) —
verification.docx(+verification.json): the independent / antagonistic reliability pass. A blind red-team agent re-derives the services from scratch and is diffed against our data, alongside structural/geographic sanity checks; every finding is classified HARD (blocks the build) or SOFT (logged). See references/s6-verify.md.
Each map is an editable SVG rendered to a 300 dpi JPG (3508×2480 px), auto-versioned vN.N.
The St Ives, Cambridgeshire reference is in C:\u3a St Ives\Using AI\Buses\Inspiration\ (...Internal.jpg / ...External.jpg, and the editable-vector proof PDF). The St Ives staged baseline is in ...\Buses\Areas\St Ives\; the original distillation build is in ...\Buses\Development Docs\Rebuild\.
The stages, the manifest, and versioning (read this first)
A run is six resumable stages (S1–S5 build the leaflet; S6 verifies it, on demand). Each stage writes into its own dated subfolder under the town folder, and a manifest.json indexes every run (date/time, outputs, and—for images—version). Any process can resume from a completed stage by reading the manifest and pulling that stage's latest outputs. Stages and the files each owns:
| Stage | Folder | Owns (outputs) | Steps |
|---|---|---|---|
| S1 services | S1-services\<ts>\ |
verified-services.json, disagreements.docx + disagreements.json + disagreements.pdf, raw locality_*.html / svc_*.html |
references/s1-services.md |
| S2 geometry | S2-geometry\<ts>\ |
atco2ll.json, atco2name.json (every full-chain stop), osm.json, osm2.json, features_geo.json (linear-feature geometry; river_geo.json kept too), routes_full_atco.json (complete both-direction record), routes_intown_atco.json (the drawn display subset), routes_atco.json (alias of intown), complexity.json (the triage verdict), raw overpass*.txt / river.json / svc_*.html |
references/s2-geometry.md |
| S3 config | S3-config\<ts>\ |
routes.json (data only — since item 3, 2026-08-04, no generator copy; routes.json's engine field records provenance instead), overrides.json (manual layout, optional) |
references/s3-config.md |
| S4 generate | S4-generate\v<ver>_<ts>\ |
internal.svg, external.svg (+ internal-schematic.svg when internalSchematic configured; + corridors_report.json when internalCorridors/corridorPalette configured) |
references/s4-s5-build-and-render.md |
| S5 render | S5-render\v<ver>_<ts>\ |
internal.jpg, external.jpg (+ internal-schematic.jpg) |
references/s4-s5-build-and-render.md |
| S6 verify | S6-verify\<ts>\ |
redteam.json, verification.json + verification.docx |
references/s6-verify.md |
<ts>=YYYY-MM-DD_HHMM. S1–S3 and S6 are dated; S4/S5 also carry the image versionv<ver>and share one version per build (both images together). S6 is dated, not versioned — it audits whatever the manifest currently points at and never bumps an image version.- Versioning is automatic from the manifest: major bump (
2.0) when the underlying data changed (a new S1/S2/S3 run feeds this build); minor bump (1.1) for a visual-only re-generate from the same data; the first build is1.0. Multiple versions coexist — nothing is overwritten. - The folder also holds
manifest.jsonand_archive_pre-staging\(any superseded flat files moved here, never deleted).
The staging helper — stage.js (use it for every stage boundary)
Let SK=C:\u3a St Ives\.claude\skills\make-bus-leaflet\assets. Run node "%SK%\stage.js" <cmd> from anywhere inside the town tree (it finds manifest.json by walking up). Zero dependencies.
init "<townDir>" "<Town>"— createmanifest.json(first thing for a new/migrated town).new <S1..S6> [--bump major|minor]— create the next run subfolder and print its absolute path (capture it). For S4 it computes the version (--bump majorif data changed, else minor; first build = 1.0); S5 inherits S4's current version; S1–S3 and S6 are dated only.pull <S1..S6> [destDir]— copy the latest outputs of a stage intodestDir(default cwd). Use it to assemble a stage's inputs from earlier stages.latest <S1..S6>— print the latest run dir of a stage (for resuming).commit <S1..S6> <runDir> --outputs a,b,c [--based-on "S2=<id>;S3=<id>"] [--note "..."] [--tokens <n>]— record the run in the manifest and mark it latest. For S4/S5 the version is parsed from the dir name.- What the stage COST is recorded too, since 2026-09-01 (OA-105).
newwrites the start onto the stage aspending,committurns it intostartedAtandelapsedMinon the run record and clears it, andstatusprints the latest run's cost — and printsOPEN since …for a stage started and not yet committed. Two things to expect:newnow dirtiesmanifest.json, which it never did before; and a run committed before that date, or a folder assembled by hand, carries no timing at all, which reads as not recorded and never as zero.--tokens <n>records what YOU state — underscores and commas are allowed — and nothing estimates a value when it is absent, because only the session knows what it spent and a guessed cost is indistinguishable from a measured one once it is in the file. Both rollouts get the timing for free; they drivenewandcommit. status/nextver [--bump …]— summarise the manifest / preview the next version.stampver [runDir]— force that run'sroutes.json"version"to match itsv<N.N>_<ts>folder name. Rarely needed by hand (see below).
The version stamp is kept honest automatically (2026-07-25). The version printed on the map is a data field — routes.json "version" — separate from the folder name, and branching a new build from an older routes.json used to ship maps stamped with the previous version (Beaconsfield v1.1 printed v1.0). Now: pull rewrites the field to match the versioned run dir it lands in (reporting when it changes something), and commit S4/S5 refuses a mismatch rather than recording a build whose maps print the wrong number. Existing formatting is preserved, not normalised — towns store "1.1", places store "v1.0", and any suffix survives; only the numeric part is rewritten. Override with --force-version only if a stamp is deliberately different.
Resuming from any stage: read manifest.json (or stage.js status), then start at the stage you want — pull the prior stages' outputs into the new run dir and proceed. E.g. a visual-only re-render: new S4 --bump minor, pull S2, pull S3, run generators, commit; then new S5, pull S4, render, commit.
Map the user's request to the right resume point (don't rebuild from scratch for a small change). Existing town ⇒ start at the earliest stage the change touches and re-run forward through S5:
| User asks for… | Start at | Version effect |
|---|---|---|
| Re-check timetables / refresh the audit | S1 (then S2–S5 only if the verified set changed) | major if services changed |
| New/removed/changed service (data changed) | S1 → S5 | major (--bump major) |
| Re-fetch stops / river / POIs from OSM | S2 → S5 | major |
| Recolour, relabel, move a panel, layout tweak (same data) | S3 → S4 → S5 | minor (--bump minor) |
| Straighten routes / drag labels / hand-adjust layout | S3 (edit overrides.json, e.g. with the drag editor) → S4 → S5 |
minor (--bump minor) |
| Just re-draw / re-render from current settings | S4 → S5 | minor |
| Verify / "is this data right?" / red-team the town | S6 (audits current S1–S5; no rebuild) | none (dated, not versioned) |
| "Is this town too complex?" / re-score after curating | S2 (complexity_score.js on the latest S2 run; no rebuild) |
none |
| "What versions/stages exist?" | — | run stage.js status |
Rule of thumb for the S4 bump: major when you produced a new S1/S2/S3 run this time (data changed), minor when you reused the existing data and only changed the visuals. Always carry forward through S5 so the JPGs match the new SVGs, and commit each stage so the manifest stays the source of truth.
AN S4 MUST CARRY BOTH PROVENANCE STAMPS, AND stage.js commit S4 NOW REFUSES ONE THAT DOES NOT (2026-08-29, OA-161). engine says which generator drew a map; design.sheetVersion is the build N.N · date the footer prints and the number to quote when a sheet looks wrong. Both were written only by rollout.js and rollout_places.js, so a build assembled BY HAND — stage.js new S4, pull, then the generators — lost both silently, and St Neots Town Centre v2.13 shipped that way. The byte gate cannot see it: ci-reference/ is seeded from the same unstamped run, so both sides of the comparison agree exactly and it goes green — the shape seeded from what it polices. The fix is a refusal at the stage boundary every route to an S4 passes through. If it fires, run stage.js stamps <runDir> and then re-run the generators, because by commit time the sheets already carry the old footer. --force-stamps overrides and says out loud what it recorded. Falsified by npm run test:prove-red-stage-stamps, which runs in CI.
Per-town working folder (required — everything lives here)
Each town gets one dedicated folder holding manifest.json and the six stage folders:
C:\u3a St Ives\Using AI\Buses\Areas\<Town>\
(e.g. ...\Buses\Areas\Huntingdon\). First step of any new town: stage.js init. The folder root also holds local-decisions.json — the map's local questions and the defaults we printed against them, kept for the life of the map rather than per run, because an answer has to outlive the build that asked for it (references/local-decisions.md). The generator scripts read every data file from, and write internal.svg / external.svg into, the current working directory — so within S4/S5 you cd into the run folder, pull the inputs, then run them. icons.js (shared code) always loads from the skill's assets/. Never put a town's data in the skill's assets/ — that stays template-only.
Locked design decisions (do not silently change)
-
Build as SVG → render to A4 landscape JPG at 300 dpi. SVG must declare
width="3508" height="2480" viewBox="0 0 297 210"(1 unit = 1 mm) so the rasteriser renders natively and text stays crisp. Render withassets/render.js(usessharp). -
Measure the sheet's quality, don't eyeball it.
gate.jsproves a generator is deterministic; it says nothing about whether the output is any good.node "%SK%\quality_metrics.js" --allreads every shippedci-reference/*.svgand counts the defects that make a sheet look amateur — point labels sitting on route ink, labels colliding, labels over a foreign icon, icons fused into blobs, the same place name printed twice, content buried under the footer plate, text below the 2.4 mm print floor, whitespace piled into one corner — reporting a per-sheet defect total and defects-per-100-labels. Read-only; touches no generator and cannot break a gate. Add--detailto name every offender,--jsonfor the machine-readable form. Thresholds all live in oneT = {}object at the top of the file. It measures text with real Arial advance widths (font_metrics.js, baked; regenerate withfont_metrics_build.js) rather than the generators'length × size × 0.52guess. Baseline as of 2026-08-15: 658 defects across 31 sheets, every sheet failing at least one check — see…\Buses\Development Docs\quality-baseline-scorecard_2026-08-15.mdand the phased plan beside it. -
Internal map = geo-anchored first draft, then optionally schematised: real lat/lon, equirectangular projection, PCA auto-rotation so the town's long axis fills the landscape page. Geography is the starting canvas, not a hard constraint — you may straighten portions of routes toward a London-Underground look via Tier-1 overrides (below), trading geographic accuracy for legibility on the runs you touch. The auto draft stays the baseline; overrides are layered on top and re-applied on every regenerate.
-
Manual layout overrides are Tier-1 (data, not hand-edited SVG). Hand adjustments live in
overrides.json(owned by S3) and are re-applied by the generators on every regenerate, so they survive data refreshes. Author them by dragging in the editor (assets/edit-server.js+override-editor.html) or by hand. Never hand-edit the generated SVG — that fork is lost on the next build. See references/overrides.md. -
The generators are FULLY CONFIG-DRIVEN — no per-town code edits.
gen_internal.jsandgen_external_radial.jscontain no town-specific literals; everything (route draw order, panel order + descriptions, title, road-label ATCO prefix, orientation route, POI filter/tidy rules, the external hub label) is read fromroutes.json. Run the current template generators unchanged for any town — copy them verbatim into the run dir (only needed so the drag editor has a local copy), or run them in place from%SK%with the run dir as CWD;icons.jsself-resolves either way. The new per-townroutes.jsonkeys:routeOrder(internal draw order),panelOrder(Services-panel order),orientationRoute(road-label orientation — the town circular or longest route),atcoPrefix(in-town stop prefix for road labels; defaults toanchorminus trailing digits),internalDesc{route:[title,subtitle]},poi{industrialKeep, excludeName, tidy[], canon[]}, andstamp{notes[], asOf, externalAt, internalAt}(opt-in "coming soon / valid" callout for advance notice of upcoming changes on the printed sheet — see references/s3-config.md). (Omitting any falls back to a sensible default; St Ives + March set them all and reproduce byte-identical — gated.) Never re-introduce a town literal into a generator — add a config key instead. -
POIs auto from OpenStreetMap, user approves the list before drawing.
-
One colour per route, consistent across both maps and across updates, colour-blind-safe. Offer the user the three palettes below; default Tol Bright. Bounded exception for big towns (approved 2026-07-28): the palettes hold ~12 usable hues, so a town drawing more than 12 lines cannot honour this rule — High Wycombe shipped 12 colours for 31 routes, each reused 2–3×, and colour stopped identifying anything. Above 12 lines, colour may identify a corridor rather than a route, with badges carrying route identity. Never quietly exceed 12 and leave colours repeating.
-
Score every town's complexity at the end of S2, before anything is drawn (
complexity_score.js→complexity.json). GREEN builds normally; AMBER applies remedies and continues without pausing; RED stops for a decision (exit 2). On AMBER/RED runcurate_services.js— it turns the ladder into config and prints each rung's predicted score: rung 0 →match_cfg.json skipRoutes+ an "also serving"mapNotesline (the only thing--applywrites); rung 1 →internalCorridors; rung 2 →coreBox; rung 2b →stopThinning; rung 3 →corridorPalette. Nothing past rung 0 is auto-applied — a family is a claim about the real world, and rungs 2/3 are design decisions. Once a rung is inroutes.jsonthe gate scores the town with it applied and stops proposing it. A town can be too complex for one sheet, and "decline the whole-town internal map and ship place-centred leaflets instead" is a legitimate finished product, not a failure. Never split a town geographically to reduce complexity — measured on High Wycombe, every geographic half still contained all 31 routes; split by route family. See references/complexity-triage.md. -
Collect & store the FULL route + stop data as standard (correctness over speed). S2 pulls every route's complete ordered stop list to its terminus, both directions, into
routes_full_atco.json, and backfillsatco2ll/atco2nameso every stop in those chains has a name + lat/lon — not just the in-town ones. The map then draws a derived display subset,routes_intown_atco.json= the town core (locality-prefix stops + named extras) plus a one-stop directional buffer to the town edge, so a pass-through route traces out in the correct direction(s) instead of collapsing to a 1–4-stop stub. The generator readsroutes_intown_atco.json(falls back toroutes_atco.jsonfor older towns). The existinginternalZoomcompression pulls the far edge stops in. See references/s2-geometry.md. -
External = every route leaving town, drawn full to its terminus. ONE layout ships: radial (
gen_external_radial.js) — a hub with straight spokes. A second, busway, drew a guided-busway / P&R corridor for St Ives and was dropped on 2026-09-02: St Ives moved to the radial template on 2026-08-03 (withexternalHubLabelcombining its Bus Station and Park & Ride into one hub, which is what Peter preferred), so it was drawn by no committed sheet for a month — and it spent that last day unrunnable, throwing at load, with every gate in the estate green, because nothing ran it. A town that genuinely needs two separate distant hubs gets a new template written against today's engine, not that one recovered from git. -
On a crowded spider, ask whether two spokes are the same spoke before touching the layout. Ramsey's 303 and 305 had identical
stopsarrays, the same destination and the same journey time, and were drawn as two parallel lines with twoHuntingdonlozenges side by side;external[].routes:["303","305"]merged them and took that sheet from 3 defects to 0. Only then is it a composition problem —design.spokeSpreadopens the fan (even spread in bearing order, clamped to 30° of the true bearing so a spoke never crosses into the opposite compass sector). After any spider composition change, look at the legend, the note block and the stamp: they are pinned in page coordinates, the spokes are not, and Ramsey's Peterborough terminus disappeared under the legend's opaque panel with nothing complaining.quality_metrics.jsnow asks that question of ALL THREE page devices and not only the legend (OA-207, 2026-08-31) — thedesign.howToUsepanel was added later with the identical rect signature, andP.rects.find()returned the legend and stopped, so Wisbech's teal 60 spoke ran under the help panel on the shipped v2.7 withrouteLinesUnderLegendreading 0. Three of the eight towns turned out to carry route ink under that panel; no symbol is buried on any sheet. A finding now names which box:route line behind a page panel: #009988 at 232.2,142.1 [under the panel], via--detail. The generator's own message is still the half that lies — it reports the move and not the residue ("moved 152,-16 mm to 162,142" after leaving 8% of route ink underneath), and fixing that is deferred becausegen_external_radial.jsis inside the engine template hash. See references/design-quality.md. -
Facts come from the structured GTFS dataset first; bustimes catches what BODS omits. S1 derives the service facts (routes/operators/days/termini/variants) from
_gtfs/cambridgeshire.sqliteviagtfs_query.py(deterministic, official BODS data), then runs a bustimes pass for community/demand-responsive/pre-book services that are absent from BODS (e.g. VL14, FACT 33A) and to cross-check. Don't rely on GTFS alone — it would silently drop those. And a service can be in NEITHER feed: Wisbech's route 68 (FACT's Tesco Bus) is in no BODS extract, on no bustimes page and in no OSM relation, and was found only by a blind S6 red team — its whole chain was resolved by hand from the operator's own published timetable, with asource: "operator"provenance and a tracked row-by-row audit (manual-chain-68.json) so the invention can be reviewed rather than re-derived. Before deciding such a service cannot be drawn, download whatever the operator publishes — route 68's timetable is a PNG a text fetch does not show you. See references/s1-services.md. -
Accuracy: cross-check each route against the operator's own timetable; the operator wins; flag conflicts in the disagreement audit (S1). bustimes.org locality pages list recently-expired and non-serving routes — always verify status and whether the route actually enters the town.
-
Fonts ≥ the St Ives example (titles ~11 mm; body ~3 mm; never below ~2.5 mm).
-
Zoom the internal map onto the town (user rule). When routes run far out along rural approach roads, keep the built-up core to scale and pull the outer/rural stops in (they need NOT be to scale) so the town fills the page. Enable via
routes.json"internalZoom":{"corePct":0.55,"comp":0.22}(inner 55 % of stops kept to scale, the rest drawn at 0.22× their extra distance from the anchor); tune per town. Omit it for a compact town. -
Linear features (1–3 per town). The single hardcoded river is generalised: each town draws 1–3 key linear features (river, main road / A-road, railway, canal, …), configured per town in
routes.jsonfeatures[]and locked once chosen. For a new town, auto-identify candidates from OSM, present them as suggestions and ask the user which to include (S2) — then they're fixed for that town (don't re-ask on refreshes). Each feature has a stable key so it can be straightened/nudged viaoverrides.json. See references/linear-features.md. (Omittingfeatures[]entirely falls back to a single auto river feature — legacy behaviour, keeps St Ives/March byte-identical.) -
Never give a route a colour close to the river blue (river stroke is
#9ec9e8). Avoid pale blue / cyan for route lines — they read as the river. (March's route 32 was moved off#4477AAto orange#EE7733.)gen_internal.jsnow enforces this: it prints aPALETTE WARNINGwhen a drawn route's colour is close to a drawn river's in both Lab distance and hue. It caught two towns still doing it on 2026-08-16 — St Ives 9 and Ramsey X31, both#66CCEE— which moved to teal and indigo. Pick a replacement by scoring candidates against every other colour on the sheet plus the water and taking the largest worst-case, then render it: a hue can be numerically distant and still sit next to the line it must be told apart from. -
North arrow on EVERY internal map (Peter, 2026-07-20), and the engine finds it a blank corner itself (Peter, 2026-08-15: "it just needs any blank area"). Drawn by default under
internalRoads; suppress withnorthArrow:false. Don't hand-pin{x,y}— underlabels.engine:"v2"a configured position is used only while it stays clear, and every town's pinned value has been removed, because a stale one silently stops matching the sheet the next time the map is redrawn. The abstract diagram omits it (no meaningful north). -
Railway features: use
style:{rail:"chequer"}(Peter, 2026-08-15) — a black casing with white blocks laid over it as a dash pattern, the symbol most people read as "railway". It replaces the Ordnance-Survey sleeper crossbars (FEATURE_STYLES.railwaydefault, Peter 2026-07-20), which are computed per polyline segment and so bunch, gap and splay wherever the geometry is dense or warped. Choosing chequer also merges the parallel OSM tracks into one line. The tie style remains the default only so existing sheets stay byte-identical — prefer chequer for any new or re-rendered town. See references/linear-features.md. -
Every new or re-rendered town carries the design-quality keys (2026-08-15, extended through 2026-08-16):
"design": { "footerSafe": true, "spreadIcons": true, "iconInk": "charcoal", "panelScale": true, "scaleBar": true, "routeCasing": true, "cornerRadius": 2.0, "badgeFit": true, "hubFit": true }, "labels": { "engine": "v2" }They keep the map out of the footer band, pull fused POI symbols apart, hand every point label to the shared placer
assets/labeller.js(occupancy grid, real Arial widths, scored positions, relaxation, two-line wrapping, leader lines, and anunplaced.jsonreport), give the panel one type scale, caption the scale bar honestly on a fisheyed sheet, case and fillet the route lines, and stop a wide route number overflowing its badge or a hub box.iconInkis Peter's G3 answer — POI symbols in one neutral (red kept for the GP cross) so colour on the sheet means route and nothing else.printSafekeeps every drawn thing 5 mm from the trim — footer, placer, panel columns, terminus lozenges — after a borderless print showed all 31 sheets putting their credit 3 mm from the edge. Two keys are conditional, not universal:panelCorridors(+corridorDesc) only on a town withinternalCorridors(High Wycombe alone), andspokeSpreadon a crowded spider — Beaconsfield, Huntingdon, March and Ramsey carry it; the other four are blocked by legend size, not by the bearing rule. All 8 built towns carry the universal set (628 → 225 measured defects across the 31 sheets, and all 21 town sheets now clear the 5 mm print margin); the 5 places are still on v1 — the portal re-vendor is done and did not change them, because every key defaults off, so places move only when the keys are adopted on them. Most keys default off purely so absent config stays byte-identical — but not all of them, andspreadIconsis the exception that has already cost time: it readsDESIGN.spreadIcons !== false, so it is ON unless a town switches it off, and setting it totrueon a sheet with fused symbols changes nothing. Its separation is also weaker than it looks —iconMinSepdefaults to 3.2 mm for a 4.2 mm symbol, so two icons can satisfy it and still overlap (Ramsey needed 4.6). Check the!== falseidiom in the generator before concluding a key is inert. The rules these keys implement are written down in references/style-guide.md; the keys themselves are in references/design-quality.md, and the two traps above in references/gotchas.md.design.sheetUrl,design.sheetQranddesign.howToUseare ADOPTED on all 13 maps (published 2026-08-19; this paragraph said "no town carries them yet" until 2026-08-21, which is the stale-claim trap the runbook sweep exists for).sheetUrlprints a short address in the footer band,sheetQrdraws a QR code of it at 14mm — the largest that fits the space the URL line already makes, so it costs the frame nothing further — andhowToUseadds a bullet panel to the external sheet, settled at three bullets at 92mm, the only combination that fits all eight towns. They exist becauseMap design © BusMaps.ukis a credit and not a way back to the version that is current now. Costs, measured: the QR raises the footer plate andfooterSafeshrinks the map frame to match, so 15 of 39 sheets lose 1–4 POI/street labels — accepted deliberately, with the reason recorded inquality-ledger.json's ownnotefield. Full account: references/design-quality.md.design.fixedOrientationpins which way up the internal sheet is drawn (2026-08-21). Default absent = PCA auto-rotation, exactly as before and byte-identical. Accepts"north","auto", or any number of degrees. Worth knowing because PCA re-derives the angle from the stop cloud on every build, so a route added or withdrawn next month can swing the whole sheet several degrees — no gate notices, because the sheet is correct either way, but it is obvious to anyone comparing this month's printed copy with last month's. No map is pinned today. To pin one to however it currently looks,node "%SK%\freeze_orientation.js" --town "<Town>"(dry run; add--apply) reads the angle the last build actually used out ofbuild-meta.jsonand writes it down explicitly;--north,--deg <n>and--releaseare the other forms. Numbers are stored unnormalised on purpose — see references/gotchas.md §"Normalising an angle is not free". Key table: references/s3-config.md. -
On a town that has been through the complexity triage, read its config before calling anything on the sheet a defect (Peter, 2026-08-15). High Wycombe is the only one today: its 22 services are bundled into 14 drawn lanes and coloured as 11 corridors, so a shared hue there is rung 3 working, not an exhausted palette, and its labelled town-centre box is rung 2, not a placeholder.
complexity.jsonappliedshows what a town has already had done to it. See references/complexity-triage.md. -
Judge a config or engine change on ALL the towns before committing it —
node "%SK%\preview_design.js" --all --patch '{...}'rebuilds every sheet with the patch applied, measures before/after and lists the labels gained and lost, writing nothing underAreas/. Thenadopt_config.js --applycommits it as a new S3 per town androllout.js --apply --forcerenders it (--forceis required — see references/changing-the-engine.md §2b for why a config rollout silently does nothing without it). Put the JSON in a UTF-8 file and pass--patch-file/--set-filewhenever it contains an en-dash or a middot (nearly every panel string does): PowerShell mangles non-ASCII in argv on the way tonode.exe, silently, andadopt_config.jscommits what it is handed. -
Read the build's stderr, not just its exit code. The generators report the things no gate can see — a corridor family that barely co-runs, a panel row that has outgrown its column, a route wearing the river's colour, a spider spoke pair the spread cannot separate.
PALETTE WARNING route X32 …printed on every March build for two rollouts before anyone read it.node "%SK%\pick_route_colour.js" --town "<Town>" --route <key>answers the colour one: it ranks candidate hues by their worst separation against every other colour on that sheet plus every linear feature it draws in a chromatic colour — roads and railways as well as rivers, since OA-304 — and shows each one's separation from just the routes it is drawn BESIDE. Add--stagewhen the route is not on the shipped sheet yet — the default readsci-reference, which mirrors the last committed S4, so a route being added is exactly the route it cannot see (OA-226). -
Read
unplaced.jsonafter a build. A label the placer could not fit is now reported instead of vanishing silently; if a name that matters is on that list, hand-place it inoverrides.jsonrather than accepting the drop. -
Every exiting route shows "to X" AT the frame edge. Truncated tails are pushed to the boundary with
match_cfg.json reachExtend; a destination shared by 2+ differently-coloured routes on the same road is one BLACK label. A one-stop / express service that shouldn't draw a line goes inmatch_cfg.json skipRoutes. -
Fisheye: one always-on centre focus + optional local
lenses[]. After the first draft, if the map is congested somewhere, ask the user which area(s) to fish-eye (don't guess) and add a lens there. -
The abstract tube-map DIAGRAM is PARKED since 2026-09-10 (buses-data OA-297; the return path is OA-298): no town builds it, its config sits under
parked.internalDiagram, and the portal does not offer it. While it was live it was produced as standard for every town (auto-draft each build viadiagram_internal.js; finish with adiagram_edit.jspin-tuning session — it is never print-ready straight from the solver). -
Refresh
<townDir>\_latest\on every build, and after ANY in-place edit (refresh_latest.js "<townDir>") — copies of the newest internal/external/diagram JPGs + disagreements/verification DOCX + disagreements PDF, so nobody digs through dated stage folders. This is also what the portal importer reads to find the customer-facing disagreements PDF — see S1 above. It also re-runscollect-maps.ps1 -Allat the Buses root as its last step, soCollected_latestscan't drift behind it. Never copy the JPGs by hand and never skip this after hand-patching a file inside an already-committed S5 folder (no version bump) — a skipped or late refresh here is exactly what left High Wycombe Aldi and St Neots Town Centre stale on 2026-08-08. -
Retention: prune superseded runs with
python3 "%SK%\prune_runs.py"(dry-run by default;--applydeletes). Keeps all of S3-config, the newest 3 S1/S2 runs, the newest 2 S4/S5 versions (only the newest run of each — three attempts at v1.0 are one version), all of S6 (changed 2026-08-27: it holds the trackedredteam.json, and the old keep-newest-only rule had nine S6 folders queued for deletion, seven of them holding an answer), and anything in…\Buses\retention-pins.json. Pins are load-bearing: the portal'sFIXTURE_DIRpoints at St IvesS5-render\v6.6, two versions behind the newest, so a plain keep-the-newest rule silently breaks the byte-identical gate. Add a pin whenever anything outside the Buses folder — or a printed edition — starts depending on a dated run. S1–S3 are in git so pruning them is recoverable; S4/S5/S6 are not, and the SyncBack mirror drops them at its next run, so run SyncBack before--apply. Policy:…\Buses\Documentation\README - Retention and pruning.md.
Colour-blind-safe palettes (user picks; assign one colour per route)
- Tol Bright (default): #4477AA blue · #66CCEE cyan · #228833 green · #CCBB44 ochre · #EE6677 red · #AA3377 purple · #BBBBBB grey
- Okabe–Ito: #0072B2 · #56B4E9 · #009E73 · #E69F00 · #D55E00 · #CC79A7 · #555555
- Match the St Ives look: #00A5E0 · #00B6CB · #1A3A8F · #E8740C · #1E7A46 · #E5007D · #8A8A8A Badge text colour: white on dark fills, #111 on light fills (cyan/ochre/grey). River clash: the river is drawn pale blue (
#9ec9e8). If a town has a river through it, don't leave a route on a pale blue/cyan that blends into it — substitute a spare distinct hue (e.g. Tol-vibrant orange#EE7733). The palette is the binding constraint, and it is ~12 hues. "One colour per route" therefore has a hard ceiling: past ~12 drawn lines the palette repeats and colour stops identifying anything (High Wycombe v1.0 drew 31 lines in 12 colours). Two bounded exceptions, both opt-in per town and both from the complexity ladder: internalCorridors— bundle a family of co-running services into ONE line carrying a stack of badges, sharing the lead's colour. Use the same lead asexternal[].routesso both sheets agree. Candidates come fromcurate_services.js; confirm each one and read thecorridors_report.jsonwarning.corridorPalette— colour by CORRIDOR, not by route, for routes that share a corridor but don't co-run closely enough to bundle. This retires the locked "one colour per route" decision for that town; approved 2026-07-28, bounded to towns drawing more than 12 lines, never a default. It doesn't use fewer colours — it makes the sharing mean something, and the generator warns about every hue still shared by unrelated corridors.
A town that colours by corridor also needs internalTitleColor, or the sheet's title inherits the orientation route's shared corridor hue.
See references/s3-config.md — and coreBox / stopThinning beside them. Worked example: High Wycombe v2.1 (2026-07-28) took rungs 0 → 1 → 2 → 2b → 3 and went RED (31 / 320 / 1.21 / 6.18) → GREEN (11 colour groups / 91 stops / 0 / 0) on config alone.
Sources of truth
- Services facts (routes / operators / days / termini / variants) — PRIMARY: the structured GTFS dataset (
_gtfs/cambridgeshire.sqlite, built from BODS open data — the legal source of truth operators must publish to). Query it withassets/gtfs_query.py(see S1). This replaces hand-scraping the bustimes locality page for the facts; it's deterministic and reproduced the entire hand-verified St Ives v4.0 set exactly (operators, all days-of-week, and the 301S/V/X variants). See references/s1-services.md and the dataset's own_gtfs/README.md. - Services & stop order — cross-check + the gaps GTFS misses:
bustimes.org. Locality pagehttps://bustimes.org/localities/<slug>lists services; each service pagehttps://bustimes.org/services/<slug>lists its stops in route order as/stops/<ATCO>links. Still required for: (a) community / demand-responsive / pre-book minibus services, which are NOT in BODS (e.g. St Ives VL14 Villager, March 33A FACT) — always do a bustimes pass to catch and merge these; (b) cross-checking the GTFS facts. - Operator timetables: the operator's own site — the tie-breaker for accuracy & operating days, and the second source for the disagreement audit.
- Moovit — a second reading of BODS, not a third source.
moovitapp.commay be consulted when an operator's own timetable is hard to find (Huntingdon AW1, 2026-09-06), but it ingests the same BODS feed asgtfs_query.py, so agreement with it confirms nothing the feed had not already said, and its generated line pages are unverified — its St Neots 61 page read "once a day at 16:30" against seven journeys Mon–Sat in the feed (buses-data OA-260). The operator wins; never link to Moovit, Google Maps or Transit from a sheet or the portal. See references/s1-services.md step 3. - Coordinates, POIs, river, geography: OpenStreetMap via Overpass. Route-line geometry: GTFS
shapesare road-following but only published by larger operators (Stagecoach: yes; Dews/Stephensons/Whippet: no) — so keep bustimes/OSM as the geometry source and use GTFS shapes only opportunistically where present (hasGtfsShapein the query output flags which routes have them).
Onboarding a NEW town (bootstrap + scaffold)
Before S1, ask whether somebody already maps this town (buses-data OA-315, 2026-09-11). The national directory of local bus maps knows what 76 English transport authorities publish, and the question is asked ONCE when a town is chosen — never on a refresh, which must not re-ask a question already answered. It cannot refuse a build and is not able to: all 20 maps in the estate grade area-only, none is covered at town level, and what the gate enforces is that every map HAS an answer, not what the answer is. Run it from the buses-data root (C:\u3a St Ives\Using AI\Buses), filling in the two quoted values — the authority name copied exactly from the directory's own table, because nothing here guesses which authority a town belongs to:
node "C:/u3a St Ives/Using AI/Buses/BusMapsUK/bus-map-directory/coverage.mjs" --new "<Town>" --lta "<authority>"
It prints what that authority publishes and an entry to paste into existing-coverage.json with reason left blank for you to write. node "C:/u3a St Ives/Using AI/Buses/BusMapsUK/bus-map-directory/coverage.mjs" --check is the gate, and it is red until the new town has an entry. The convention is written up in BusMapsUK/bus-map-directory/README.md in buses-data — deliberately NOT linked from here, because this repository is public and that one is private, so a relative link would climb out of the tree and 404 for everybody but Peter.
From just a town name, the deterministic setup is now scripted (no more hand-finding the slug/prefix/anchor):
python3 "%SK%\bootstrap_town.py" "<Town>" [--region <Region>] [--centre lat,lon]— geocodes the town, derives the ATCO locality prefix + anchor/bus-station from the GTFS stops, pulls the service facts (gtfs_query), assigns a draft Tol-Bright palette, computes draft external spokes with real bearings, suggests candidate linear features from OSM, and writes aroutes.draft.json+bootstrap-report.mdfor approval. Nothing subjective is finalised — confirm services vs bustimes (community/DRT buses are NOT in BODS), lock the palette (river-blue clash!), curate external chains + features.python3 "%SK%\scaffold_town.py" "<Town>" [--centre lat,lon]— one command:stage.js init+new S1, runs the bootstrap into it, writesgtfs-services.json, registers the town intown_prefixes.json, and drops aSCAFFOLD-NEXT.mdchecklist. Stops at the S1 review gate (does not commit S1 or run S2). Then review → S2 geometry → S3 (moveroutes.draft.json→routes.json) → S4/S5 with the unedited generators → S6.- Region-agnostic:
_gtfs/regions.jsonregisters each GTFS region;gtfs_query.py/bootstrap_town.py/scaffold_town.pypick a dataset via--dbor$GTFS_DB($CAMBS_GTFS_DBstill works but warns). No region is the default — with neither flag they fail listing the built regions. To support a town in a region that is not yet built: add aregions.jsonentry, download that BODS region zip, and build it withpython3 gtfs_build.py --zip <zip> --out <db> --keep-prefixes <csv>— no code changes. One region per town:town_prefixes.json's"region"is a single string and nothing unions two datasets, so a town whose services straddle two BODS regions is not supported — and building the neighbouring region does not retro-fit an existing town with it. - Tier-2 unattended draft (process-efficiency-plan item 8, 2026-08-04):
python3 "%SK%\draft_town.py" "<Town>" [--region ...] [--centre lat,lon] [--buses-root ...] [--db ...] [--max-edge-km 2.5]builds a draft of the current-standard map in one unattended command: scaffold → S1 (GTFS-only, every route flagged unverified) → S2 (route chains + stop coords straight from the GTFS trip/stop_times tables, no bustimes scrape; POIs/linear features/road graph from live Overpass, thenmatch_routes.js) → complexity gate → S3 → S4/S5 with the unmodified template generators →refresh_latest.js. It configuresinternalRoads(all 7 built towns use it; it is what gates route badges, "to X" terminus arrows, road labels, the north arrow, the version stamp and the focus compression), derives real external stop chains, and fillsminutesToDestinationviagtfs_duration.py.- Place names are reverse-geocoded, not taken from GTFS. GTFS has no locality column and operators publish street-level headsigns, so three different Ramsey routes all "terminate at Bus Station".
draft_town.py'sPlaceNamerreverse-geocodes each stop via Nominatim, cached per 9-char ATCO locality. Two ordering traps, both hit and fixed: UK Nominatim puts the district incity(→ "Huntingdonshire"), so admin-looking names are rejected; andsuburbmust stay below town/city or destinations degrade to a district of themselves (Peterborough → "Millfield", Huntingdon → "Hartford"). Anything unresolved is labelled<check>rather than shipped as a confident guess. - Band policy follows the skill's own rule (s2-geometry.md step 7): GREEN builds, AMBER auto-applies rung 0 (
curate_services.js --apply), re-matches, re-scores and CONTINUES with the band recorded in the S2 commit note and prominently inDRAFT-REVIEW.md; only RED stops. Rungs 1+ stay human. Note: the gate must be run aftermatch_routes.jsor it scores straight-line geometry and its calibration doesn't hold — Ramsey read GREEN without the road match and AMBER with it. - GTFS-declared route variants are merged onto one spoke (
external[].routes) and one internal line (internalCorridors), using onlypossibleVariantOf— a much narrower claim than "these different services co-run".corridors_report.json's overlap warnings (<0.6) are surfaced in the review file. Distinct routes that would collide get their bearings spread instead of merged. - Always writes
<townDir>\DRAFT-REVIEW.md— 14 numbered items covering what is unverified, auto-guessed or untuned. Verified end-to-end on a real new town (Ramsey, Cambridgeshire, 8 services, AMBER): produced route badges, terminus arrows, road names, north arrow, version stamp, real destinations (March/Peterborough/Huntingdon/St Ives) with journey times.status.jsPASSes it alongside all 7 existing towns. - Not Tier-3 (item 9, still not recommended) — a draft for correction, not an unattended-publish pipeline.
- Place names are reverse-geocoded, not taken from GTFS. GTFS has no locality column and operators publish street-level headsigns, so three different Ramsey routes all "terminate at Bus Station".
Tooling (MCP servers + skills to use)
Use these in preference to the raw fallbacks; the fallbacks stay documented because no tool covers every case.
- GTFS dataset + query helper (S1 facts). One dataset per BODS region, registered in
_gtfs/regions.jsonand built byassets/gtfs_build.py(no API key):_gtfs/cambridgeshire.sqlite(East Anglia, ATCO 0500/0570),_gtfs/buckinghamshire.sqlite(South East, ATCO 0400) and_gtfs/bedfordshire.sqlite(South East, ATCO 0200/0210). EVERY REGION IS TREATED THE SAME (2026-08-21): there is no default region. Pick one with--dbor$GTFS_DB; with neither, the ad-hoc scripts fail and list the built regions rather than guessing — because a town queried against the wrong region's dataset matches nothing and reports every one of its routes as withdrawn, which reads exactly like a real answer.$CAMBS_GTFS_DBstill works but warns: it is misnamed for a multi-region system. Query withpython3 "%SK%\gtfs_query.py" <ATCO_PREFIX> --town "<Town>" --out gtfs-services.json(or--near "lat,lon,km"for a geographic radius when you don't know the prefix). Returns each route's operator, days, termini/headsigns, variant hints, andhasGtfsShape. Refresh the dataset by re-downloading the zip and re-runninggtfs_build.py(the feed reissues ~weekly;_gtfs/feed_info_<region>.jsonrecords each dataset's build + validity window — one per region, no shared file) — then runassets/gtfs_refresh_report.py, which diffs the new data against every built town's shippedverified-services.jsonand writes_gtfs/refresh-report_<date>.mdlisting which towns to update and why (new/withdrawn routes, operator/day changes, routes to re-evaluate; community/DRT gaps flagged as expected). A shipped route that is legitimately absent from BODS should DECLARE it —notInBods: {why, since, recheckBy}on the service entry, reported[NOT-IN-BODS]and not counted as actionable (OA-259, 2026-09-06). The older operator-name inference survives as the fallback, and it is only that: Whippet's commercial X1 is absent from the feed while nine of its sibling routes are in it, and read[WITHDRAWN?]every month until it declared itself. A town records known and deliberately not drawn innotOnLeaflet[]and nowhere else since 2026-09-06;verifiedNotDisplayed,notDisplayedandexcludedare read-only aliases, still parsed for ever and written by nothing, whichcheck-exclusion-fields.mjsenforces from buses-data's own CI. All four are read by this report AND by S6, from one rule joined bynpm run test:prove-known-off-parity; see references/s1-services.md and references/s6-verify.md plus a one-linerefresh-summary.txt. The towns it checks live in_gtfs/town_prefixes.json— S1 adds the town there (name →prefixesornear, plusregion, always — every town names its region explicitly, Cambridgeshire ones included) when building a new one;scaffold_town.pyfillsregionin automatically from--db. A town whose region is missing or wrong is reported NOT CHECKED rather than diffed — deliberately, because diffing a town against a dataset that cannot contain it reports every one of its routes as withdrawn (Beaconsfield did exactly that for a month). An over-broad NaPTAN block is a reason to usenearinstead ofprefixes: Beaconsfield's040000001spans 326 stops across Bucks and produced 35 unrelated[ADD?]rows until it moved to a 2.5 km radius. This runs automatically monthly via the Windows Scheduled Task "Refresh Cambridgeshire bus data" — the task name is historical; the script now refreshes every region marked"built"inregions.json(_gtfs/refresh-bus-data.ps1: per-region download → build, then one report across all towns → centre-screen pop-up dialog with Yes/No to open the report; logs to_gtfs/refresh.log). A region that fails to download is logged and skipped rather than failing the run — its towns simply report NOT CHECKED. A dialog box is used rather than a toast (toasts get swallowed by Focus Assist). Get ahead of changes: the monthly job also runsassets/gtfs_upcoming.py, which mines the feed's future-dated services (operators must publish ≥42 days before a change) →_gtfs/upcoming/upcoming-report_<date>.md. For a single town on demand (the S1 pre-print gate) usegtfs_upcoming.py --town "<Town>"(prints, writes nothing). It covers PLACES too (2026-08-17): unlike towns, place maps are not registered anywhere —assets/gtfs_places.pydiscovers everymanifest.jsonunderAreas/*/Places/,Places/*/orPlaces/*/*/(excluding the_portal-fixtureCI copy) and scans each against its ownnearradius from itsgtfs-services.json, emitting a## <Place>section with a_kind place · …_meta line that the portal joins on exactly. Use--place "<Place>"for the same pre-print gate,--list-unitsto see every town and place with its resolved region and radius,--no-placesto opt out. Before this, a place was only ever covered as a side effect of its parent town — so a place whose town wasn't registered got nothing, and the portal seeded a place map's public "changes coming" banner from its town's section (the Aldi map would have advertised High Wycombe's new WW1, which does not serve it). To render a leaflet as a future date will be, passgtfs_query.py --asof YYYY-MM-DD(only services in effect then; readscalendar_datestoo). To print advance notice on the sheet, setroutes.jsonstamp(opt-in; byte-identical when absent). Approx. journey time to each external destination (opt-inexternal[].minutesToDestination, S3):python3 "%SK%\gtfs_duration.py" <ATCO_PREFIX> --fill routes.jsonfills every spoke it can from the same sqlite (needs itsarrival_time/departure_timecolumns — added 2026-08-03, rebuild older dbs); takes--near "lat,lon,km"instead of a prefix for a town intown_prefixes.jsonlocated that way (e.g. Beaconsfield, High Wycombe — remember--dbtoo, since those are onbuckinghamshire.sqlitenot the default). See references/s3-config.md for what it can't cover (round-trip services, DRT, badgeLabels-remapped route keys) — rolled out to all 7 built towns 2026-08-03, coverage and per-town gotchas in references/changing-the-engine.md. Tier-1 unattended monthly refresh (item 7, 2026-08-04):python3 "%SK%\auto_refresh_month.py"reusesgtfs_refresh_report.py's per-town diff and classifies each changed town SAFE (only OPERATOR/DAYS changes — a mechanical text sync, auto-applied with--apply: patchesverified-services.json+routes.json'soperators[]/external[].days, runs S1(data-only)→S3→S4→S5 and stages it as a portal proposed-update viapropose-update.mjs) or ESCALATE (any ADD?/WITHDRAWN?/RE-EVAL — a new route needing a colour, or a shipped route possibly gone — left untouched for a human). Dry-run by default; writes_gtfs/auto-refresh-report_<date>.md. Not wired into the monthly scheduled task — run it by hand until it's been lived with, per the same "verify before automating unattended" caution asrollout.js. (Fixed a real, unrelated bug found while building this:gtfs_refresh_report.py/gtfs_upcoming.pyboth looked for town data at<root>/<Town>instead of<root>/Areas/<Town>, so the monthly refresh report had been silently empty for every town.) - NaPTAN stop register (
_gtfs/naptan.sqlite, built byassets/naptan_build.py, added 2026-08-22) — what GTFS does NOT tell you about a stop. BODS gives five stop columns and no way to tell same-named stops apart (Cambridge's Drummer Street is 14 rows all calledDrummer St Bus Station); NaPTAN carries theIndicator(Stop E,Bay 1,opp), theBearing, theStreet, theLandmarkand the NPTG locality, keyed onATCOCode— which is GTFS'sstop_id, so it joins with no lookup table. OGL v3.0, no API key. Build or rebuild it by running, from the assets folder,python3 naptan_build.py --out "C:/u3a St Ives/Using AI/Buses/_gtfs/naptan.sqlite"(~31 s;--outis the only argument and needs no substitution on this machine — it readsregions.jsonand fetches only the ATCO areas the built datasets actually reference, so it widens by itself when a region is added;--areas 050,040restricts it,--alltakes the national register). NaPTAN changes slowly and is deliberately NOT on the monthly cadence — rebuild it a few times a year, or when a town reports a stop with no row. Two traps, both spelled out in_gtfs/README.md: use the derivedlat/lon, neverLatitude/Longitude(blank on ~10% of rows nationally and on most of Cambridgeshire's — the build convertsEasting/Northingand records which inpos_source); and gate any stand lettering on the derivedstandcolumn, never onIndicator, which is populated almost everywhere and usually saysoppornear. How much stand cover exists town by town is measured inDevelopment Docs/stand-coverage_2026-08-22.md— short answer, lettering is an authority's policy rather than a national standard: Buckinghamshire letters its on-street town-centre stops, Cambridgeshire letters only its bus stations. Read that measurement with its own correction, though — it counts clusters of same-named stops, and the 2026-08-22 boarding-plan prototype showed that is not the question the product asks: at St Ives the busiest service boards 47 m away under a different name, so the place scores "fully lettered" and would still misdirect most of its readers (Development Docs/boarding-plan-product_2026-08-22.md§7.1). Re-counting against the frame is an open action. - Boarding-plan tools (added 2026-08-22, live in this skill's
assets/because they are engine-level, but drive a place sheet — the procedure ismake-place-bus-leaflet's SKILL.md, "Phase 3"):naptan_stands.py(which stops in a frame can be honestly named — OK/REFUSE),boarding_index.py(destination → where to stand, departures only),gen_boarding.js(the sheet; declines unlessroutes.jsonhas aboardingPlanblock),boarding_verify.py(the gate — checks NaPTAN, GTFS and the rendered SVG, and has a--self-testthat proves it can go red). First built for St Ives Bus Station. bboxMCP server (bbox-mcp-server, registered at user scope) — the preferred way to hit OpenStreetMap in S2. Tools:search_overpass(run raw Overpass QL within a bbox/radius — use it for thenode[highway=bus_stop]/naptan:AtcoCodestop pull and the POI queries),list_osm_tags(get the corre
Truncated - read the full file at https://github.com/PeterC66/claude-skills/blob/2ac4c872c68605e70158067743fad210d8de016b/make-bus-leaflet/SKILL.md.