From 3cd705f7e6348b664c3a8800814f0b59ac250cfd Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Mon, 10 Aug 2026 20:01:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(skill):=20travel-guide=20section-end=20foo?= =?UTF-8?q?ters=20=E2=80=94=20field=20notes,=20next-up,=20ghost=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- travel-guide/README.md | 2 +- travel-guide/SKILL.md | 8 +- .../references/editorial-structure.md | 9 ++- travel-guide/references/pdf-rendering.md | 4 + travel-guide/scripts/render-travel-guide.py | 77 ++++++++++++++++++- travel-guide/styles/travel-dossier.css | 17 ++++ travel-guide/tests/test_scripts.py | 35 +++++++++ 7 files changed, 147 insertions(+), 5 deletions(-) diff --git a/travel-guide/README.md b/travel-guide/README.md index 2e314b7..7ab2387 100644 --- a/travel-guide/README.md +++ b/travel-guide/README.md @@ -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 diff --git a/travel-guide/SKILL.md b/travel-guide/SKILL.md index eff98df..dfc5423 100644 --- a/travel-guide/SKILL.md +++ b/travel-guide/SKILL.md @@ -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 diff --git a/travel-guide/references/editorial-structure.md b/travel-guide/references/editorial-structure.md index 922b50c..0c84cde 100644 --- a/travel-guide/references/editorial-structure.md +++ b/travel-guide/references/editorial-structure.md @@ -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 diff --git a/travel-guide/references/pdf-rendering.md b/travel-guide/references/pdf-rendering.md index a15be5e..4b00636 100644 --- a/travel-guide/references/pdf-rendering.md +++ b/travel-guide/references/pdf-rendering.md @@ -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; diff --git a/travel-guide/scripts/render-travel-guide.py b/travel-guide/scripts/render-travel-guide.py index 3996695..199305c 100755 --- a/travel-guide/scripts/render-travel-guide.py +++ b/travel-guide/scripts/render-travel-guide.py @@ -148,6 +148,70 @@ def render_meters(brief): return '
%s
' % "".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 = '%s%s' % ( + 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 = ('Next: %s — %s' + '%s') % (esc(kicker), esc(heading), num) + else: + next_html = 'End of dossier' + mark = render_mark() + watermark = '' % mark if mark else "" + return '\n%s' % (note_html, next_html, watermark) + + +def inject_footer(section_html, section, brief): + footer_html = section_footer(section, brief) + index = section_html.rfind("") + 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 = '' diff --git a/travel-guide/styles/travel-dossier.css b/travel-guide/styles/travel-dossier.css index b352e18..0d93478 100644 --- a/travel-guide/styles/travel-dossier.css +++ b/travel-guide/styles/travel-dossier.css @@ -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; } diff --git a/travel-guide/tests/test_scripts.py b/travel-guide/tests/test_scripts.py index 8faa27d..c6d97ed 100755 --- a/travel-guide/tests/test_scripts.py +++ b/travel-guide/tests/test_scripts.py @@ -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()