From myfootmarks
Render the final magazine-style trip book HTML from research + primer + itinerary + checklists + chosen template. Orchestrates a 4-stage subagent pipeline (normalize → fetch-assets → generate-maps → render-html) with resumable intermediate state.
How this skill is triggered — by the user, by Claude, or both
Slash command
/myfootmarks:build-trip-bookThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Produce three self-contained HTML artifacts in `<slug>/` — itinerary, checklists, and keepsake — using the user's chosen template.
visual-specs/assets.mdvisual-specs/checklists-canonical.cssvisual-specs/components.mdvisual-specs/copy.mdvisual-specs/designs.mdvisual-specs/interactions.mdvisual-specs/itinerary-canonical.cssvisual-specs/keepsake-canonical.cssvisual-specs/rendering-checklist.mdvisual-specs/tokens.jsonvisual-specs/tokens.mdProduce three self-contained HTML artifacts in <slug>/ — itinerary, checklists, and keepsake — using the user's chosen template.
Required:
<slug>/trip.yaml (must include template: field; defaults to modern-magazine when absent).myfootmarks/trips/<slug>/research/destination-primer.md.myfootmarks/trips/<slug>/research/*.md (all 14 research outputs + restaurants-final.md)<slug>/itinerary.md<slug>/packing.md<slug>/pre-trip.md<slug>/day-of.md<slug>/trip-itinerary.html — during-trip bring-along (tactical density, field cards for in-hand logistics)<slug>/trip-checklists.html — before-trip print-and-cross-off (austere, B&W-safe, native checkboxes, one-per-page sections)<slug>/trip-book.html — keepsake (read-ahead / hotel). Note the semantic shift: starting in v2 this file is no longer "the whole book"; it is keepsake-only, and the itinerary + checklists have moved into the two new files above.<slug>/assets/ — image cache from Wikidata/Commons (cross-run-persistent).myfootmarks/trips/<slug>/book-data.json — Stage 1 output.myfootmarks/trips/<slug>/asset-manifest.json — Stage 2 output.myfootmarks/trips/<slug>/maps/*.svg — Stage 3 outputs (one day-<N>.svg per itinerary day, one <place-id>.svg per featured place, and one destination.svg editorial overview)Each stage's primary output file is checked at the top of that stage. If the output exists AND every upstream input is older (by mtime) than the output, the stage is skipped.
Mtime comparisons per stage:
| Stage | Output | Triggers re-run when newer |
|---|---|---|
| 1 — normalize | book-data.json | any research/*.md, destination-primer.md, itinerary.md, or trip.yaml |
| 2 — fetch-assets | asset-manifest.json | book-data.json |
| 3 — generate-maps | each day-<N>.svg, each <place-id>.svg, and destination.svg (per-file resumability — any single missing output triggers re-render of that file only, not the whole stage) | book-data.json, itinerary.md, trip.yaml (day-overview maps depend on itinerary's timeline; per-place insets and destination map are book-data driven) |
| 4 — render-html | trip-itinerary.html AND trip-checklists.html AND trip-book.html (all three must exist AND all must be newer than every input to skip) | book-data.json, asset-manifest.json, any maps/*.svg, packing.md, pre-trip.md, day-of.md, itinerary.md |
Stage 4 output is an all-or-nothing set of three files. Any input newer than any of the three outputs OR any of the three outputs missing → re-render all three.
An explicit --rebuild flag is NOT implemented in v1. To force a re-run of a specific stage, delete that stage's intermediate output; to force a full rebuild, delete .myfootmarks/trips/<slug>/book-data.json (cascades through the pipeline).
Read .myfootmarks/current-trip → slug → <slug>/trip.yaml. Extract destination, start_date, end_date, travelers, home_base, regions, pace, template (default modern-magazine when absent).
Verify upstream artifacts exist:
.myfootmarks/trips/<slug>/research/destination-primer.md<slug>/itinerary.md, packing.md, pre-trip.md, day-of.md.myfootmarks/trips/<slug>/research/*.md file with places: frontmatterIf anything required is missing, stop and tell the user which upstream skill needs to run first. Do not proceed.
If .myfootmarks/trips/<slug>/book-data.json exists AND is newer than every research file + destination-primer.md + itinerary.md + trip.yaml, skip this stage.
Otherwise, dispatch a subagent with the following brief.
You normalize the raw research outputs into a single structured book model. Read:
<slug>/trip.yaml.myfootmarks/trips/<slug>/research/destination-primer.md frontmatter (hero_wikidata_id, places: array with kind: destination | neighborhood).myfootmarks/trips/<slug>/research/*.md frontmatter (14 research files + restaurants-final.md)<slug>/itinerary.md (for day structure and stop IDs)Produce a single JSON file at .myfootmarks/trips/<slug>/book-data.json with this shape:
{
"trip": {
"destination": "...",
"start_date": "YYYY-MM-DD",
"end_date": "YYYY-MM-DD",
"travelers": [...],
"home_base": "...",
"regions": [...],
"pace": "...",
"template": "modern-magazine"
},
"primer": {
"hero_wikidata_id": "Qnnnnn",
"neighborhoods": [
{ "id": "...", "name": "...", "character": "...", "walk_this": "...", "pairs_with": "..." }
]
},
"time_primer": {
"weather": { "daily_forecast": [...] },
"events": {
"dated": [...],
"recurring": [...],
"festivals": [...]
},
"things_to_know": [
{ "tag": "In season | Reduced hours | Free evening | Book ahead | Weather-sensitive | New opening", "title": "...", "body": "..." }
]
},
"places": [
{
"id": "castelo-sao-jorge",
"name": "...",
"wikidata_id": "Qnnnnn",
"commons_filename": "...",
"image_url": "...",
"source": "...",
"archetype": "...",
"match_confidence": "...",
"match_reason": "...",
"lat": 38.714,
"lon": -9.133,
"lens": ["attraction", "scenic", "daytrip"],
"neighborhood": "...",
"category": "...",
"address_street": "...",
"phone": "...",
"hours_structured": { "mon": "...", "tue": "...", "...": "..." },
"reservation_url": "...",
"reservation_required": false,
"lead_time_days": null,
"signature": "...",
"source_url": "...",
"stroller": true,
"with_kids": true,
"kid_first": false
}
],
"dishes": [
{ "id": "...", "name": "...", "description": "...", "wikidata_id": null, "commons_filename": null, "image_url": "...", "source": "...", "archetype": "dish", "match_confidence": "...", "match_reason": "...", "cultural_context": "...", "served_at": ["place_id", "..."] }
],
"lookups": {
"by_neighborhood": { "<neighborhood-name>": [{ "place_id": "...", "lens_summary": "..." }] },
"by_day_of_week": {
"mon": { "open": ["place_id", ...], "closed": ["place_id", ...], "recurring_events": [...] },
"tue": { "...": "..." },
"wed": { "...": "..." },
"thu": { "...": "..." },
"fri": { "...": "..." },
"sat": { "...": "..." },
"sun": { "...": "..." }
},
"by_weather": { "sunny": ["place_id", ...], "rainy": ["place_id", ...] },
"with_kids": ["place_id", ...],
"without_kids": ["place_id", ...],
"reservations": { "weeks_ahead": [...], "days_ahead": [...], "day_of": [...], "walk_up": [...] }
},
"featured_groups": {
"icons": ["place_id", ...],
"neighborhood_anchors": { "<region>": ["place_id", ...] },
"events_this_week": ["place_id", ...]
},
"daily_plan": [
{
"day": 1,
"date": "YYYY-MM-DD",
"theme": "...",
"weather": { "icon": "...", "high_c": 18, "low_c": 10, "summary": "..." },
"narrative": "3-5 sentence lede",
"timeline": [
{ "time": "09:30", "place_id": "...", "note": "..." }
],
"alternatives": [
{ "slot": "morning | lunch | dinner", "instead_of": "place_id", "options": [{ "place_id": "...", "label": "..." }] }
]
}
],
"checklists": { "packing": "...", "pre_trip": "...", "day_of": "..." },
"appendix": ["place_id", ...],
"_conflicts": []
}
Group the places: entries across all research files into one canonical per-place record:
id (exact match) OR by wikidata_id (if both entries have one and they match).lens: arrays. A grouped place carries the union of all lenses from every research file that surfaced it. Example: a place surfaced by research-attractions + research-scenic + research-daytrips ends up with lens: ["attraction", "scenic", "daytrip"].lat/lon to 3 decimal places, different category), keep the first-seen value using this read order: restaurants-final.md (pre-merged) → research-attractions.md → research-scenic.md → research-outdoor.md → research-daytrips.md → research-arts.md → research-music.md → research-nightlife.md → research-shopping.md → research-events.md → research-local-foods.md → research-weather.md. Log every conflict to _conflicts[] with { place_id, field, kept_value, rejected_value, rejected_source } for debugging.wikidata_id, commons_filename, image_url, source, archetype, match_confidence, match_reason) on a grouped place: prefer the entry with higher confidence (high > medium > low); on tie, prefer source priority wikidata-verified > rest-summary > wikivoyage-banner > openverse > commons-file-search > commons-category > subpage-og-image > homepage-og-image; on further tie, fall back to the existing first-seen-by-file-priority rule. Log conflicts to _conflicts[] with {place_id, field: "image-resolution-tuple", kept_source, rejected_source, rejected_file}.featured_groups.icons: places tagged with lens.length >= 2 (surfaced by multiple research lenses — a proxy for "matters multiple ways"). Cap at 6–8. Prefer higher lens.length and entries with wikidata_id set. Example: Butchart Gardens surfaced by [attraction, scenic, outdoor, daytrip] is a clear icon.featured_groups.neighborhood_anchors[region]: for each home-base region in trip.regions, pick 2–4 places where neighborhood == region, preferring ones NOT already in icons (avoid double-counting). Order by lens.length descending.featured_groups.events_this_week: every entry from research-events's places: whose dates overlap the trip window (check start_date, festival_run, or recurring_days intersected with trip day-of-week set).lookups.by_neighborhood[region]: every place with neighborhood == region, with a lens_summary string summarizing its lens list (e.g., "Attraction · Scenic · Kid-powered"). Sort by lens.length descending.lookups.by_day_of_week[day]: for each short day key (mon … sun):
open: place_ids where hours_structured[day] != null.closed: place_ids where hours_structured[day] == null AND at least one other day has hours (indicating intentional closure, not unknown).recurring_events: place_ids from research-events where type: recurring and recurring_days contains this day.lookups.by_weather.sunny: places with any of [outdoor, scenic, daytrip] in lens. by_weather.rainy: places with [attraction, arts, food, shopping, nightlife] in lens AND explicit indoor category (museum, gallery, restaurant, market, bar, concert-hall).lookups.with_kids: places where kid_first == true. without_kids: places inferred adults-only from category (cocktail-bar, speakeasy, wine-bar, fine-dining) or from with_kids == false AND stroller == false.lookups.reservations: bucket places by lead_time_days:
> 14 → weeks_ahead3 <= x <= 14 → days_ahead1 <= x <= 2 → day_of0 (walk-up) → walk_upnull lead_time (no reservation expected).Parse itinerary.md to extract per-day structure. For each day:
places[] list to resolve a place_id. If no match, keep the stop as a free-text note with place_id: null.alternatives: [] for that day.Weather for each day comes from research-weather.md's daily_forecast array matched by date.
things_to_know extractionScan every research-file's Markdown body for time-sensitive gotchas that match the trip window. For each one you find, emit a { tag, title, body } entry using ONE of these canonical tags (pick the best fit):
In season — ingredient at peak, bloom window, migration, etc.Reduced hours — shoulder-season schedule, partial closures.Free evening — free admission night or half-price night that falls within the trip window.Book ahead — a specific restaurant/event within the trip week that requires urgency.Weather-sensitive — an activity that depends on weather holding.New opening — something new since the last guidebook edition that the reader might miss.Cap at 8 entries. Prioritize ones that actually fall within the trip dates, not just "sometime in May."
Write .myfootmarks/trips/<slug>/book-data.json as pretty-printed JSON (2-space indent). Do not emit anything else.
If .myfootmarks/trips/<slug>/asset-manifest.json exists AND is newer than book-data.json, skip this stage.
Otherwise, dispatch a subagent with the following brief.
You resolve and download one representative image per entity in book-data.json (places[] and dishes[] and the destination hero), honoring trip.yaml.image_overrides first and falling back to the find-place-image resolver. You produce asset-manifest.json with enriched per-entry metadata and print a per-entity audit table to stdout.
curl for image downloads, jq for JSON manipulation, md5 for Commons hash computation, sleep for pacing/myfootmarks:find-place-image skill — invoke it directly when fresh resolution is needed<slug>/trip.yaml — for destination (passed as region to find-place-image) and image_overrides (top-level key, optional)..myfootmarks/trips/<slug>/book-data.json — places[], dishes[], and primer.hero_wikidata_id.Read inputs. Parse trip.yaml.destination (use as region). Parse trip.yaml.image_overrides (default to empty map if absent). Parse book-data.json and collect every entity that needs an image: each places[] entry (key by places[].id), each dishes[] entry (key by dishes[].id), plus a synthetic hero entity keyed by the destination-primer's place id, with wikidata_id: <book-data.primer.hero_wikidata_id>.
Validate image_overrides keys. For each key in trip.yaml.image_overrides, check that it matches an entity id in the collected set. Unknown keys produce a warning row in the audit table but the build continues.
For each entity (in book-data order), resolve the image:
Step 0: Override. If image_overrides[<entity_id>] is present:
"skip" → record manifest entry {status: "no-image", source: "override-skip", reason: "manual-skip", confidence: "high", match_reason: "manual-skip"}. Skip download. Continue.skills/find-place-image/references/attribution-extract.md §1. Record source: "override-commons", confidence: "high". On failure → status: "fetch-failed", populate reason. Continue.curl -sS -L -o. Verify the response Content-Type is image/*; if not, record status: "fetch-failed", reason: "non-image-mime: <mime>". On success → record source: "override-url", author: "unknown", license: "user-provided", confidence: "high". Continue.status: "fetch-failed", reason: "malformed-override-value". Continue.Step 1: Pre-resolved Q-ID validation. Else if the entity has non-null wikidata_id AND non-null commons_filename AND non-null image_url:
source == "wikidata-verified" (already verified upstream): download image_url, fetch attribution per attribution-extract.md §1, record source: "wikidata-verified", confidence: "high". Continue.source == null or any non-wikidata-verified value): invoke find-place-image in validate-mode by passing wikidata_id along with name, category, region. On the envelope's status: "found" → download the envelope's image_url, write all envelope fields to the manifest. On status: "no-image" (validation rejected the Q-ID) → record status: "no-image", reason: "stale-or-mismatched-id", copy match_reason from the envelope. Do NOT fall through to fresh-resolve in this run — the bad ID needs to be cleared from research output first.Step 2: Fresh resolve. Else (no override, no usable Q-ID): invoke find-place-image with {name, category, region} (and coordinates if present). On found → download image_url, write all envelope fields to manifest. On no-image → record status: "no-image", copy match_reason from envelope.
Image download mechanics. Per source:
rest-summary, wikidata-verified, wikivoyage-banner, commons-file-search, commons-category, override-commons): the image_url from find-place-image is already a 1200px thumbnail URL (https://upload.wikimedia.org/wikipedia/commons/thumb/<a>/<ab>/<filename>/1200px-<filename>). For raw Commons filenames (override path), construct the URL using:
filename_underscored=$(echo "$filename" | tr ' ' '_')
md5=$(echo -n "$filename_underscored" | md5 -q)
prefix1=${md5:0:1}
prefix2=${md5:0:2}
thumb_url="https://upload.wikimedia.org/wikipedia/commons/thumb/${prefix1}/${prefix2}/${filename_underscored}/1200px-${filename_underscored}"
# If filename ends in .png/.svg/etc., append .jpg to force JPG transcoding
url directly (Openverse returns hot-linkable image URLs).curl -sS -L -o, verify Content-Type: image/* afterward via curl -sI.mkdir -p "<slug>/assets"
curl -sS -L -A "${UA}" -o "<slug>/assets/<entity-id>.jpg" "${image_url}"
sleep 0.25
If curl returns non-zero or the output file is under 1KB → status: "fetch-failed", reason: "download-failed".
Idempotency. Before each download, check if <slug>/assets/<entity-id>.jpg already exists AND book-data.json[<entity_id>] mtime is unchanged from the prior manifest entry's generated_at timestamp AND no override change has occurred for that entity. If all three are true, skip the entire find-place-image call AND the download — reuse the prior manifest entry verbatim (still print its row in the audit table).
Cache find-place-image results. When invoking the resolver from this stage (not from the slash command), key the cache on SHA-1 of {name, category, region, wikidata_id?} JSON-canonical and write to .myfootmarks/trips/<slug>/cache/find-place-image/<hash>.json. On a re-run with unchanged inputs, reuse the cache hit (do NOT re-invoke the resolver).
Write asset-manifest.json. Emit .myfootmarks/trips/<slug>/asset-manifest.json with this enriched schema:
{
"generated_at": "<ISO-8601>",
"images": [
{
"place_id": "...",
"status": "cached" | "no-image" | "fetch-failed",
"local_path": "<slug>/assets/<id>.jpg" | null,
"source": "rest-summary" | "wikidata-verified" | "wikivoyage-banner" | "openverse" | "commons-file-search" | "commons-category" | "subpage-og-image" | "homepage-og-image" | "override-commons" | "override-url" | "override-skip",
"archetype": "destination" | "landmark" | "restaurant" | "event" | "dish" | "seasonal" | "neighborhood" | null,
"confidence": "high" | "medium" | "low" | null,
"match_reason": "...",
"source_filename": "..." | null,
"image_url": "..." | null,
"source_url": "...",
"author": "...",
"license": "...",
"reason": "..."
}
],
"hero_image": { "place_id": "<destination-id>", "...": "(same shape as an entry in images[])" }
}
ENTITY ARCHETYPE SOURCE CONFIDENCE NOTES
butchart-gardens landmark rest-summary high Sunken Garden, CC BY-SA 3.0
royal-bc-museum landmark rest-summary high Main entrance, CC BY 3.0
red-fish-blue-fish restaurant openverse high Flickr CC BY-NC
belfry-casey-and-diana event subpage-og-image medium /shows/casey-and-diana
victoria-highland-games-2026 event commons-file-search high Category:Highland games
obscure-local-cafe restaurant — — no-image (stack-exhausted)
bad-override-example landmark override-url — fetch-failed: 404 at <url>
NOTES column carries (for cached) a short ", " hint; (for
no-image) the match_reason (e.g., no-match, category-mismatch, stale-or-mismatched-id); (for fetch-failed) <reason>: <detail>. Pad columns with spaces to align — no box-drawing characters.
Append warning rows for any unknown override keys flagged in step 2.
| Failure | manifest behavior | audit row |
|---|---|---|
Resolver returns no-image (stack exhausted) | status: "no-image", match_reason: "no-match" | — — no-image (stack-exhausted) |
Resolver returns no-image (category-mismatch on validate-mode) | status: "no-image", match_reason: "category-mismatch" or region-mismatch | — — no-image (stale-or-mismatched-id) |
| Override invalid (404, malformed, non-image) | status: "fetch-failed", populated reason | override-* — fetch-failed: <reason> |
| Override key matches no entity | (no manifest entry written for that key) | warning row at end of audit |
| Wikimedia 429 / 5xx | retry per rate-limit etiquette; persistent → status: "fetch-failed", reason: "upstream-unavailable" | <source> — fetch-failed: upstream-unavailable |
Return a short status summary: "Stage 2 complete: <N cached>, <M no-image>, <K fetch-failed>, <L overridden>."
If .myfootmarks/trips/<slug>/maps/ exists AND every expected SVG is present AND each is newer than book-data.json, skip this stage.
Otherwise, dispatch a subagent with the following brief.
You generate self-contained inline-SVG maps backed by real OpenStreetMap tile imagery and editorial overlays, plus QR codes for live-navigation handoff. Three map surfaces: day-overview maps in the itinerary doc, place-card inset maps shared by itinerary + keepsake, and a destination-level editorial map in the keepsake doc.
The base layer is OSM tile cartography fetched at build time and embedded as base64 PNG inside the SVG. The overlay layer (numbered pins, dashed accent-red route, white-halo label boxes, home-base square) carries the book's editorial signature. QR codes are generated via the keyless api.qrserver.com API and embedded as base64 PNG.
STOP — read this before doing anything else. If .myfootmarks/trips/<slug>/maps/ already contains SVG files from a prior build, do NOT open them, inspect their contents, or use their visual style as a reference. Per-file resumability skips files that are still current; for any file you DO render, follow this brief exactly — base64-embedded OSM tile mosaic + editorial overlay + QR. Never pattern-match the new output to legacy line-art files left over on disk. If the existing files look stylistically different from what this brief describes, that is expected — they are stale outputs from v1 and will be regenerated whenever their inputs change.
https://tile.openstreetmap.org/{z}/{x}/{y}.png), QR API (https://api.qrserver.com/v1/create-qr-code/), and optional Overpass queries for neighborhood-label nodesbase64 -w 0), file-existence cache checks, sleep-based rate limiting, simple math via bc or node -eRuntime data:
<slug>/trip.yaml — for destination string used in QR URL , {destination} suffix.myfootmarks/trips/<slug>/book-data.json (output of Stage 1) — authoritative data model. Specifically you need:
daily_plan[].timeline[] — per-day stop sequence (name, lat, lon, place_id)daily_plan[].home_base (or trip-level home_base) — anchor for route start/endfeatured_groups.icons[], featured_groups.neighborhood_anchors[], featured_groups.events_this_week[] — places that get inset mapsplaces[].lat, places[].lon, places[].name, places[].google_place_id (optional)Visual contract (canonical source of truth — MUST be read):
skills/build-trip-book/visual-specs/components.md → "v2 maps — tile mosaic + QR linkouts" for the four new components (tile-mosaic-svg, qr-place, qr-day, destination-map)skills/build-trip-book/visual-specs/interactions.md → "v2 maps — print-mode + remote-API rules" for fair-use, rate limits, fallback behaviorskills/build-trip-book/visual-specs/tokens.json for accent-red #d7362b, paper #fafaf7, ink #0b0b0c, fonts (Inter sans-800 for pin numbers, sans-700 for labels)Read all three before writing any SVG.
Three categories of .svg files written to .myfootmarks/trips/<slug>/maps/:
day-<N>.svg — one per day in daily_plan. Contains zoom-15 tile mosaic + numbered pins for each timeline stop + home-base square + dashed accent-red route + per-day QR (in margin) + OSM attribution. Inlined by Stage 4 inside each per-day section in the itinerary.<place-id>.svg — one per featured place across all featured_groups.*[]. Contains zoom-16/17 tile mosaic + single accent-red pin + per-place QR (in margin) + OSM attribution. Inlined by Stage 4 inside place field cards (itinerary) and place story cards (keepsake).destination.svg — one file per trip. Contains zoom-12/13 tile mosaic covering the full trip bounding box + sparse editorial overlay (no route, optional neighborhood labels) + OSM attribution. NO QR. Inlined by Stage 4 inside the keepsake's Destination Primer section.All three artifact types are fully self-contained — base64-embedded tiles + base64-embedded QR PNG + inline SVG primitives. No remote href, no external CSS, no separate asset files.
For each map artifact, compute the lat/lon bounding box and pad it. Pad fractions:
Bbox formula (pseudocode, run in Bash via node -e or equivalent):
function computeBbox(points, padFrac) {
const lats = points.map(p => p.lat), lons = points.map(p => p.lon);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const dLat = Math.max((maxLat - minLat) * padFrac, 0.003);
const dLon = Math.max((maxLon - minLon) * padFrac, 0.005);
return { s: minLat - dLat, w: minLon - dLon, n: maxLat + dLat, e: maxLon + dLon };
}
For day-overview: points = [home_base, ...timeline.map(slot => slot.place)].
For place-inset: points = [{lat, lon}] of the single place; result is just {lat ± dLat, lon ± dLon}.
For destination: points = [home_base, ...all featured places, ...all daily_plan stops] — the union.
For each bbox + zoom level, compute the tile coordinates spanning the bbox via Web Mercator:
function lonToTileX(lon, z) { return (lon + 180) / 360 * Math.pow(2, z); }
function latToTileY(lat, z) {
return (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z);
}
tx0 = floor(lonToTileX(bbox.w, z)), tx1 = floor(lonToTileX(bbox.e, z)), ty0 = floor(latToTileY(bbox.n, z)), ty1 = floor(latToTileY(bbox.s, z)). The mosaic is cols = tx1 - tx0 + 1 wide and rows = ty1 - ty0 + 1 tall.
For each (tx, ty) in the grid:
.myfootmarks/trips/<slug>/cache/tiles/tile-{z}-{tx}-{ty}.png.https://tile.openstreetmap.org/{z}/{tx}/{ty}.png with:
User-Agent: myfootmarks/0.x (trip book generator; contact [email protected]) (descriptive, plugin-identifying — NOT blank, NOT a generic curl-style UA)Referer: https://github.com/escapevelocitylabs/myfootmarks (any valid origin)base64 -w 0 < {file} for embedding in the SVG.On HTTP 429 or 403: back off 60 seconds, retry the failing tile once. If it fails again, fall back to a text-only placeholder for that map (<text>Map unavailable</text> inside the SVG) and continue with the rest of the maps.
Tile budget per trip: ~12 tiles per day-overview × 12 days + ~1–4 tiles per place-inset × 30 places + ~16 tiles for destination = up to ~250 tiles. Aggressive cache; re-runs on the same data should fetch zero tiles.
For each map artifact, write an <svg> whose viewBox matches the mosaic's pixel space:
<svg viewBox="0 0 {cols*256} {rows*256}" xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet">
<!-- Tile mosaic layer -->
<image href="data:image/png;base64,{base64-of-tile-tx0-ty0}"
x="0" y="0" width="256" height="256"/>
<image href="data:image/png;base64,{base64-of-tile-tx1-ty0}"
x="256" y="0" width="256" height="256"/>
<!-- ... one <image> per tile, positioned at (gridX * 256, gridY * 256) ... -->
<!-- Editorial overlay group (Step 4) -->
<g class="map-overlay">
<!-- pins, route, labels, home base -->
</g>
<!-- QR + attribution (Step 5) -->
<g class="qr-slot">
<image class="qr-day|qr-place" href="data:image/png;base64,{qr-base64}"
x="..." y="..." width="..." height="..."/>
<text class="qr-label" x="..." y="...">Scan for directions</text>
</g>
<text class="map-attribution" x="..." y="..." font-size="9">Map data © OpenStreetMap contributors</text>
</svg>
Critical: The SVG viewBox MUST be 0 0 {cols*256} {rows*256} — the pixel dimensions of the tile mosaic. Pins (Step 4) project lat/lon into this same coordinate space via the mosaic projector. Project against the mosaic extent, NOT the requested bbox (mosaic is tile-aligned and may exceed the bbox).
For each named place / home base, project lat/lon into mosaic pixel space:
function makeMosaicProjector(m, w, h) {
return ({ lat, lon }) => {
const xPx = (lonToTileX(lon, m.zoom) - m.tx0) / m.cols * w;
const yPx = (latToTileY(lat, m.zoom) - m.ty0) / m.rows * h;
return { x: xPx, y: yPx };
};
}
Where m = { zoom, tx0, ty0, cols, rows } from Step 2 and w = cols * 256, h = rows * 256.
Then emit overlay primitives:
Route line (day-overview only — sequence is [home_base, ...stops, home_base]):
<path d="M {x0} {y0} L {x1} {y1} L {x2} {y2} ..." fill="none"
stroke="#d7362b" stroke-width="3"
stroke-dasharray="7 5"
stroke-linecap="round" stroke-linejoin="round"/>
Home-base marker (day-overview only — black square, distinguishes from circular stop pins):
<rect x="{hx - 7}" y="{hy - 7}" width="14" height="14"
fill="#0b0b0c" stroke="#fafaf7" stroke-width="2"/>
Numbered pin (day-overview: i ranges 1..N for stops; place-inset: just one pin, i=1 or unnumbered):
<g>
<circle cx="{x}" cy="{y}" r="14"
fill="#d7362b" stroke="#fafaf7" stroke-width="2.5"/>
<text x="{x}" y="{y + 4}" text-anchor="middle"
font-family="Inter" font-weight="800" font-size="13"
fill="#fafaf7">{i}</text>
</g>
Pin label (right of pin, day-overview only — place-insets and destination map don't need labels):
<g>
<rect x="{x + 18}" y="{y - 8}" width="{w}" height="16"
fill="#fafaf7" stroke="#0b0b0c" stroke-width="0.5"/>
<text x="{x + 22}" y="{y + 4}"
font-family="Inter" font-weight="700" font-size="11"
fill="#0b0b0c">{name}</text>
</g>
width = max(80, name.length * 6.5). Always position right of the pin (x + 18).
Destination map overlay: sparse — no route, no numbered pins. Optional small dots for featured-place neighborhoods if Overpass returns place=neighbourhood nodes in the bbox. NO labels unless the trip has a clear handful of named neighborhoods.
All strokes ≥ 0.75px for print-grayscale safety. Route at 3px is fine.
QR codes use the keyless api.qrserver.com API. NOT an npm dep — pure WebFetch.
Per-place QR (L1):
URL pattern (the URL the QR encodes — what the phone scanner opens):
https://www.google.com/maps/search/?api=1&query={url-encoded "name, destination"}
If the place has google_place_id, append &query_place_id={url-encoded id}.
Example for "Beacon Hill Park" in Victoria, BC:
https://www.google.com/maps/search/?api=1&query=Beacon%20Hill%20Park%2C%20Victoria%2C%20BC
Then fetch the QR PNG:
WebFetch https://api.qrserver.com/v1/create-qr-code/?data={url-encoded-target}&size=200x200&ecc=M
Cache as .myfootmarks/trips/<slug>/cache/qr/qr-place-{place-id}-M.png. Base64-encode for embedding.
Per-day QR (L2):
URL pattern:
https://www.google.com/maps/dir/?api=1&origin={home_base.lat},{home_base.lon}&destination={home_base.lat},{home_base.lon}&waypoints={url-encoded "lat1,lon1|lat2,lon2|..."}
ALWAYS use lat/lon waypoints. Named-place waypoints are unreliable.
Example for Day 3 of a Victoria trip:
https://www.google.com/maps/dir/?api=1&origin=48.4101,-123.3656&destination=48.4101,-123.3656&waypoints=48.5635%2C-123.4708%7C48.5566%2C-123.4622
Fetch with &size=240x240&ecc=L. Cache as qr-day-{N}-L.png.
Failure handling: on HTTP non-200 or timeout, substitute a text fallback inside the SVG slot:
<text class="qr-fallback" x="..." y="...">Search "{name}" on Google Maps</text><text class="qr-fallback" x="..." y="...">Open Google Maps for today's stops</text>Build does not error. Page renders fine. Logged in runs.jsonl.
Rate limit: 1 QR per second, no concurrent calls. ~30–50 QRs per trip max.
No QR on the destination map. That's by design — the keepsake's destination-level map is atmospheric, not navigational.
For each generated QR, verify the encoded URL contains the expected destination string. This is the eyeball replacement for an automated decode test.
Per-place QR check: the URL passed to api.qrserver.com (before URL-encoding) must contain {name}, {destination} exactly. Example check via Bash:
# Pseudo-check: the URL passed to the QR API should encode "Beacon Hill Park, Victoria, BC"
expected_substring="Beacon%20Hill%20Park%2C%20Victoria%2C%20BC"
[[ "$qr_api_url" == *"$expected_substring"* ]] || echo "QR URL mismatch for Beacon Hill Park"
Per-day QR check: the URL must contain the home_base lat/lon as both origin and destination, AND each timeline stop's lat/lon as a waypoint. Example:
# Day 3 origin/destination = home base lat/lon, both encoded
[[ "$qr_api_url" == *"origin=48.4101,-123.3656"* ]] || echo "Day 3 origin mismatch"
[[ "$qr_api_url" == *"destination=48.4101,-123.3656"* ]] || echo "Day 3 destination mismatch"
[[ "$qr_api_url" == *"48.5635%2C-123.4708"* ]] || echo "Day 3 missing Butchart Gardens waypoint"
If any self-check fails, regenerate the QR with corrected input. If still failing after one retry, fall back to text inside the SVG and log the failure in runs.jsonl.
This catches encoding bugs (wrong place name, wrong waypoint order, URL-encoding errors) without an automated decode harness. Pixel-level decode failures (corrupted QR after print) are caught by manual print-test post-merge.
<text>Map unavailable</text> placeholder inside the SVG. Page renders fine. QR is unaffected (independent code path).api.qrserver.com returns non-200 or times out: fallback to text inside the SVG (per Step 5 fallback strings). Build does not error.runs.jsonl.10 imperative rules for this subagent pass:
day-<N>.svg per day, <place-id>.svg per featured place, exactly one destination.svg per trip. Never collapse them; never invent additional categories.api.qrserver.com for QR generation. Never use a different URL scheme (?query={lat},{lon} was rejected — hides the listing card). Never add an npm dep.?query={name}, {destination} (with optional &query_place_id={id}). Per-day QR MUST be the multi-waypoint directions URL with lat/lon waypoints.<image href="data:...">. Never use remote hrefs — the SVG must be fully self-contained for offline + print viewing.makeMosaicProjector), not the requested bbox. Mosaic is tile-aligned and may exceed the bbox; using the bbox produces misaligned pins.viewBox="0 0 {cols*256} {rows*256}" and preserveAspectRatio="xMidYMid meet" on the outer <svg>. The viewBox MUST match the mosaic pixel space exactly.Map data © OpenStreetMap contributors <text> element near the bottom of every map SVG. OSM fair-use requires it. Never hide in HTML comments.tile-{z}-{x}-{y}.png and qr-{type}-{id}-{ecc}.png). Re-runs on the same data must fetch zero new tiles or QRs.#d7362b for route lines and pin fills, paper #fafaf7 for pin label backgrounds and stroke insets, ink #0b0b0c for label text and home-base square. Strokes ≥ 0.75px for print safety..myfootmarks/trips/<slug>/maps/ for style cues. They may be stale v1 line-art outputs from before this brief existed. Render every file you write according to this brief, regardless of what is already on disk. "Match the existing style" is a failure mode, not a feature.Return a short status summary to the caller:
"Stage 3 complete: <N day maps>, <M place insets>, 1 destination map, <K fallback maps>."
If <slug>/trip-itinerary.html, <slug>/trip-checklists.html, AND <slug>/trip-book.html ALL exist AND ALL are newer than book-data.json, asset-manifest.json, every maps/*.svg, itinerary.md, packing.md, pre-trip.md, AND day-of.md, skip this stage. If ANY of the three output files is missing OR ANY input is newer than ANY of the three outputs, re-render all three.
Otherwise, dispatch a subagent with the following brief.
You compose three fully-independent self-contained HTML documents (itinerary / checklists / keepsake) by inlining all prepared assets (images as local <img> src references, SVG maps inlined as <svg>, copy, CSS) and applying the modern-magazine template. All three docs share one template voice but express three distinct reading densities — see visual-specs/designs.md → "One voice, three densities" for the mood requirements per doc.
Runtime data produced by earlier stages:
<slug>/trip.yaml.myfootmarks/trips/<slug>/book-data.json (output of Stage 1 — authoritative data model).myfootmarks/trips/<slug>/asset-manifest.json (output of Stage 2).myfootmarks/trips/<slug>/maps/*.svg (outputs of Stage 3 — inline each)<slug>/itinerary.md (for per-day narrative)<slug>/packing.md, pre-trip.md, day-of.md (checklist content)Visual contract — the canonical source of truth for the modern-magazine template. MUST be read:
skills/build-trip-book/visual-specs/designs.md — 9-section design narrative (especially Section 9: Agent Prompt Guide, the imperative rules)skills/build-trip-book/visual-specs/tokens.json — full DTCG design tokensskills/build-trip-book/visual-specs/tokens.md — human-readable token summaryskills/build-trip-book/visual-specs/components.md — 28-component inventory with HTML/CSS patternsskills/build-trip-book/visual-specs/copy.md — canonical copy strings, tone, and voice rulesskills/build-trip-book/visual-specs/interactions.md — print mode behavior, state rules (prototype-only states do NOT apply in the production render)skills/build-trip-book/visual-specs/assets.md — confirms all glyphs inline, no icon filesskills/build-trip-book/visual-specs/itinerary-canonical.css — the verbatim CSS to inline into trip-itinerary.html's <style> block. Copy its full contents byte-for-byte; do not paraphrase, reorder, or simplify.skills/build-trip-book/visual-specs/checklists-canonical.css — same role for trip-checklists.html.skills/build-trip-book/visual-specs/keepsake-canonical.css — same role for trip-book.html.skills/build-trip-book/visual-specs/rendering-checklist.md — the must-have contract every rendered HTML file must satisfy. The agent reads this AFTER writing each file and validates its own output against it (see "Self-validation" section below).Read all eleven files before writing any HTML (the seven .md/.json specs plus the three *-canonical.css files plus rendering-checklist.md). When Visual Specifications conflict with anything in this brief, the Visual Specifications win.
Write exactly three HTML files to <slug>/. Each file must be fully self-contained (own <head>, own inline CSS, own cover, own footer). No file in the set may reference another file in the set via <a href> — zero cross-file links. The reader must be able to use any one of the three docs without the other two.
<slug>/trip-itinerary.html — during-trip bring-along. Tactical density. Field cards for in-hand logistics.<slug>/trip-checklists.html — before-trip print-and-cross-off. Austere. B&W-safe. Native checkboxes. One-per-page checklist sections.<slug>/trip-book.html — keepsake. Atmospheric, editorial pacing, long-form narrative, destination hero.Each doc has its own reading order. See visual-specs/designs.md → "Per-doc reading order (v2 — three-doc split)" for the canonical list.
trip-itinerary.html:
cover-rail at top with doc-mark="Itinerary" + doc-desc="Bring this one with you · Day-by-day". No hero photo on this cover.book-data.time_primer.events), "Things to know this week" list.book-data.daily_plan. Each day carries: theme, weather band, timeline with per-slot alternatives, day-overview SVG map (inline from maps/day-<N>.svg), and — new in v2 — an inline field card embedded directly inside each named-place timeline slot. Field card rows: Address, Open today, Cost, Get there. See visual-specs/components.md → "Field card" for the component spec.emergency-rail panels: Emergency (911, poison control, non-emergency police, pediatric urgent care, walk-in clinic, 24-hr pharmacy) · Lodging · Transport. See visual-specs/components.md → "Back cover".trip-checklists.html:
cover-rail at top with doc-mark="Checklists" + doc-desc="Print this one · Cross off over time". Four urgency-bucket blocks stacked vertically: Weeks-ahead · Days-ahead · Night-before · Day-of-arrival. Each bucket shows its task list with a due-tag chip per task. No hero photo. B&W-safe throughout.page-break-before: always). Native <input type="checkbox">.trip-book.html (keepsake):
cover-rail at top with doc-mark="Keepsake" + doc-desc="Read this one ahead · Or leave it at the hotel". Large bordered hero image (from asset-manifest.hero_image, 58vh, 3px ink border, 4/4 accent offset shadow). Big-title (destination name) + italic-serif subtitle. Cover-meta + attribution row.book-data.primer. Neighborhoods sub-section renders one block per home-base region. The setting sub-section opens with the destination-level editorial map inlined from maps/destination.svg — atmospheric, no QR, no route, sparse overlay.book-data.featured_groups. Each place appears as a story card (narrative, history, hero imagery, cultural framing) — NOT a field card. One canonical story card per place; lens badges + layered-insight lines per lens. No in-field logistics (address, hours today, etc.) leak into story cards — that belongs to the itinerary's field card.book-data.dishes[] entry lists its served_at places as sub-cards referencing the canonical story cards.book-data.appendix[] place IDs — long-tail researched places that didn't make the Featured cut.15 imperative rules for this subagent pass. Rules marked [v2] are new or rewritten for the three-doc split; unmarked rules are inherited from v1 and apply uniformly across all three docs.
plan-trip run into <slug>/: trip-itinerary.html, trip-checklists.html, trip-book.html. Never emit a single combined document.<head>, own inline CSS, own cover). Never use <link rel="stylesheet"> pointing across docs; no shared JS file.href attribute targeting another doc in the set. Zero cross-file links — each doc must be usable by a reader who only has that one doc in hand. Grep the produced files before declaring done: grep -c 'href="trip-' must return 0 for each of the three files.<slug> on both; disjoint content. Never copy the keepsake's story card into the itinerary or vice versa.cover-rail pattern at top (doc-mark + doc-desc) so readers recognize which of the three docs they're holding. See visual-specs/copy.md → "Doc-rail pattern" for the exact strings per doc.visual-specs/components.md → "v2 — New components for three-doc split" → "Field card" for the component spec.emergency-rail panels: Emergency · Lodging · Transport). Never omit it — this is the "something went sideways" page.<Doc mark> · <Destination> · <Dates> · This document is one of three. Never use the footer to link to the other docs.color.accent.base (#d7362b) for section eyebrows, primary lens badges, page-refs, and route lines on day maps. Never substitute with a secondary brand hue — there is no secondary brand hue. Apply inherited tokens uniformly across all three docs.-0.03em to -0.045em) for display h2 and larger. Never use Inter below 700 for body content — use Source Serif 4 instead.page-break-before: always on each, so each checklist prints on its own page. Never use a multi-column checklist grid — this was rejected in v1 and the three-doc split doubles down on print-first checklists.Inherited rules that still apply (carried forward from v1, unchanged):
type field render using the matching pattern (dated → timeline row, recurring → weekly grid chip, festival → bordered mini-guide). Applies to the itinerary's time-specific primer (for dated events falling this week) AND to the keepsake's Featured Places → "Events this week" group.wikidata_id or with a failed P18 lookup render with data-variant="no-image" (no hero, typography-only). Applies uniformly across all three docs' place references.3px 3px 0 ink or 4px 4px 0 accent), never soft-blurred. Applies uniformly.visual-specs/tokens.json)#fafaf7 (base), #f2f1ec (alt — time-primer + what-to-eat), #ebebe8 (deep — quick-lookups)#0b0b0c (default), #2a2a2c (soft), #747479 (mute), #a8a8a4 (faint)#d7362b (base), #a6261c (dark), #fbe5e3 (soft — timeline-ref chips, today column, yes-chips)#dcdcd8Inter, 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif), Source Serif 4 (body, 'Source Serif 4', 'Source Serif Pro', Charter, 'Iowan Old Style', Georgia, serif)860px (primary reading column, page-w), 1080px (wide — featured/day/lookups/checklists, wide-w)17px serif; primer body 16.5px; card body 15pxclamp(120px, 18vw, 220px); h2-display 78px; h2-day 72px; feat-card-title 30px-0.045em (cover), -0.035em (tighter), -0.03em (display), 0.22em (eyebrow uppercase)0.82 (cover), 0.95 (display-2), 1.05 (tight), 1.55 (body), 1.65 (primer)--x1: 4px → --x11: 80px (see tokens.json for full scale)1px solid #dcdcd8 (rule), 1px solid #0b0b0c (ink), 2px solid #0b0b0c (ink-2), 3px solid #0b0b0c (ink-3), 3px solid #d7362b (accent-3)3px 3px 0 #0b0b0c (button), 4px 4px 0 #d7362b (menu/switcher)0 everywhere — all corners sharp by designEach card references its image via <img src="assets/<place-id>.jpg" alt="<place name>"> (relative path — relative to the trip directory). For places with asset-manifest.images[].status != "cached", render the card with data-variant="no-image" — no <img>, typography-only treatment.
Each image gets an attribution row below or within the card: Photo: <author> / Wikimedia Commons, <license> — styled per visual-specs/components.md (usually font-size: 9.5px, color: #747479).
Inline each <svg> from .myfootmarks/trips/<slug>/maps/ directly into the HTML. DO NOT link to external files. The trip book must be self-contained.
Day-overview maps (maps/day-<N>.svg) inline inside each day spread. Inset maps (maps/<place-id>.svg) inline inside each canonical place card that has a corresponding inset.
Apply the print-specific overrides from visual-specs/interactions.md: body white, accent collapses to #000, --accent-soft collapses to #eee. Checklists page-break-before. No page chrome (no top-nav, no state-switcher, no walkthrough SDK — none of those are rendered in production anyway).
v2 maps QR sizing (REQUIRED): include the following inside the document's @media print block so QR codes survive print + photocopy + phone-camera scan. Physical print size ≥ 2cm wide is non-negotiable.
@media print {
.qr-place { width: 2cm !important; height: 2cm !important; }
.qr-day { width: 2.5cm !important; height: 2.5cm !important; }
.qr-place text, .qr-day text { font-size: 7pt; }
}
#state-switcher, #top-nav) — prototype-only dev tool.<script src="…walkthrough.js"> or #walkthrough-panel) — prototype-only.data-screen/data-state selectors in CSS — those drive the prototype's state switcher, irrelevant to production.Approximately 2500–4500 lines of HTML across the three files combined (not per-file). The keepsake typically accounts for the largest share; checklists the smallest. Do not chase a line count — hit the IA and let the content determine length. No external asset references in any of the three files (images are local paths under assets/; SVGs are inline).
Write all three files: <slug>/trip-itinerary.html, <slug>/trip-checklists.html, <slug>/trip-book.html.
Self-validate each written file against visual-specs/rendering-checklist.md → "Self-validation contract":
Invalid Date, ISO timestamp pattern [0-9]{4}-[0-9]{2}-[0-9]{2}T, >undefined<, [object Object], >NaN). Any match is a hard fail for that file.-c (count) to verify per-doc required elements are present in the expected counts (e.g. grep -c '<div class="tl-item"' should equal grep -c '<div class="field-card"' for trip-itinerary.html). Full per-doc checks are listed in the rendering-checklist.trip-itinerary.html, walk paired <h2 class="day-title"> / <p class="day-narrative"> elements, and string-compare the text content. They MUST NOT be equal. This is the one check that is not pure Grep.On any check failure: describe which check failed (with offending lines / counts), re-emit the affected file with the fix, and re-run all checks for that file. Cap at 3 self-correction passes per file. On the 4th failed attempt, stop self-correcting and report the unresolved violations to the user; the run-log entry records "status": "validator_failed" with the violation list and Step 6's "ok" run-log entry is NOT written.
On clean validation across all three files: return a short status summary to the caller: "Stage 4 complete: itinerary <N1> lines, checklists <N2> lines, keepsake <N3> lines, <M KB total>; validator passed."
Append to .myfootmarks/trips/<slug>/runs.jsonl a single line with stage-by-stage status:
{"timestamp": "<ISO-8601>", "skill": "build-trip-book", "slug": "<slug>", "status": "ok", "stages": {"normalize": "ran" | "skipped" | "error", "fetch-assets": "...", "generate-maps": "...", "render-html": "..."}, "outputs_written": ["<slug>/trip-itinerary.html", "<slug>/trip-checklists.html", "<slug>/trip-book.html"]}
On error in any stage, log "status": "error", "reason": "<short message>", and include the failing stage in the stages object. Do not proceed to downstream stages.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub escapevelocitylabs/myfootmarks --plugin myfootmarks