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()