mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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:
@@ -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.
|
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
|
## What You Get
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
gold eyebrow, white headline, restrained red accent, generous white content
|
||||||
pages, ghost section numbers, a color-coded day strip right after the brief,
|
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
|
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
|
images, readable tables, and a bottom-of-page footer per section: a content-
|
||||||
decoration hide uncertainty or practical caveats.
|
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
|
## 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;
|
- 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
|
- a unified warm photo grade on anchor images so mixed-source photos read as
|
||||||
one editorial set;
|
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;
|
- dark table headers with clear column labels;
|
||||||
- short captions and visible image credits.
|
- 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
|
recommendation. Keep body text selectable and readable in grayscale or with
|
||||||
high-contrast settings. Every meaningful image needs useful alternative text.
|
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
|
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
|
## Companion web page
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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;
|
colors, and the meters render when pace or budget are supplied;
|
||||||
- ghost section numbers do not collide with content;
|
- 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;
|
- no page is blank, clipped, or unexpectedly split;
|
||||||
- title, tables, captions, and source URLs are readable;
|
- title, tables, captions, and source URLs are readable;
|
||||||
- contrast works on the dark cover and in grayscale content pages;
|
- contrast works on the dark cover and in grayscale content pages;
|
||||||
|
|||||||
@@ -148,6 +148,70 @@ def render_meters(brief):
|
|||||||
return '<div class="trip-meters">%s</div>' % "".join(parts)
|
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):
|
def render_cover(brief, base_dir, warnings):
|
||||||
trip = brief.get("trip", {})
|
trip = brief.get("trip", {})
|
||||||
cover = brief.get("cover", {})
|
cover = brief.get("cover", {})
|
||||||
@@ -409,7 +473,18 @@ def render_sources(brief):
|
|||||||
|
|
||||||
|
|
||||||
def render_body(brief, base_dir, warnings, mode):
|
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 = ""
|
nav = ""
|
||||||
if mode == "companion":
|
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>'
|
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>'
|
||||||
|
|||||||
@@ -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-day small { display: block; color: var(--muted); font-size: .78rem; line-height: 1.35; }
|
||||||
.glance-note { margin: .8rem 0 0; font-size: .78rem; }
|
.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-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 { 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); }
|
.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; }
|
.sheet { max-width: none; min-height: 0; padding: .65in .72in; }
|
||||||
.cover-inner { padding: .75in .72in; }
|
.cover-inner { padding: .75in .72in; }
|
||||||
.sheet, .page-break { break-before: page; }
|
.sheet, .page-break { break-before: page; }
|
||||||
|
body.dossier .sheet { min-height: 10.9in; }
|
||||||
|
.section-footer, .route-watermark { break-inside: avoid; }
|
||||||
.companion-nav { display: none; }
|
.companion-nav { display: none; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
.cover-stat, .anchor-card, .day-card, .callout, .skip-card { break-inside: avoid; }
|
.cover-stat, .anchor-card, .day-card, .callout, .skip-card { break-inside: avoid; }
|
||||||
|
|||||||
@@ -178,6 +178,41 @@ class TravelGuideScriptsTest(unittest.TestCase):
|
|||||||
payload = json.loads(result.stdout)
|
payload = json.loads(result.stdout)
|
||||||
self.assertTrue(any("kind" in warning for warning in payload["warnings"]))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user