feat(skill): travel-guide section-end footers — field notes, next-up, ghost mark

Fills the white space between sections with a bottom-of-page footer per
section: a content-derived field note (first anchor failure mode, first day
alternative, practical recheck item, or first skip reason) when one exists, a
next-section line with the following section's number, and a faint ghost
route mark. Footers hug the page bottom via flex column + margin-top auto;
multi-page sections carry the footer at the end of the section. Sheets fill
the print page so the footer lands at the bottom instead of floating.

Field notes repeat model content in one line and never invent new plans;
sections with nothing worth saying render the next-up line only. QA gate,
editorial reference, SKILL.md, and README updated; test suite extended to
cover footer presence, next-section wiring, and field-note content.

AI assistance: implementation and tests drafted by Jasper (Hermes Agent),
design reviewed and approved by Magnus Hedemark.
This commit is contained in:
Magnus Hedemark
2026-08-10 20:01:17 -04:00
parent b87d18e15f
commit 3cd705f7e6
7 changed files with 147 additions and 5 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ Turn a destination, a real traveler, and a few constraints into a considered tra
Most itinerary tools optimize for coverage. This skill helps an agent design for fit: the pace, people, budget, interests, energy, and small details that make a trip feel like it belongs to the travelers.
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. The visual system is editorial by default: a darkened photographic cover with a route journey line, ghost section numbers, a color-coded day strip, pace and budget meters, and a unified warm photo grade across anchor photos. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
It can produce a print-ready HTML dossier for PDF conversion, a responsive companion page, or both. The visual system is editorial by default: a darkened photographic cover with a route journey line, ghost section numbers, a color-coded day strip, pace and budget meters, a unified warm photo grade across anchor photos, and a bottom-of-page footer on each section that carries a field note, a next-section line, and a ghost route mark. It keeps current logistics and recommendations tied to sources, and it can create a sanitized edition for sharing without exposing exact dates, lodging, booking identifiers, or private notes.
## What You Get
+6 -2
View File
@@ -192,8 +192,12 @@ The visual default is a dark photographic cover with a route journey line, warm
gold eyebrow, white headline, restrained red accent, generous white content
pages, ghost section numbers, a color-coded day strip right after the brief,
pace and budget meters, compact cards, a unified warm photo grade on anchor
images, and readable tables. Preserve contrast and selectable text. Do not let
decoration hide uncertainty or practical caveats.
images, readable tables, and a bottom-of-page footer per section: a content-
derived field note (failure mode, plan B, recheck item, or skip reason) when
one exists, a next-section line, and a ghost route mark. Preserve contrast and
selectable text. Do not let decoration hide uncertainty or practical caveats.
The footer is informational, never a schedule: it repeats model content in one
line, it does not invent new plans.
## Exit criteria
@@ -90,6 +90,10 @@ The default visual language is editorial rather than app-like:
- pace and budget meters in the brief when the trip model supplies them;
- a unified warm photo grade on anchor images so mixed-source photos read as
one editorial set;
- a bottom-of-page footer per section: a content-derived field note (the first
anchor's failure mode, the first day's alternative, the practical recheck
item, or the first skip reason) when one exists, a next-section line, and a
ghost route mark;
- dark table headers with clear column labels;
- short captions and visible image credits.
@@ -97,7 +101,10 @@ Use images to establish place and texture, not to imply that an image proves a
recommendation. Keep body text selectable and readable in grayscale or with
high-contrast settings. Every meaningful image needs useful alternative text.
The day strip is a glanceable overview, not a schedule: it must never invent a
timed plan that the day cards do not support.
timed plan that the day cards do not support. The section footer is the same
kind of restraint: it repeats one line already in the model rather than adding
new recommendations, and a section with nothing worth saying simply omits the
field note.
## Companion web page
+4
View File
@@ -67,6 +67,10 @@ Before delivery, verify all of the following:
- the day strip ("trip at a glance") shows one card per day with legible kind
colors, and the meters render when pace or budget are supplied;
- ghost section numbers do not collide with content;
- each section footer sits at the bottom of its page without colliding with
content; the field note repeats a model line (failure mode, alternative,
recheck, or skip reason) and the next-section line matches the section that
actually follows;
- no page is blank, clipped, or unexpectedly split;
- title, tables, captions, and source URLs are readable;
- contrast works on the dark cover and in grayscale content pages;
+76 -1
View File
@@ -148,6 +148,70 @@ def render_meters(brief):
return '<div class="trip-meters">%s</div>' % "".join(parts)
SECTION_ORDER = ["brief", "glance", "anchors", "days", "special", "skip", "practical", "sources"]
SECTION_HEADINGS = {
"brief": ("The brief", "Why this trip, now?"),
"glance": ("Trip at a glance", "The whole trip, one glance."),
"anchors": ("The anchors", "Protect the good parts."),
"days": ("Day architecture", "Enough shape to wander."),
"special": ("Make it special", "Make it special."),
"skip": ("A useful no", "Skip this."),
"practical": ("Field notes", "Keep the friction small."),
"sources": ("Evidence and freshness", "Sources."),
}
def field_note(section, brief):
"""One content-derived line for the section footer; None when nothing fits."""
if section == "anchors":
first = next((a for a in brief.get("anchors", []) if isinstance(a, dict)), None)
if first and first.get("failure_mode"):
return ("If it goes wrong", first["failure_mode"])
if section == "days":
first = next((d for d in brief.get("days", []) if isinstance(d, dict)), None)
if first and first.get("alternative"):
return ("Plan B", first["alternative"])
if section == "practical":
for item in brief.get("practical", []):
if isinstance(item, dict) and "recheck" in str(item.get("label", "")).lower() and item.get("value"):
return ("Recheck before departure", item["value"])
if section == "skip":
first = next((s for s in brief.get("skip", []) if isinstance(s, dict)), None)
if first and first.get("reason"):
return ("Why we skip it", first["reason"])
return None
def section_footer(section, brief):
"""Bottom-of-page footer: field note, next-section line, and a ghost mark."""
note = field_note(section, brief)
note_html = ""
if note:
note_html = '<span class="fn-label">%s</span><span class="fn-text">%s</span>' % (
esc(note[0]), esc(note[1]))
index = SECTION_ORDER.index(section)
next_html = ""
if index < len(SECTION_ORDER) - 1:
next_name = SECTION_ORDER[index + 1]
kicker, heading = SECTION_HEADINGS[next_name]
num = "%02d" % (index + 2)
next_html = ('<span class="next-up">Next: <span class="next-kicker">%s</span> — %s'
'<span class="next-num">%s</span></span>') % (esc(kicker), esc(heading), num)
else:
next_html = '<span class="next-up"><span class="next-kicker">End of dossier</span></span>'
mark = render_mark()
watermark = '<div class="route-watermark" aria-hidden="true">%s</div>' % mark if mark else ""
return '<div class="section-footer">%s%s</div>\n%s' % (note_html, next_html, watermark)
def inject_footer(section_html, section, brief):
footer_html = section_footer(section, brief)
index = section_html.rfind("</section>")
if index == -1:
return section_html + "\n" + footer_html
return section_html[:index] + "\n" + footer_html + "\n" + section_html[index:]
def render_cover(brief, base_dir, warnings):
trip = brief.get("trip", {})
cover = brief.get("cover", {})
@@ -409,7 +473,18 @@ def render_sources(brief):
def render_body(brief, base_dir, warnings, mode):
body = [render_cover(brief, base_dir, warnings), render_brief(brief), render_glance(brief), render_anchors(brief, base_dir, warnings), render_days(brief), render_special(brief), render_skip(brief), render_practical(brief), render_sources(brief)]
sections = [
("brief", render_brief(brief)),
("glance", render_glance(brief)),
("anchors", render_anchors(brief, base_dir, warnings)),
("days", render_days(brief)),
("special", render_special(brief)),
("skip", render_skip(brief)),
("practical", render_practical(brief)),
("sources", render_sources(brief)),
]
body = [render_cover(brief, base_dir, warnings)]
body.extend(inject_footer(html, name, brief) for name, html in sections)
nav = ""
if mode == "companion":
nav = '<nav class="companion-nav" aria-label="Guide sections"><a href="#brief">Brief</a><a href="#glance">At a glance</a><a href="#anchors">Anchors</a><a href="#days">Days</a><a href="#special">Special</a><a href="#practical">Field notes</a><a href="#sources">Sources</a></nav>'
+17
View File
@@ -93,6 +93,21 @@ p { line-height: 1.52; }
.glance-day small { display: block; color: var(--muted); font-size: .78rem; line-height: 1.35; }
.glance-note { margin: .8rem 0 0; font-size: .78rem; }
body.dossier .sheet { display: flex; flex-direction: column; }
.section-footer {
margin-top: auto; padding-top: 1.1rem;
border-top: 1px solid var(--line);
display: flex; align-items: baseline; gap: 1.2rem; flex-wrap: wrap;
font-size: .82rem;
}
.fn-label { color: var(--signal); font-weight: 800; font-size: .62rem; letter-spacing: .12em; text-transform: uppercase; }
.fn-text { color: #4e5358; }
.next-up { margin-left: auto; color: var(--muted); white-space: nowrap; }
.next-kicker { color: var(--signal); font-weight: 700; }
.next-num { margin-left: .5rem; color: rgba(23, 25, 27, .14); font-size: 1.7rem; font-weight: 800; line-height: 1; }
.route-watermark { position: absolute; right: 1.6rem; bottom: 1.4rem; width: 12rem; opacity: .07; pointer-events: none; }
.route-watermark svg { width: 100%; height: auto; }
.anchor-list { display: grid; gap: 1.3rem; }
.anchor-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.1rem; padding: 1.25rem 0; border-top: 1px solid var(--line); break-inside: avoid; }
.anchor-card.has-image { grid-template-columns: 11rem minmax(0, 1fr); }
@@ -139,6 +154,8 @@ p { line-height: 1.52; }
.sheet { max-width: none; min-height: 0; padding: .65in .72in; }
.cover-inner { padding: .75in .72in; }
.sheet, .page-break { break-before: page; }
body.dossier .sheet { min-height: 10.9in; }
.section-footer, .route-watermark { break-inside: avoid; }
.companion-nav { display: none; }
a { color: inherit; }
.cover-stat, .anchor-card, .day-card, .callout, .skip-card { break-inside: avoid; }
+35
View File
@@ -178,6 +178,41 @@ class TravelGuideScriptsTest(unittest.TestCase):
payload = json.loads(result.stdout)
self.assertTrue(any("kind" in warning for warning in payload["warnings"]))
def test_section_footer_shows_next_section_and_watermark(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
brief.write_text(json.dumps(self._filled_brief()), encoding="utf-8")
output = directory / "dossier.html"
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rendered = output.read_text(encoding="utf-8")
self.assertIn('class="section-footer"', rendered)
self.assertIn("Next:", rendered)
self.assertIn("Trip at a glance", rendered)
self.assertIn('class="route-watermark"', rendered)
self.assertIn("End of dossier", rendered)
def test_section_footer_field_notes_repeat_model_lines(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
brief = directory / "brief.json"
data = self._filled_brief()
data["anchors"][0]["failure_mode"] = "Rain sends the walk indoors."
data["days"][0]["alternative"] = "A nearby café and an early night."
data["practical"] = [{"label": "Recheck before departure", "value": "Museum hours change seasonally.", "source_ids": ["S1"]}]
brief.write_text(json.dumps(data), encoding="utf-8")
output = directory / "dossier.html"
result = run_script("render-travel-guide.py", brief, "--output", output, "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rendered = output.read_text(encoding="utf-8")
self.assertIn("If it goes wrong", rendered)
self.assertIn("Rain sends the walk indoors.", rendered)
self.assertIn("Plan B", rendered)
self.assertIn("A nearby café and an early night.", rendered)
self.assertIn("Recheck before departure", rendered)
self.assertIn("Museum hours change seasonally.", rendered)
if __name__ == "__main__":
unittest.main()