tripkit 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to TripKit are documented here. Versioning follows [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.4.0] — 2026-06-27
6
+
7
+ ### Added
8
+ - **Post-trip media — interactive map mode.** Attach a trip's real geotagged photos/videos to stops and see them on the map.
9
+ - **`tripkit media <folder> <trip.yaml>`** — scans a media folder, reads EXIF GPS + timestamps (`exifr`), auto-matches each item to the nearest stop on the matching day (haversine, day-gated by photo date), optionally generates thumbnails (`sharp`, optional dependency — gracefully skipped if absent), and writes a `<trip>.media-review.yaml` handoff file. Low-confidence / geotag-less / too-far items go to an `unmatched:` bucket for review.
10
+ - **`tripkit media apply <review.yaml> <trip.yaml>`** — merges the reviewed/captioned media into `stops[].media[]`. Idempotent.
11
+ - **Schema:** new optional `stops[].media[]` (also valid on `lodging`): `{ src, type: photo|video, thumb?, caption?, lat?, lng?, taken_at? }`. `src` is a relative path **or** a URL, so output can stay a shareable single file. Purely additive — stops without media render exactly as before.
12
+ - **Renderer:** stop cards and map popups show a thumbnail strip + a `📷 N` count badge; **stop markers with media get a corner `📷 N` badge that opens the lightbox directly** (the marker body still opens the normal popup); a full-screen **lightbox** (keyboard ←/→/Esc, click, swipe; inline video playback) opens from any thumbnail; a toggleable **photo-pin map layer** plots every media item that carries its own EXIF GPS, distinct from the day-numbered stop markers.
13
+ - **Validation:** `tripkit validate` checks each media item has a `src` and valid `type`, and warns when a media item's GPS is >25 mi from its stop (likely a wrong match).
14
+ - **Agent skill:** new Phase 5 documents the post-trip ingest → review → apply flow, with the agent owning the captioning/match-fixing review step.
15
+ - **Guide:** `docs/MEDIA-GUIDE.md` — end-to-end walkthrough (CLI steps, the review-file format field by field, AI-agent orchestration, the `media` schema, URLs vs local files, troubleshooting, command reference). Linked from the README and the agent skill.
16
+ - Dependencies: `exifr` (required for ingest) added to `dependencies`; `sharp` (thumbnails) added to `optionalDependencies`.
17
+
18
+ ### Fixed
19
+ - **Lightbox close button.** The full-width photo counter (`1 / 2`) overlay was intercepting clicks aimed at the `×` button, so closing by click failed (Esc still worked). The counter is now click-through and the close button sits above it.
20
+ - **Origin/destination pins now connect to the route.** Auto-generated polylines (no explicit `routes[]`) now anchor Day 1 to `trip.origin` and the final day back to the destination (or origin, for a round trip). Previously the green "A" Start pin floated unattached — most visible when the last day's lodging is `name: "Home"` at the origin, which the route generator excludes from geometry.
21
+
22
+ ## [1.3.0] — 2026-04-30
23
+
24
+ ### Added
25
+ - GitHub Actions CI: runs `npm test` (validator + skill-coverage gate) and validates every YAML in `examples/` on Node 18/20/22 for every push and PR to `main`.
26
+ - **Claude Code skill bundle.** `agent/SKILL.md` now ships with `name`/`description` YAML frontmatter so it discovers as a proper Claude Code skill. New `npx tripkit install-skill` command copies the skill to `~/.claude/skills/tripkit/` (or `--project` for `.claude/skills/`); after install, invoke with `/skills tripkit`.
27
+ - **Dark mode.** `theme.dark_mode: true` now applies a `data-theme="dark"` attribute to the document and overrides the full color system: warm near-black background, lifted accent green, translucent callout tints, dark Leaflet popups. Stop badges, callouts, and chrome all theme automatically.
28
+ - **Day-status visual treatment.** The `status: completed | active | upcoming` field on each day is now reflected in the day-nav: completed days are muted, the day flagged `active` (today) gets a small accent dot. Selecting a muted day still highlights it fully.
29
+
30
+ ### Changed
31
+ - Renamed `agent/AGENT-SKILL.md` → `agent/SKILL.md` to match Claude Code's skill convention. The body is unchanged and still agent-agnostic; the new frontmatter is ignored by non-Claude tooling.
32
+ - Renderer color system refactored to CSS variables. Stop badge colors, callout backgrounds, and shadows are all theme-aware via `--b-*-bg/fg`, `--*-soft`, and `--shadow*` variables — single source of truth for both light and dark modes.
33
+ - Stop cards lift on hover (subtle shadow + 1px translate) for a more tactile feel.
34
+ - Removed unused `--coral` variable (was a duplicate of `--warn`).
35
+
5
36
  ## [1.2.0] — 2026-04-30
6
37
 
7
38
  ### Added
package/README.md CHANGED
@@ -75,10 +75,31 @@ npx tripkit validate my-trip.yaml
75
75
 
76
76
  It checks required fields, lat/lng ranges, valid stop types, hex colors, day numbering, and warns when `trip.total_stops` doesn't match the actual count. Validation also runs automatically before each render — pass `--no-validate` to skip it.
77
77
 
78
+ ### Add your trip photos (after the trip)
79
+
80
+ Turn a folder of geotagged photos/videos into an interactive map where each stop shows what you actually captured:
81
+
82
+ ```bash
83
+ npx tripkit media ./my-photos my-trip.yaml # 1. EXIF-match photos to stops → writes my-trip.media-review.yaml
84
+ # edit captions / fix any mismatches in the review file
85
+ npx tripkit media apply my-trip.media-review.yaml my-trip.yaml # 2. merge into the trip
86
+ npx tripkit my-trip.yaml my-trip.html # 3. re-render with galleries
87
+ ```
88
+
89
+ Step 1 reads EXIF GPS + timestamps and auto-matches each photo to the nearest stop on the matching day. Items it can't place confidently (no GPS, too far from any stop) land in an `unmatched:` bucket you can fix by hand — or hand to an AI agent to caption and place. Thumbnails are generated automatically if [`sharp`](https://www.npmjs.com/package/sharp) is installed (optional). In the rendered page, stops gain a photo strip + count badge, a full-screen lightbox (arrow keys / swipe, inline video), and a toggleable map layer pinning every geotagged shot.
90
+
91
+ `src` values can be relative paths (the default) **or** URLs — use URLs if you want the output to stay a single shareable HTML file.
92
+
93
+ 📖 **Full walkthrough:** [docs/MEDIA-GUIDE.md](docs/MEDIA-GUIDE.md) — the review-file format, how matching works, letting an AI agent caption and place your photos, and troubleshooting.
94
+
78
95
  ### Option 3: With an AI Agent (everyone)
79
96
 
80
97
  1. Start a conversation with your preferred AI agent (Claude recommended)
81
- 2. Share the `agent/AGENT-SKILL.md` as context
98
+ 2. Share the `agent/SKILL.md` as context — or, if you're using **Claude Code**, install it as a skill (one-time):
99
+ ```bash
100
+ npx tripkit install-skill # installs to ~/.claude/skills/tripkit/
101
+ ```
102
+ then invoke with `/skills tripkit` in any conversation.
82
103
  3. Tell it about your trip: _"I'm planning a 5-day road trip through the Pacific Northwest with my family..."_
83
104
  4. The agent uses the questionnaire to gather details, researches routes/hotels/restaurants, and generates a TripKit YAML file
84
105
  5. Convert to HTML with `npx tripkit your-trip.yaml`, or ask the agent to render it directly
@@ -108,7 +129,7 @@ tripkit/
108
129
  │ └── (coming soon) # React component library
109
130
  ├── agent/
110
131
  │ ├── questionnaire.yaml # Elicitation template
111
- │ └── AGENT-SKILL.md # System prompt for AI agents
132
+ │ └── SKILL.md # System prompt + Claude Code skill bundle
112
133
  ├── docs/
113
134
  │ └── screenshot.png
114
135
  ├── convert.js # YAML → HTML CLI tool
@@ -152,6 +173,13 @@ See `schema/tripkit.schema.yaml` for the complete specification.
152
173
  - Click a day → map zooms to that segment
153
174
  - Click a stop → map zooms and opens popup
154
175
 
176
+ ### Trip Photos & Videos
177
+ - `tripkit media` ingests a folder of geotagged photos/videos and EXIF-matches them to stops
178
+ - Per-stop photo strip + count badge in cards and map popups
179
+ - Full-screen lightbox: arrow keys / swipe to navigate, inline video playback
180
+ - Toggleable map layer that pins every geotagged shot at its own location
181
+ - Photos stay as relative paths or URLs — the output can still be a single shareable file
182
+
155
183
  ### Day-by-Day Sidebar
156
184
  - Weather callouts with forecasts
157
185
  - Alert banners (road closures, permit requirements, age restrictions)
@@ -1,6 +1,11 @@
1
+ ---
2
+ name: tripkit
3
+ description: Plan road trips and itineraries that render as interactive map visualizations. Produces YAML conforming to the TripKit schema, validates it with `tripkit validate`, and renders to a self-contained HTML file via `npx tripkit`.
4
+ ---
5
+
1
6
  # TripKit Agent Skill
2
7
 
3
- System prompt / skill for AI agents that plan trips using the TripKit framework. Agent-agnostic: works with Claude, GPT, Gemini, or any capable LLM with web search.
8
+ System prompt / skill for AI agents that plan trips using the TripKit framework. The YAML frontmatter above is for Claude Code's `/skills` discovery; the body itself is agent-agnostic works with Claude, GPT, Gemini, or any capable LLM with web search.
4
9
 
5
10
  ## Role
6
11
 
@@ -36,6 +41,17 @@ The plan WILL change. Track every change in `agent_context.iteration_log` with d
36
41
  - **Real-time adaptation** — weather changes, road closures, fatigue.
37
42
  - **Schedule conflicts** — work meetings, reservations.
38
43
 
44
+ ### Phase 5: Post-trip media (optional)
45
+ After the trip, the traveler can attach their real geotagged photos/videos to stops so the map shows what they actually saw. This is a **review-in-the-middle** flow — your job is the "review" step:
46
+
47
+ 1. **Build** — the user runs `tripkit media <media-folder> <trip>.yaml`. This reads EXIF GPS + timestamps, auto-matches each item to the nearest stop on the matching day (haversine), generates thumbnails if `sharp` is installed, and writes `<trip>.media-review.yaml`. Items with no GPS, no stops on the day, or a nearest stop too far away land in an `unmatched:` bucket.
48
+ 2. **Review (your role)** — open the review file. For each item: write a short, specific `caption` (what it shows, not "photo 1"). Fix any mis-matched `stop_index`. For `unmatched` items, reason from the filename, `taken_at`, and the trip plan to assign a `day` + `stop_index` — or leave them unmatched if genuinely unplaceable. Use the `suggested_stop`/`reason` hints.
49
+ 3. **Apply** — the user runs `tripkit media apply <trip>.media-review.yaml <trip>.yaml` to merge the reviewed items into `stops[].media[]`, then re-renders. Apply is idempotent (re-running won't duplicate).
50
+
51
+ Captions are the high-value artifact here — they're what the lightbox shows. Keep them concise and grounded in what's actually visible.
52
+
53
+ Full reference (review-file format, matching rules, edge cases): `docs/MEDIA-GUIDE.md`.
54
+
39
55
  ## Schema Reference (must match `schema/tripkit.schema.yaml`)
40
56
 
41
57
  ### `trip` block
@@ -69,6 +85,7 @@ Each day:
69
85
  - `reservation_required: bool` + `reservation_url` — for permit/timed-entry stops.
70
86
  - `image` — Unsplash or other URL. If omitted, the renderer falls back to a type-specific default.
71
87
  - `navigate_url` — Google Maps directions link.
88
+ - `media: [...]` — **post-trip only.** A gallery of the traveler's actual photos/videos at this stop. Usually populated by the `tripkit media` ingest flow (see Phase 5), not hand-authored during planning. Each item: `{ src, type: photo|video, thumb?, caption?, lat?, lng?, taken_at? }`. `src` is a relative path or URL. Items with `lat`/`lng` also drive the renderer's toggleable photo-pin map layer.
72
89
 
73
90
  ### `routes[]` block (optional but strongly recommended for road trips)
74
91
  Each entry:
package/convert.js CHANGED
@@ -29,6 +29,13 @@ TripKit — AI-friendly trip planning toolkit
29
29
  Usage:
30
30
  ${invokedAs} <trip.yaml> [output.html] Render YAML to interactive HTML
31
31
  ${invokedAs} validate <trip.yaml> Check a trip YAML for schema errors
32
+ ${invokedAs} media <folder> <trip.yaml> Geo-match a folder of trip photos/videos to
33
+ stops and write <trip>.media-review.yaml
34
+ ${invokedAs} media apply <review.yaml> <trip.yaml>
35
+ Merge a reviewed media file into the trip YAML
36
+ ${invokedAs} install-skill [--project] Install the agent skill for Claude Code
37
+ (default: ~/.claude/skills/tripkit/;
38
+ --project: ./.claude/skills/tripkit/)
32
39
 
33
40
  Flags:
34
41
  -h, --help Show this help
@@ -76,6 +83,23 @@ function reportFindings(errors, warnings) {
76
83
  });
77
84
  }
78
85
 
86
+ // === SUBCOMMAND: install-skill ===
87
+ if (args[0] === 'install-skill') {
88
+ const projectScope = args.includes('--project');
89
+ const dest = projectScope
90
+ ? path.join(process.cwd(), '.claude', 'skills', 'tripkit')
91
+ : path.join(require('os').homedir(), '.claude', 'skills', 'tripkit');
92
+ const src = path.join(__dirname, 'agent');
93
+
94
+ fs.mkdirSync(dest, { recursive: true });
95
+ for (const f of fs.readdirSync(src)) {
96
+ fs.copyFileSync(path.join(src, f), path.join(dest, f));
97
+ }
98
+ console.log(c.green('✓') + ` Installed TripKit skill to ${dest}`);
99
+ console.log(c.dim(` Invoke in Claude Code with: /skills tripkit`));
100
+ process.exit(0);
101
+ }
102
+
79
103
  // === SUBCOMMAND: validate ===
80
104
  if (args[0] === 'validate') {
81
105
  const inputFile = args[1];
@@ -96,6 +120,45 @@ if (args[0] === 'validate') {
96
120
  process.exit(errors.length > 0 ? 1 : 0);
97
121
  }
98
122
 
123
+ // === SUBCOMMAND: media (ingest geotagged photos/videos) ===
124
+ if (args[0] === 'media') {
125
+ const io = {
126
+ log: (m) => console.log(m),
127
+ warn: (m) => console.error(` ${c.yellow('⚠')} ${m}`),
128
+ c,
129
+ };
130
+ const { buildReview, applyReview } = require('./media-ingest');
131
+
132
+ (async () => {
133
+ try {
134
+ if (args[1] === 'apply') {
135
+ const reviewFile = args[2];
136
+ const tripFile = args[3];
137
+ if (!reviewFile || !tripFile) {
138
+ console.error(c.red('Usage: ') + `${invokedAs} media apply <review.yaml> <trip.yaml>`);
139
+ process.exit(1);
140
+ }
141
+ if (!fs.existsSync(reviewFile)) { console.error(c.red(`Error: file not found: ${reviewFile}`)); process.exit(1); }
142
+ if (!fs.existsSync(tripFile)) { console.error(c.red(`Error: file not found: ${tripFile}`)); process.exit(1); }
143
+ await applyReview(reviewFile, tripFile, io);
144
+ } else {
145
+ const folder = args[1];
146
+ const tripFile = args[2];
147
+ if (!folder || !tripFile) {
148
+ console.error(c.red('Usage: ') + `${invokedAs} media <folder> <trip.yaml>`);
149
+ process.exit(1);
150
+ }
151
+ if (!fs.existsSync(tripFile)) { console.error(c.red(`Error: file not found: ${tripFile}`)); process.exit(1); }
152
+ await buildReview(folder, tripFile, io);
153
+ }
154
+ } catch (e) {
155
+ console.error(c.red('✖ ') + e.message);
156
+ process.exit(1);
157
+ }
158
+ })();
159
+ return;
160
+ }
161
+
99
162
  // === DEFAULT: render ===
100
163
  const skipValidate = args.includes('--no-validate');
101
164
  const positional = args.filter(a => !a.startsWith('-'));
@@ -0,0 +1,298 @@
1
+ # TripKit Media Guide
2
+
3
+ Turn the photos and videos from a finished trip into an interactive map where every
4
+ stop shows what you actually captured there — and an optional, agent-assisted workflow
5
+ for captioning and fixing matches.
6
+
7
+ This guide covers:
8
+
9
+ 1. [What you get](#what-you-get)
10
+ 2. [Quick start](#quick-start)
11
+ 3. [How matching works](#how-matching-works)
12
+ 4. [The review file, field by field](#the-review-file-field-by-field)
13
+ 5. [Letting an AI agent do the review](#letting-an-ai-agent-do-the-review)
14
+ 6. [The `media` schema](#the-media-schema)
15
+ 7. [Using URLs instead of local files](#using-urls-instead-of-local-files)
16
+ 8. [Troubleshooting](#troubleshooting)
17
+ 9. [Command reference](#command-reference)
18
+
19
+ ---
20
+
21
+ ## What you get
22
+
23
+ Once media is attached to a trip, the rendered HTML gains:
24
+
25
+ - **Per-stop galleries** — a thumbnail strip and a `📷 N` count badge on each stop card and map popup.
26
+ - **A marker cue** — stops with media show a `📷 N` badge on their map marker; click it to jump straight to the photos.
27
+ - **A full-screen lightbox** — arrow keys / swipe / click to navigate, `Esc` to close, inline video playback, with each item's caption and capture time/GPS.
28
+ - **A photo-pin layer** — a toggleable map layer (the **📷 Photos** button, top-right) that drops a pin at the exact GPS of every geotagged item, separate from the day-numbered stop markers.
29
+
30
+ Stops without media render exactly as before — everything here is additive and optional.
31
+
32
+ ---
33
+
34
+ ## Quick start
35
+
36
+ You need: a finished `trip.yaml` (the same file the renderer already uses) and a folder of
37
+ photos/videos from the trip. Photos with EXIF GPS + timestamps match automatically; others
38
+ can be placed by hand (or by an agent) in the review step.
39
+
40
+ ```bash
41
+ # 1. Match your media folder to the trip's stops.
42
+ # Writes trip.media-review.yaml — nothing in trip.yaml changes yet.
43
+ npx tripkit media ./my-photos trip.yaml
44
+
45
+ # 2. Open trip.media-review.yaml. Add captions, fix any wrong matches,
46
+ # and place the items under "unmatched" that you want to keep.
47
+
48
+ # 3. Merge the reviewed media into the trip.
49
+ npx tripkit media apply trip.media-review.yaml trip.yaml
50
+
51
+ # 4. Re-render. The map now shows your photos.
52
+ npx tripkit trip.yaml trip.html
53
+ open trip.html
54
+ ```
55
+
56
+ > Running from a git clone instead of npx? Use `node convert.js …` in place of `npx tripkit …`.
57
+
58
+ **Thumbnails (optional).** If [`sharp`](https://www.npmjs.com/package/sharp) is installed,
59
+ step 1 also writes downscaled thumbnails to `my-photos/thumb/` and references them for fast
60
+ map/gallery loading. If `sharp` isn't installed, the step still works — it just uses the
61
+ full-resolution images directly. Install with `npm install sharp` to enable thumbnails.
62
+
63
+ ---
64
+
65
+ ## How matching works
66
+
67
+ `tripkit media` reads each file's EXIF metadata and assigns it to a stop using two signals:
68
+
69
+ 1. **Timestamp → day.** The photo's `DateTimeOriginal` is matched to the day whose `date`
70
+ falls on the same calendar date. (The year comes from `trip.dates`.) This keeps a photo
71
+ taken on Day 3 from matching a closer-looking stop on Day 5.
72
+ 2. **GPS → nearest stop.** Within that day, the item is assigned to the geographically
73
+ nearest stop (great-circle distance). The result is tagged with a `confidence`:
74
+ - `high` — within ~3 miles of the stop.
75
+ - `medium` — within ~25 miles.
76
+ - Anything farther, or with **no GPS**, or whose date matches **no day**, goes to the
77
+ `unmatched` bucket for you to handle.
78
+
79
+ Videos are routed to `unmatched` by default — most video formats don't expose GPS the way
80
+ photos do, so they're left for you to place rather than guessed at.
81
+
82
+ Nothing is destructive: matching only ever *proposes*. Your `trip.yaml` is untouched until
83
+ you run `media apply`.
84
+
85
+ ---
86
+
87
+ ## The review file, field by field
88
+
89
+ `tripkit media` writes `<trip>.media-review.yaml`. It's meant to be read and edited before
90
+ applying. A trimmed example:
91
+
92
+ ```yaml
93
+ trip_file: trip.yaml
94
+ summary:
95
+ total: 6 # files scanned
96
+ with_gps: 5 # had EXIF GPS
97
+ with_timestamp: 5 # had an EXIF capture time
98
+ matched: 4 # confidently assigned to a stop
99
+ unmatched: 2 # need your attention
100
+ thumbnails: generated # or: "skipped (sharp not installed)"
101
+
102
+ stops:
103
+ - day: 1 # ← which day (matches days[].number in trip.yaml)
104
+ stop_index: 0 # ← which stop within that day (0-based)
105
+ stop: Lower Yosemite Fall # name, for your reference (not used on apply)
106
+ media:
107
+ - src: media/fall_01.jpg
108
+ type: photo
109
+ caption: "" # ← WRITE THIS. Shown in the lightbox.
110
+ thumb: media/thumb/fall_01.jpg
111
+ lat: 37.7560 # original EXIF GPS (drives the photo-pin layer)
112
+ lng: -119.5965
113
+ taken_at: '2026-05-15T17:15:00Z'
114
+ confidence: high # advisory only; removed on apply
115
+
116
+ unmatched:
117
+ - src: media/random_sf.jpg
118
+ type: photo
119
+ caption: ""
120
+ lat: 37.7749
121
+ lng: -122.4194
122
+ taken_at: '2026-05-16T12:00:00Z'
123
+ reason: nearest stop "Glacier Point" is 156 mi away # why it wasn't auto-matched
124
+ suggested_stop: Glacier Point
125
+ - src: media/no_gps.jpg
126
+ type: photo
127
+ caption: ""
128
+ reason: no GPS in EXIF
129
+ ```
130
+
131
+ **What to edit:**
132
+
133
+ | Goal | Do this |
134
+ |------|---------|
135
+ | Add a caption | Fill in `caption:` (this is the highest-value edit — it's what the lightbox shows). |
136
+ | Move a photo to a different stop | Change its `day:` / `stop_index:` (under `stops:`), or move the item between entries. |
137
+ | Keep an unmatched item | Give it a `day:` and `stop_index:` — then it's applied like any matched item. |
138
+ | Drop an unmatched item | Leave it in `unmatched` without a `day`/`stop_index`. It's ignored on apply. |
139
+ | Remove a wrong match | Delete the item from the file before applying. |
140
+
141
+ **What's ignored on apply:** the `confidence`, `reason`, `suggested_stop`, and `stop` (name)
142
+ fields are review aids — they're stripped when merging into `trip.yaml`. Only
143
+ `src`, `type`, `thumb`, `caption`, `lat`, `lng`, and `taken_at` are written through.
144
+
145
+ **`media apply` is idempotent.** Re-running it won't duplicate items already present (it
146
+ matches on `src`), so it's safe to apply, render, tweak captions, and apply again.
147
+
148
+ ---
149
+
150
+ ## Letting an AI agent do the review
151
+
152
+ The review step — captioning every photo and placing the unmatched ones — is exactly the
153
+ kind of judgement work an AI agent is good at. The flow is designed so a human or an agent
154
+ can do it; the agent never touches your originals, only the review YAML.
155
+
156
+ A typical agent session:
157
+
158
+ > **You:** "I ran `tripkit media ./trip-photos yosemite.yaml`. Here's the review file —
159
+ > caption the photos and place the unmatched ones, then tell me the apply command."
160
+ >
161
+ > *(paste or share `yosemite.media-review.yaml`)*
162
+
163
+ The agent should:
164
+
165
+ 1. **Caption each matched item** — concise, specific, grounded in what the photo shows and
166
+ the stop it's at. "Lower Yosemite Fall roaring with May snowmelt," not "Photo 1."
167
+ It can lean on `stop`, `taken_at`, and the trip's own descriptions for context.
168
+ 2. **Resolve `unmatched` items** — use `suggested_stop`, the `reason`, the filename, the
169
+ `taken_at` time, and the trip itinerary to decide a `day` + `stop_index`, or leave
170
+ genuinely unplaceable items unmatched. A `reason: no GPS` selfie taken at 8pm on Day 2
171
+ probably belongs at that day's dinner or lodging stop.
172
+ 3. **Flag the suspicious ones** — if `suggested_stop` is 150 miles away, that photo likely
173
+ isn't from a planned stop at all; better to leave it out than force a wrong pin.
174
+ 4. **Hand back the edited review file** and the exact `tripkit media apply …` command.
175
+
176
+ If you use **Claude Code**, install the skill once and the agent already knows this flow
177
+ (it's Phase 5 of the TripKit agent skill):
178
+
179
+ ```bash
180
+ npx tripkit install-skill # → ~/.claude/skills/tripkit/
181
+ ```
182
+
183
+ Then: *"Use the tripkit skill to caption and place the media in this review file."*
184
+
185
+ **Why review-in-the-middle?** The agent works on a separate YAML, not your photo files or
186
+ your trip. You see every proposed caption and placement before anything is written, and the
187
+ `media apply` step is a plain mechanical merge you run yourself. The intelligence is
188
+ auditable, not a black box.
189
+
190
+ After the agent returns the file, validate and render:
191
+
192
+ ```bash
193
+ npx tripkit media apply yosemite.media-review.yaml yosemite.yaml
194
+ npx tripkit validate yosemite.yaml # surfaces e.g. media GPS far from its stop
195
+ npx tripkit yosemite.yaml yosemite.html
196
+ ```
197
+
198
+ ---
199
+
200
+ ## The `media` schema
201
+
202
+ Each stop (and, optionally, `lodging`) may carry a `media` array. It's purely additive to
203
+ the [existing schema](../schema/tripkit.schema.yaml).
204
+
205
+ ```yaml
206
+ stops:
207
+ - name: "Multnomah Falls"
208
+ lat: 45.576
209
+ lng: -122.116
210
+ image: "https://…" # unchanged: the single hero/primary image
211
+ media: # the gallery
212
+ - src: "media/IMG_2401.jpg" # relative path OR full URL (required)
213
+ type: photo # "photo" | "video"
214
+ thumb: "media/thumb/IMG_2401.jpg" # optional; renderer falls back to src
215
+ caption: "620-ft waterfall from the lower viewpoint" # optional
216
+ lat: 45.5762 # optional EXIF GPS; powers the photo-pin layer
217
+ lng: -122.1158
218
+ taken_at: "2026-04-07T14:32:00" # optional ISO 8601 capture time
219
+ ```
220
+
221
+ - `image` (the original single-image field) is untouched and still used as the hero/primary.
222
+ `media` is the new gallery shown in cards, popups, the lightbox, and the marker badge.
223
+ - Only `src` and a valid `type` are required per item; everything else is optional.
224
+ - Items that include `lat`/`lng` also appear as individual pins on the **📷 Photos** map layer.
225
+
226
+ `tripkit validate` checks every media item has a `src` and a valid `type`, and **warns**
227
+ when an item's GPS is more than ~25 miles from its stop — a strong hint it was placed on the
228
+ wrong stop.
229
+
230
+ ---
231
+
232
+ ## Using URLs instead of local files
233
+
234
+ `src` (and `thumb`) accept either a **relative path** or a **full URL**.
235
+
236
+ - **Relative paths** (the default from `tripkit media`) keep full-resolution media, but the
237
+ output is now an HTML file *plus* its media folder — ship them together.
238
+ - **URLs** (e.g. photos you've uploaded to S3, Cloudinary, Google Photos, or anywhere with a
239
+ direct link) keep the output a **single, self-contained, shareable HTML file** with no
240
+ media folder to carry around.
241
+
242
+ You can mix both in one trip. To use URLs, either point `src` at them when hand-editing the
243
+ review file, or swap the relative paths for URLs in `trip.yaml` after applying.
244
+
245
+ ---
246
+
247
+ ## Troubleshooting
248
+
249
+ **"Missing dependency exifr."** The ingest step needs it: `npm install exifr` (or just
250
+ `npm install` in a clone — it's a normal dependency). Only the `media` command needs it; the
251
+ renderer and `convert` don't.
252
+
253
+ **Thumbnails were skipped.** That's the `sharp`-not-installed path; it's fine. Run
254
+ `npm install sharp` to enable them. The review file's `summary.thumbnails` tells you which
255
+ path ran.
256
+
257
+ **Everything landed in `unmatched`.** Usually one of:
258
+ - The photos have no GPS (phone privacy settings, screenshots, exported/edited copies that
259
+ stripped EXIF). Place them by hand using `day` + `stop_index`.
260
+ - The photo dates don't line up with any day's `date`, so the day filter excludes every
261
+ stop. Check that `trip.dates` has the right year and each day's `date` is correct.
262
+
263
+ **A photo matched the wrong stop.** Edit its `day`/`stop_index` in the review file before
264
+ applying, or move it to the right `stops:` entry. If you already applied, fix it in
265
+ `trip.yaml` (the `media` array under that stop) and re-render.
266
+
267
+ **Videos didn't match.** Expected — they're routed to `unmatched` by default. Give them a
268
+ `day` + `stop_index` to place them; they play inline in the lightbox.
269
+
270
+ **The marker badge / photo-pin layer didn't appear.** The badge only shows on stops that
271
+ have `media`; the **📷 Photos** toggle only appears when at least one media item has its own
272
+ `lat`/`lng`. If your items lack GPS, the galleries and lightbox still work — just not the pins.
273
+
274
+ ---
275
+
276
+ ## Command reference
277
+
278
+ ```text
279
+ tripkit media <folder> <trip.yaml>
280
+ Scan <folder> for photos/videos, read EXIF GPS + timestamps, auto-match each
281
+ to the nearest stop on the matching day, optionally generate thumbnails, and
282
+ write <trip>.media-review.yaml. Does not modify <trip.yaml>.
283
+
284
+ tripkit media apply <review.yaml> <trip.yaml>
285
+ Merge the reviewed media into <trip.yaml>'s stops[].media[]. Idempotent.
286
+
287
+ tripkit validate <trip.yaml>
288
+ Validate the trip, including media (src present, type valid, GPS sanity).
289
+
290
+ tripkit <trip.yaml> [output.html]
291
+ Render to interactive HTML (auto-validates first).
292
+ ```
293
+
294
+ Supported media extensions: photos — `.jpg .jpeg .png .heic .heif .webp .tif .tiff`;
295
+ videos — `.mp4 .mov .m4v .avi .mkv .webm`.
296
+
297
+ See also: the [README](../README.md) for the project overview and the
298
+ [agent skill](../agent/SKILL.md) (Phase 5) for the agent-facing version of this flow.
package/docs/README.md ADDED
@@ -0,0 +1 @@
1
+ Screenshot placeholder — replace with actual screenshot of the TripKit visualizer
@@ -307,6 +307,21 @@ days:
307
307
  duration: "2–3 hrs (loop)"
308
308
  reservation_required: false
309
309
  navigate_url: "https://www.google.com/maps/dir/?api=1&destination=Multnomah+Falls+Oregon"
310
+ # Post-trip media demo — `src` can be a relative path (from `tripkit media`) or a URL.
311
+ # These carry lat/lng, so they also appear in the toggleable photo-pin map layer.
312
+ media:
313
+ - src: "https://images.unsplash.com/photo-1546587348-d12660c30c50?w=1600"
314
+ type: photo
315
+ caption: "Multnomah Falls roaring with spring snowmelt — lower viewpoint"
316
+ lat: 45.5762
317
+ lng: -122.1158
318
+ taken_at: "2026-04-07T14:32:00"
319
+ - src: "https://images.unsplash.com/photo-1559521783-1d1599583485?w=1600"
320
+ type: photo
321
+ caption: "Benson Bridge between the upper and lower falls"
322
+ lat: 45.5765
323
+ lng: -122.1155
324
+ taken_at: "2026-04-07T14:40:00"
310
325
 
311
326
  - name: "Latourell Falls"
312
327
  lat: 45.537