From 0713dee471c7ada04dbca616021ce34f6afe0ac6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 20:51:57 -0700 Subject: [PATCH 01/13] docs(spec): AG-UI map cockpit polish design (fit-to-bounds + AdvancedMarker + crash fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-PR plan for the Phase-2 map cockpit follow-up: PR-A lands the already-built blank-page-on-reload crash fix (GoogleMapsLoader gate); PR-B adds fit-to-bounds, migrates google.maps.Marker → AdvancedMarkerElement with a cloud-based dark map style (DEMO_MAP_ID fallback), preserving flat day-colored dot pins. Grey-map is documented as environmental (tile-quota throttling), not a code bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-24-ag-ui-map-cockpit-polish-design.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md diff --git a/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md b/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md new file mode 100644 index 00000000..d4d53e01 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md @@ -0,0 +1,82 @@ +# AG-UI Map Cockpit Polish — Design + +**Status:** Approved (brainstorm), pending spec review +**Date:** 2026-06-24 +**Area:** `examples/ag-ui/angular` — App-mode Google Map cockpit (Phase 2 follow-up) +**Related:** Phase 1 [#729], Phase 2 [#732]; redesign spec `2026-06-22-ag-ui-itinerary-redesign-design.md` + +## Goal + +Close the Phase-2 map cockpit's deferred polish: frame the map to the actual stops (fit-to-bounds), migrate off the deprecated `google.maps.Marker` to `AdvancedMarkerElement` (with a cloud-based dark map style), and land the already-built but unmerged blank-page-on-reload crash fix. + +## Context established during brainstorming + +Three findings reshaped the scope: + +1. **The "grey base map" is NOT a code bug.** Prior debugging root-caused it as Google Maps **tile-quota throttling** from heavy cumulative reloads in one session (tile requests returning `transferSize: 0`). It renders dark in a fresh session. Fit-to-bounds was explicitly ruled out as the cause. **No code change addresses grey — there is nothing to fix there.** + +2. **A real dev-mode crash fix is built but unmerged.** Branch `worktree-ag-ui-app-mode-mapfix`, commit `189d04b9` (base = `fb0c49f7`, 2 unrelated commits behind main, rebases clean). `` throws in its constructor when `window.google` is absent (a `ngDevMode`-gated throw at `@angular/google-maps` `google-maps.mjs:136`); on a fresh reload with App-mode persisted, the async Maps script loses the bootstrap race and the throw aborts the shell render → blank page (no console error; zoneless swallows it). #732's smoke missed it because it only toggled App-mode at runtime, never reloaded with it persisted. The fix: a `GoogleMapsLoader` service owning the script injection (exposes a `loaded` signal), with `` gated behind `@if (loader.loaded())`. It touches 3 files (`app.config.ts`, new `google-maps-loader.ts`, `map-canvas.component.ts`), is lint-clean with 42 tests passing. + +3. **The marker migration forces a styling tradeoff (decided: cloud style).** `AdvancedMarkerElement` *requires* a map `mapId`, and a map with a `mapId` **ignores the inline JSON `styles` array** — styling moves to a cloud-based map style tied to that `mapId`. We chose to migrate and move the dark theme to a Google Cloud Console map style (future-proof), accepting a one-time console-config dependency, with a `DEMO_MAP_ID` fallback so a fresh clone still runs (light map) without setup. + +## Scope: two sequential PRs + +The crash fix and the map-polish work both heavily rewrite `map-canvas.component.ts`, so they are split to keep diffs clean and land the finished fix without gating it behind new work. + +### PR-A — Land the crash fix (already built) + +Rebase `189d04b9` onto current main and open it as a PR. No new code authored; this is bringing finished, verified work to main. + +- **Files:** `examples/ag-ui/angular/src/app/app.config.ts`, `examples/ag-ui/angular/src/app/google-maps-loader.ts` (new), `examples/ag-ui/angular/src/app/map-canvas.component.ts`. +- **Companion fix (in the same branch):** App-mode-on reload forces `/sidebar` (the persist effect previously redirected to the default `/embed` route, covering the map with the opaque chat). +- **Verify:** rebases clean; `nx lint`/`test`/`build` for `examples-ag-ui-angular` green (42 tests); live smoke — reload with App-mode persisted renders the shell (no blank page) and lands on `/sidebar`. + +### PR-B — Fit-to-bounds + AdvancedMarker migration + cloud dark style + +Built on top of PR-A's loader-gated map. + +#### Fit-to-bounds + +- New effect keyed on `stopsWithCoords()` (structural changes: add/remove/geocode) builds a `google.maps.LatLngBounds` and calls `GoogleMap.fitBounds(bounds, padding≈48px)`. `GoogleMap` obtained via `viewChild(GoogleMap)`. **Because `` is behind PR-A's `@if (loader.loaded())` gate, the `viewChild` is `undefined` until the map loads — the effect must no-op when it is absent** (mirrors the existing focus effect's `marker && win` guards). +- **Edge cases:** 0 stops → fall back to `PARIS_CENTER` + default zoom; 1 stop → `panTo` + fixed city zoom (~13) (avoids `fitBounds` single-point over-zoom); ≥2 → `fitBounds` with padding. +- **Coexistence with focus:** the existing focus effect (row/marker click → pan + open info window, keyed on `focusedStopId`) is unchanged. Fit-to-bounds fires only on the structural signal, so the two never fight: clicking a stop pans to it; adding a stop reframes to all. +- **Testability:** extract a pure helper `computeBounds(stops) → { north, south, east, west } | null` (jsdom-safe geometry) with a unit test. The `fitBounds` *call* is live-smoke verified. +- The `center()`/`zoom()` signals remain only for the 0/1-stop fallback path. + +#### AdvancedMarker migration + +- Swap `MapMarker` → `MapAdvancedMarker` (`@angular/google-maps`, confirmed available at the installed version; Angular core 21.1.6). +- Add `[mapId]="mapId"` to `` (required for advanced markers). +- **Pin styling (decided: flat colored dots):** each marker's `content` is a styled `
` circle built imperatively — `background: DAY_COLORS[(day-1) % n]`, white border — returned by a `pinContent(s)` method. `DAY_COLORS` stays in-repo (marker styling, not map styling). This preserves the current dot aesthetic rather than switching to a teardrop `PinElement`. +- **Delete `DARK_STYLE` and `styles: DARK_STYLE`** from `mapOptions` — a `mapId` map ignores inline styles (dead code). +- InfoWindow: `MapInfoWindow.open(marker)` still works (`MapAdvancedMarker` is a valid anchor). The focus effect's index-based marker lookup is unchanged. + +#### Cloud dark style + mapId (external seam) + +User-performed Console steps (cannot be automated): +1. In Google Cloud Console: create a **Map ID** (vector), create/import a **dark map style**, associate them. +2. Provide the Map ID via the **existing** `generated-keys.local.ts` + `scripts/inject-env.mjs` mechanism — add a `GOOGLE_MAPS_MAP_ID` env var alongside `GOOGLE_MAPS_API_KEY` (both gitignored, `fileReplacements`-injected). Add it to the `.env` at the main checkout. + +**Fallback:** if no `GOOGLE_MAPS_MAP_ID` is configured, use Google's `DEMO_MAP_ID` so the demo runs (light style, advanced markers work) for a fresh cloner. Documented in the example README: a custom Map ID enables the dark theme. + +**Verification consequence:** all code is authorable + lint/build/smoke-able now, but the **dark-theme visual check is gated on the user's Console setup + env var**. Until then the map renders light with correct dark-dot pins. + +## Testing & verification + +- **Unit (vitest):** `computeBounds()` pure helper; existing `geocoding.service.spec.ts` retained; PR-A's 42 tests carry over. +- **No jsdom map rendering** (Maps API needs a real browser) → marker content, `mapId`, `fitBounds` calls, and the dark theme are verified by **live Chrome-MCP smoke** (established pattern), with known gotchas: re-run `scripts/inject-env.mjs` (with `GOOGLE_MAPS_API_KEY` exported) after any `nx build` (the Nx-cached `inject-env` target can write an empty key); probe DOM via `javascript_tool` not screenshots (WebGL canvas times out); wait for HMR idle. The worktree root has no `.env` — the real one is at the main checkout. +- **Live smoke checklist:** + - *PR-A:* reload with App-mode persisted → shell renders (no blank page), lands on `/sidebar`. + - *PR-B:* day-colored dot markers render; fit-to-bounds frames all stops on load and reframes on add/remove; single-stop centers; info window opens on marker click; row-click still pans; dark theme appears once the Map ID is configured (`DEMO_MAP_ID` = light until then). + +## Error handling + +- **No `GOOGLE_MAPS_MAP_ID`** → `DEMO_MAP_ID` fallback (light map; advanced markers still function). +- **Geocoding failure** (existing `GeocodingService`) → the stop stays coord-less and is already filtered from `stopsWithCoords()`, so markers + bounds skip it gracefully (no change needed). + +## Out of scope + +- Grey-map "fix" (environmental, not code). +- Dark-style array fine-tuning (the array is deleted; styling moves to cloud). +- Mobile/a11y polish and `styles.css` token cleanup (homed in the Phase-2 acceptance-followup doc). +- Any change to the chat library or other examples. From b1b64b753e425f544b291a8a03992bd49a690a73 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 20:59:16 -0700 Subject: [PATCH 02/13] docs(spec): reconcile map-cockpit spec with open PR #736 (PR-A) PR-A is the already-open crash-fix PR #736, not a to-be-created PR. Note the 7-file scope, that it leaves fit-to-bounds/AdvancedMarker to PR-B, and that PR-B must branch off #736 (not main) since main lacks the loader gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-24-ag-ui-map-cockpit-polish-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md b/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md index d4d53e01..73bae937 100644 --- a/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md +++ b/docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-polish-design.md @@ -3,7 +3,7 @@ **Status:** Approved (brainstorm), pending spec review **Date:** 2026-06-24 **Area:** `examples/ag-ui/angular` — App-mode Google Map cockpit (Phase 2 follow-up) -**Related:** Phase 1 [#729], Phase 2 [#732]; redesign spec `2026-06-22-ag-ui-itinerary-redesign-design.md` +**Related:** Phase 1 [#729], Phase 2 [#732]; PR-A = crash fix [#736] (open); redesign spec `2026-06-22-ag-ui-itinerary-redesign-design.md` ## Goal @@ -23,17 +23,17 @@ Three findings reshaped the scope: The crash fix and the map-polish work both heavily rewrite `map-canvas.component.ts`, so they are split to keep diffs clean and land the finished fix without gating it behind new work. -### PR-A — Land the crash fix (already built) +### PR-A — Land the crash fix — **already open as [#736]** -Rebase `189d04b9` onto current main and open it as a PR. No new code authored; this is bringing finished, verified work to main. +This is the already-built, already-rebased crash-fix branch `worktree-ag-ui-app-mode-mapfix` (commit `189d04b9` + follow-ups), now open as **[#736]** against `main`. No new code is authored here; PR-B simply waits on / branches off it. -- **Files:** `examples/ag-ui/angular/src/app/app.config.ts`, `examples/ag-ui/angular/src/app/google-maps-loader.ts` (new), `examples/ag-ui/angular/src/app/map-canvas.component.ts`. -- **Companion fix (in the same branch):** App-mode-on reload forces `/sidebar` (the persist effect previously redirected to the default `/embed` route, covering the map with the opaque chat). -- **Verify:** rebases clean; `nx lint`/`test`/`build` for `examples-ag-ui-angular` green (42 tests); live smoke — reload with App-mode persisted renders the shell (no blank page) and lands on `/sidebar`. +- **Files (7):** `app.config.ts`, `google-maps-loader.ts` (new), `map-canvas.component.ts` (wraps `` in `@if (loader.loaded())`, injects `GoogleMapsLoader`, keeps `MapMarker`/`DARK_STYLE`/`markerOptions`), plus the companion fixes — `modes/sidebar-mode.component.ts` (forces `/sidebar` on App-mode reload, previously redirected to `/embed` and covered the map), `e2e/itinerary-client-tools.spec.ts`, and `shell/ag-ui-shell.component.{css,html}` cleanup. +- **Scope boundary confirmed:** #736 does **not** touch fit-to-bounds or AdvancedMarker — those are entirely PR-B. The grey-map base is left open in #736's body ("tracked separately"); this spec treats it as environmental tile-quota throttling and out of scope. +- **Verify (already done on the branch):** `nx lint`/`test`/`build` for `examples-ag-ui-angular` green (42 tests); live smoke — reload with App-mode persisted renders the shell (no blank page) and lands on `/sidebar`. ### PR-B — Fit-to-bounds + AdvancedMarker migration + cloud dark style -Built on top of PR-A's loader-gated map. +Built on top of PR-A's loader-gated map. **Branches off [#736]'s head (or off `main` once #736 merges) — never off current `main`, which lacks the loader gate.** PR-B deletes the exact `DARK_STYLE` / `markerOptions` / `[options]` / `[center]` lines #736 carries; because it layers *on top of* #736 this is a clean sequential edit, not a merge conflict. #### Fit-to-bounds From d99c3f86ac9824abb9647ba77168fc0246550f7e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 21:17:07 -0700 Subject: [PATCH 03/13] docs(plan): AG-UI map cockpit PR-B implementation plan (fit-to-bounds + AdvancedMarker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7-task TDD plan layering on PR #736: pure computeBounds helper + unit test, fit-to-bounds effect with 0/1/≥2 fallbacks + loader-gate no-op guard, GOOGLE_MAPS_MAP_ID env plumbing, marker-library load, MapMarker→MapAdvancedMarker with day-colored div pins + mapId + DEMO_MAP_ID fallback, README, and live map smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-24-ag-ui-map-cockpit-pr-b.md | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-ag-ui-map-cockpit-pr-b.md diff --git a/docs/superpowers/plans/2026-06-24-ag-ui-map-cockpit-pr-b.md b/docs/superpowers/plans/2026-06-24-ag-ui-map-cockpit-pr-b.md new file mode 100644 index 00000000..450119f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-ag-ui-map-cockpit-pr-b.md @@ -0,0 +1,516 @@ +# AG-UI Map Cockpit Polish (PR-B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Frame the App-mode map to the actual itinerary stops (fit-to-bounds) and migrate off the deprecated `google.maps.Marker` to `AdvancedMarkerElement` with a cloud-based dark map style, in `examples/ag-ui/angular`. + +**Architecture:** Layers on top of the already-open crash-fix PR #736 (loader-gated ``). A pure `computeBounds` helper (unit-tested) feeds a fit-to-bounds effect in `MapCanvasComponent`. Advanced markers require a map `mapId`, which disables inline JSON styles — so the dark theme moves to a Google Cloud map style wired through the existing `inject-env.mjs` + `generated-keys` env mechanism, with a `DEMO_MAP_ID` fallback so a fresh clone still runs (light map). + +**Tech Stack:** Angular 21 (signals, `effect`, `computed`, `viewChild`/`viewChildren`, OnPush), `@angular/google-maps` (`GoogleMap`, `MapAdvancedMarker`, `MapInfoWindow`, `MapPolyline`), vitest, live Chrome-MCP smoke. + +--- + +## Prerequisite + +**PR #736 must be merged (or this work branched off `origin/worktree-ag-ui-app-mode-mapfix`).** `main` lacks the `GoogleMapsLoader` + `@if (loader.loaded())` gate this plan assumes. Verify before Task 1: + +```bash +git grep -l "GoogleMapsLoader" examples/ag-ui/angular/src/app/map-canvas.component.ts +# Expect: a match. If none, rebase this branch onto #736's head first. +``` + +## File Structure + +- **Create** `examples/ag-ui/angular/src/app/map-bounds.ts` — pure `computeBounds(stops) → Bounds | null`. One job: bounds geometry. No Angular, no `google.maps`. +- **Create** `examples/ag-ui/angular/src/app/map-bounds.spec.ts` — vitest for the helper. +- **Modify** `examples/ag-ui/angular/src/app/map-canvas.component.ts` — fit-to-bounds effect; `MapMarker` → `MapAdvancedMarker`; `mapId`; `markerViews` computed (day-colored `
` pins); delete `DARK_STYLE`/`markerOptions`. +- **Modify** `examples/ag-ui/angular/src/app/google-maps-loader.ts` — add `marker` to the Maps `libraries` param. +- **Modify** `examples/ag-ui/angular/src/environments/generated-keys.ts` — add `googleMapsMapId: ''` to the committed stub. +- **Modify** `examples/ag-ui/angular/scripts/inject-env.mjs` — emit `googleMapsMapId` from `GOOGLE_MAPS_MAP_ID`. +- **Modify** `examples/ag-ui/angular/src/environments/environment.ts` and `environment.development.ts` — expose `googleMapsMapId`. +- **Modify** `examples/ag-ui/angular/README.md` — document `GOOGLE_MAPS_MAP_ID` + `DEMO_MAP_ID` fallback. + +--- + +### Task 1: Pure `computeBounds` helper (TDD) + +**Files:** +- Create: `examples/ag-ui/angular/src/app/map-bounds.ts` +- Test: `examples/ag-ui/angular/src/app/map-bounds.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// examples/ag-ui/angular/src/app/map-bounds.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { computeBounds } from './map-bounds'; + +describe('computeBounds', () => { + it('returns null for an empty list', () => { + expect(computeBounds([])).toBeNull(); + }); + + it('returns null when no stop has coordinates', () => { + expect(computeBounds([{ lat: null, lng: null }, { lat: undefined, lng: undefined }])).toBeNull(); + }); + + it('returns a degenerate box for a single stop', () => { + expect(computeBounds([{ lat: 48.86, lng: 2.35 }])).toEqual({ + north: 48.86, south: 48.86, east: 2.35, west: 2.35, + }); + }); + + it('returns min/max extents for multiple stops', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: 48.80, lng: 2.40 }, + { lat: 48.90, lng: 2.30 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.80, east: 2.40, west: 2.30 }); + }); + + it('ignores stops missing coordinates', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: null, lng: null }, + { lat: 48.90, lng: 2.40 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.86, east: 2.40, west: 2.35 }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-ag-ui-angular --skip-nx-cache -- -t computeBounds` +Expected: FAIL — `Cannot find module './map-bounds'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// examples/ag-ui/angular/src/app/map-bounds.ts +// SPDX-License-Identifier: MIT + +/** A geographic bounding box (LatLngBoundsLiteral-compatible). */ +export interface Bounds { + north: number; + south: number; + east: number; + west: number; +} + +/** Smallest box containing every stop that has coordinates, or null if none do. */ +export function computeBounds( + stops: ReadonlyArray<{ lat?: number | null; lng?: number | null }>, +): Bounds | null { + let north = -Infinity, south = Infinity, east = -Infinity, west = Infinity; + let found = false; + for (const s of stops) { + if (s.lat == null || s.lng == null) continue; + found = true; + north = Math.max(north, s.lat); + south = Math.min(south, s.lat); + east = Math.max(east, s.lng); + west = Math.min(west, s.lng); + } + return found ? { north, south, east, west } : null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-ag-ui-angular --skip-nx-cache -- -t computeBounds` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/angular/src/app/map-bounds.ts examples/ag-ui/angular/src/app/map-bounds.spec.ts +git commit -m "feat(ag-ui): pure computeBounds helper for map fit-to-bounds" +``` + +--- + +### Task 2: Fit-to-bounds effect in `MapCanvasComponent` + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/map-canvas.component.ts` + +This adds a `viewChild(GoogleMap)` and an effect that frames the map. It does NOT yet touch markers (Task 5). + +- [ ] **Step 1: Import `GoogleMap`, `computeBounds`, and add the viewChild** + +In the existing `@angular/google-maps` import, ensure `GoogleMap` is imported (it already is). Add at the top of the class body, near the other `viewChild`/`viewChildren` declarations: + +```ts +import { computeBounds } from './map-bounds'; +``` + +```ts + private readonly googleMap = viewChild(GoogleMap); +``` + +- [ ] **Step 2: Add the fit-to-bounds effect in the constructor** + +Append inside the existing `constructor()` (after the focus effect): + +```ts + // Frame the map to all stops on structural change (add/remove/geocode). + // Reads googleMap() so it re-runs once the map mounts behind the loader gate. + // Keyed on stopsWithCoords() only — NOT focus — so panning to a focused stop + // and reframing to all stops never fight (they fire on different signals). + effect(() => { + const map = this.googleMap(); + const stops = this.stopsWithCoords(); + if (!map) return; // is behind @if (loader.loaded()) — not mounted yet + if (stops.length === 0) { + this.center.set(PARIS_CENTER); + this.zoom.set(12); + return; + } + if (stops.length === 1) { + this.center.set({ lat: stops[0].lat!, lng: stops[0].lng! }); + this.zoom.set(13); + return; + } + const b = computeBounds(stops); + if (b) map.fitBounds(b, 48); // ≥2 stops: fitBounds overrides center/zoom imperatively + }); +``` + +Note: the `≥2` path calls `fitBounds` imperatively and does NOT write `center`/`zoom`, so the bound `[center]`/`[zoom]` inputs don't re-push and override it. The `0`/`1` paths use the signals (which the template binds), so the map follows them. + +- [ ] **Step 3: Build the example to verify it compiles** + +Run: `node examples/ag-ui/angular/scripts/inject-env.mjs && npx nx build examples-ag-ui-angular --skip-nx-cache` +Expected: build succeeds. (If the build wrote an empty key, re-run `inject-env.mjs` with `GOOGLE_MAPS_API_KEY` exported — see Verification notes.) + +- [ ] **Step 4: Run the example unit tests** + +Run: `npx nx test examples-ag-ui-angular --skip-nx-cache` +Expected: PASS (existing tests + Task 1's still green; no map rendering under jsdom). + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/angular/src/app/map-canvas.component.ts +git commit -m "feat(ag-ui): fit map to stops on structural change (fit-to-bounds)" +``` + +--- + +### Task 3: Plumb `GOOGLE_MAPS_MAP_ID` through the env mechanism + +**Files:** +- Modify: `examples/ag-ui/angular/src/environments/generated-keys.ts` +- Modify: `examples/ag-ui/angular/scripts/inject-env.mjs` +- Modify: `examples/ag-ui/angular/src/environments/environment.ts` +- Modify: `examples/ag-ui/angular/src/environments/environment.development.ts` + +- [ ] **Step 1: Add `googleMapsMapId` to the committed stub** + +Replace the `GENERATED_KEYS` object in `examples/ag-ui/angular/src/environments/generated-keys.ts`: + +```ts +export const GENERATED_KEYS = { + googleMaps: '', + googleMapsMapId: '', +} as const; +``` + +- [ ] **Step 2: Emit `googleMapsMapId` from `inject-env.mjs`** + +In `examples/ag-ui/angular/scripts/inject-env.mjs`, after the existing `const key = ...` line add: + +```js +const mapId = env.GOOGLE_MAPS_MAP_ID ?? ''; +``` + +Replace the `contents` template literal's body with: + +```js +const contents = `// SPDX-License-Identifier: MIT +// AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand. +export const GENERATED_KEYS = { + googleMaps: ${JSON.stringify(key)}, + googleMapsMapId: ${JSON.stringify(mapId)}, +} as const; +`; +``` + +And update the final log line: + +```js +console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`); +``` + +- [ ] **Step 3: Expose `googleMapsMapId` on both environments** + +In BOTH `examples/ag-ui/angular/src/environments/environment.ts` and `environment.development.ts`, add a `googleMapsMapId` field alongside `googleMapsApiKey`. For example, environment.ts becomes: + +```ts +import { GENERATED_KEYS } from './generated-keys'; + +export const environment = { + production: true, + googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, +}; +``` + +Apply the identical `googleMapsMapId` line to `environment.development.ts` (keep its existing `production` value). + +- [ ] **Step 4: Regenerate + typecheck** + +Run: `node examples/ag-ui/angular/scripts/inject-env.mjs && npx nx build examples-ag-ui-angular --skip-nx-cache` +Expected: build succeeds; `generated-keys.local.ts` now contains `googleMapsMapId`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/angular/src/environments/generated-keys.ts examples/ag-ui/angular/scripts/inject-env.mjs examples/ag-ui/angular/src/environments/environment.ts examples/ag-ui/angular/src/environments/environment.development.ts +git commit -m "feat(ag-ui): plumb GOOGLE_MAPS_MAP_ID through the env mechanism" +``` + +--- + +### Task 4: Load the Maps `marker` library + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/google-maps-loader.ts` + +`AdvancedMarkerElement` lives in the `marker` library; the loader currently requests only `geocoding`. + +- [ ] **Step 1: Add `marker` to the libraries param** + +In `examples/ag-ui/angular/src/app/google-maps-loader.ts`, change the script `src` line: + +```ts + script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding,marker`; +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `npx nx build examples-ag-ui-angular --skip-nx-cache` +Expected: build succeeds (string-only change). + +- [ ] **Step 3: Commit** + +```bash +git add examples/ag-ui/angular/src/app/google-maps-loader.ts +git commit -m "feat(ag-ui): load Maps marker library for advanced markers" +``` + +--- + +### Task 5: Migrate `MapMarker` → `MapAdvancedMarker` + cloud dark style + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/map-canvas.component.ts` + +- [ ] **Step 1: Swap the import and component `imports`** + +Change the `@angular/google-maps` import to use `MapAdvancedMarker` instead of `MapMarker`: + +```ts +import { GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline } from '@angular/google-maps'; +``` + +And in the `@Component({ imports: [...] })` array, replace `MapMarker` with `MapAdvancedMarker`. + +Add the `environment` import if not present: + +```ts +import { environment } from '../environments/environment'; +``` + +- [ ] **Step 2: Replace `mapOptions` (delete `DARK_STYLE`, add `mapId`) and delete the `DARK_STYLE` constant** + +Delete the entire `const DARK_STYLE: google.maps.MapTypeStyle[] = [ ... ];` block at the top of the file. + +Replace the `mapOptions` field: + +```ts + // A mapId is REQUIRED for advanced markers; a mapId map ignores inline JSON + // `styles`, so the dark theme is a cloud-based map style tied to this id. + // DEMO_MAP_ID lets a fresh clone run (light map) with no Console setup. + protected readonly mapId = environment.googleMapsMapId || 'DEMO_MAP_ID'; + protected readonly mapOptions: google.maps.MapOptions = { + mapId: this.mapId, + disableDefaultUI: true, + zoomControl: true, + clickableIcons: false, + }; +``` + +- [ ] **Step 3: Add a `markerViews` computed that builds day-colored pin elements once per structural change** + +Add near `stopsWithCoords`: + +```ts + /** One marker view per coord'd stop. The pin `
` is built here (not in a + * template method) so it is recreated only when stops change — not on every + * change-detection pass (e.g. focus pans), which would cause flicker. */ + protected readonly markerViews = computed(() => + this.stopsWithCoords().map((s) => ({ + id: s.id, + stop: s, + position: { lat: s.lat!, lng: s.lng! }, + content: this.makePin(s.day), + })), + ); + + private makePin(day: number): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = + 'width:16px;height:16px;border-radius:50%;border:2px solid #fff;' + + `box-shadow:0 1px 3px rgba(0,0,0,.4);background:${DAY_COLORS[(day - 1) % DAY_COLORS.length]};`; + return el; + } +``` + +- [ ] **Step 4: Update the template markers + delete `markerOptions`** + +Replace the marker `@for` block in the template: + +```html + @for (m of markerViews(); track m.id) { + + } +``` + +Delete the `markerOptions(s)` method entirely (advanced markers use `content`, not `MarkerOptions`). + +- [ ] **Step 5: Point the focus effect's marker lookup at `MapAdvancedMarker`** + +Change the `viewChildren` declaration: + +```ts + private readonly markers = viewChildren(MapAdvancedMarker); +``` + +The existing focus effect's `const marker = this.markers()[idx];` and `win.open(marker)` are unchanged — `MapInfoWindow.open` accepts a `MapAdvancedMarker` anchor. Note the focus effect indexes by `stopsWithCoords()` position, which matches `markerViews()` order (both derive from `stopsWithCoords()`), so the index stays correct. + +- [ ] **Step 6: Build to verify it compiles** + +Run: `node examples/ag-ui/angular/scripts/inject-env.mjs && npx nx build examples-ag-ui-angular --skip-nx-cache` +Expected: build succeeds. No reference to `DARK_STYLE`, `markerOptions`, or `MapMarker` remains: + +```bash +git grep -nE "DARK_STYLE|markerOptions|MapMarker\b" examples/ag-ui/angular/src/app/map-canvas.component.ts +# Expect: no output. +``` + +- [ ] **Step 7: Lint + unit tests** + +Run: `npx nx run-many -t lint test -p examples-ag-ui-angular --skip-nx-cache` +Expected: both PASS. + +- [ ] **Step 8: Commit** + +```bash +git add examples/ag-ui/angular/src/app/map-canvas.component.ts +git commit -m "feat(ag-ui): migrate to AdvancedMarkerElement + cloud dark map style" +``` + +--- + +### Task 6: Document the Map ID env var + +**Files:** +- Modify: `examples/ag-ui/angular/README.md` + +- [ ] **Step 1: Add a Map ID subsection** + +Find the section documenting `GOOGLE_MAPS_API_KEY` in `examples/ag-ui/angular/README.md` and add directly after it: + +```markdown +### Dark map theme (optional) + +App mode renders a Google Map. Advanced markers require a **Map ID**, and a +map with a Map ID takes its styling from a **cloud-based map style** (the inline +JSON dark theme no longer applies). To get the dark theme: + +1. In the [Google Cloud Console](https://console.cloud.google.com/google/maps-apis/studio/maps), create a **vector Map ID** and a **dark map style**, and associate them. +2. Add the id to your root `.env`: + + ``` + GOOGLE_MAPS_MAP_ID=your_map_id_here + ``` + +Without `GOOGLE_MAPS_MAP_ID`, the demo falls back to Google's `DEMO_MAP_ID` and +renders a **light** map (markers and routes still work). +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/ag-ui/angular/README.md +git commit -m "docs(ag-ui): document GOOGLE_MAPS_MAP_ID + DEMO_MAP_ID fallback" +``` + +--- + +### Task 7: Full verification + live map smoke + +**Files:** none (verification only) + +- [ ] **Step 1: Static gates** + +Run: `npx nx run-many -t lint test build -p examples-ag-ui-angular --skip-nx-cache` +Expected: all green. If the build wrote an empty key (Nx caches `inject-env`), re-run: +`GOOGLE_MAPS_API_KEY="$(grep '^GOOGLE_MAPS_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | cut -d= -f2- | tr -d '"')" node examples/ag-ui/angular/scripts/inject-env.mjs` + +- [ ] **Step 2: Serve with the real key (controller-run, not in this worktree's empty .env)** + +The real `.env` is at the MAIN checkout, not this worktree. Export the key, regenerate, then serve: + +```bash +export GOOGLE_MAPS_API_KEY="$(grep '^GOOGLE_MAPS_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | cut -d= -f2- | tr -d '"')" +export GOOGLE_MAPS_MAP_ID="$(grep '^GOOGLE_MAPS_MAP_ID=' /Users/blove/repos/angular-agent-framework/.env | cut -d= -f2- | tr -d '"')" +node examples/ag-ui/angular/scripts/inject-env.mjs +# then serve the example (see the example's serve target / runbook) +``` + +- [ ] **Step 3: Live Chrome-MCP smoke checklist** (probe DOM via `javascript_tool`, NOT screenshots — the WebGL canvas times out; wait for HMR idle): + - Toggle App mode → map renders, **day-colored dot markers** present (one per coord'd stop). + - **Fit-to-bounds:** on load the viewport frames all stops (not hardcoded Paris). Add a stop via chat/composer → map reframes to include it. Remove all but one → centers on the single stop. Remove all → falls back to Paris. + - Click a marker → info window opens with place + Remove. + - Click a sidebar row → map pans to that stop (focus still works). + - **Dark theme:** appears only if `GOOGLE_MAPS_MAP_ID` is set to a real cloud-styled Map ID; with `DEMO_MAP_ID` the map is light (expected). Console is clean (no "Namespace google not found", no marker-library errors). + +- [ ] **Step 4: Record the smoke result** + +Write a short pass/fail record to `docs/superpowers/specs/2026-06-24-ag-ui-map-cockpit-pr-b-live-smoke.md` (scenarios + outcomes), mirroring the Phase-1/Phase-2 smoke records. + +- [ ] **Step 5: Open the PR** + +```bash +git push -u origin +gh pr create --base main --title "feat(ag-ui): fit-to-bounds + AdvancedMarker migration (map cockpit polish)" --body "" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Fit-to-bounds (0/1/≥2 fallbacks, no-op-while-gated, pan-on-focus preserved) → Tasks 1–2. ✓ +- `computeBounds` pure helper + unit test → Task 1. ✓ +- AdvancedMarker migration, `mapId`, flat day-colored dot pins, delete `DARK_STYLE` → Task 5. ✓ +- `marker` library load → Task 4. ✓ +- `GOOGLE_MAPS_MAP_ID` env via `inject-env.mjs` + generated-keys, `DEMO_MAP_ID` fallback → Task 3 + Task 5 Step 2. ✓ +- README note → Task 6. ✓ +- Unit (helper) + live smoke, dark theme gated on Console setup → Task 7. ✓ +- Grey-map = environmental, out of scope → not a task (correct). ✓ + +**Placeholder scan:** none — every code step has concrete code; the only `<...>` placeholders are the PR branch name and PR body in Task 7 Step 5 (intentional, controller-supplied at open time). + +**Type consistency:** `Bounds {north,south,east,west}` (Task 1) is consumed by `map.fitBounds(b, 48)` (Task 2) as a `LatLngBoundsLiteral` — field names match. `markerViews` items `{id, stop, position, content}` (Task 5 Step 3) match the template bindings (Task 5 Step 4). `googleMapsMapId` is spelled identically in generated-keys, inject-env output, both environments, and `environment.googleMapsMapId` reads. `makePin(day)`/`DAY_COLORS` indexing matches the existing `polylineOptions` convention. From bba135c9ea518182ed7482bd23e23382a3b26fda Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 21:42:40 -0700 Subject: [PATCH 04/13] feat(ag-ui): pure computeBounds helper for map fit-to-bounds --- .../ag-ui/angular/src/app/map-bounds.spec.ts | 37 +++++++++++++++++++ examples/ag-ui/angular/src/app/map-bounds.ts | 26 +++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 examples/ag-ui/angular/src/app/map-bounds.spec.ts create mode 100644 examples/ag-ui/angular/src/app/map-bounds.ts diff --git a/examples/ag-ui/angular/src/app/map-bounds.spec.ts b/examples/ag-ui/angular/src/app/map-bounds.spec.ts new file mode 100644 index 00000000..7b33800b --- /dev/null +++ b/examples/ag-ui/angular/src/app/map-bounds.spec.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { computeBounds } from './map-bounds'; + +describe('computeBounds', () => { + it('returns null for an empty list', () => { + expect(computeBounds([])).toBeNull(); + }); + + it('returns null when no stop has coordinates', () => { + expect(computeBounds([{ lat: null, lng: null }, { lat: undefined, lng: undefined }])).toBeNull(); + }); + + it('returns a degenerate box for a single stop', () => { + expect(computeBounds([{ lat: 48.86, lng: 2.35 }])).toEqual({ + north: 48.86, south: 48.86, east: 2.35, west: 2.35, + }); + }); + + it('returns min/max extents for multiple stops', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: 48.80, lng: 2.40 }, + { lat: 48.90, lng: 2.30 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.80, east: 2.40, west: 2.30 }); + }); + + it('ignores stops missing coordinates', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: null, lng: null }, + { lat: 48.90, lng: 2.40 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.86, east: 2.40, west: 2.35 }); + }); +}); diff --git a/examples/ag-ui/angular/src/app/map-bounds.ts b/examples/ag-ui/angular/src/app/map-bounds.ts new file mode 100644 index 00000000..d0a091fe --- /dev/null +++ b/examples/ag-ui/angular/src/app/map-bounds.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +/** A geographic bounding box (LatLngBoundsLiteral-compatible). */ +export interface Bounds { + north: number; + south: number; + east: number; + west: number; +} + +/** Smallest box containing every stop that has coordinates, or null if none do. */ +export function computeBounds( + stops: ReadonlyArray<{ lat?: number | null; lng?: number | null }>, +): Bounds | null { + let north = -Infinity, south = Infinity, east = -Infinity, west = Infinity; + let found = false; + for (const s of stops) { + if (s.lat == null || s.lng == null) continue; + found = true; + north = Math.max(north, s.lat); + south = Math.min(south, s.lat); + east = Math.max(east, s.lng); + west = Math.min(west, s.lng); + } + return found ? { north, south, east, west } : null; +} From 86f10f8775a8a4533369467f890a5f802b782aaf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 21:48:47 -0700 Subject: [PATCH 05/13] feat(ag-ui): fit map to stops on structural change (fit-to-bounds) --- .../angular/src/app/map-canvas.component.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/ag-ui/angular/src/app/map-canvas.component.ts b/examples/ag-ui/angular/src/app/map-canvas.component.ts index 419eb03b..bb965692 100644 --- a/examples/ag-ui/angular/src/app/map-canvas.component.ts +++ b/examples/ag-ui/angular/src/app/map-canvas.component.ts @@ -12,6 +12,7 @@ import { } from '@angular/core'; import { GoogleMap, MapInfoWindow, MapMarker, MapPolyline } from '@angular/google-maps'; import { ItineraryStop, ItineraryStore } from './itinerary-store'; +import { computeBounds } from './map-bounds'; import { GoogleMapsLoader } from './google-maps-loader'; const DARK_STYLE: google.maps.MapTypeStyle[] = [ @@ -99,6 +100,7 @@ export class MapCanvasComponent { clickableIcons: false, }; + private readonly googleMap = viewChild(GoogleMap); private readonly infoWindow = viewChild(MapInfoWindow); private readonly markers = viewChildren(MapMarker); @@ -124,6 +126,28 @@ export class MapCanvasComponent { win.open(marker); } }); + + // Frame the map to all stops on structural change (add/remove/geocode). + // Reads googleMap() so it re-runs once the map mounts behind the loader gate. + // Keyed on stopsWithCoords() only — NOT focus — so panning to a focused stop + // and reframing to all stops never fight (they fire on different signals). + effect(() => { + const map = this.googleMap(); + const stops = this.stopsWithCoords(); + if (!map) return; // is behind @if (loader.loaded()) — not mounted yet + if (stops.length === 0) { + this.center.set(PARIS_CENTER); + this.zoom.set(12); + return; + } + if (stops.length === 1) { + this.center.set({ lat: stops[0].lat!, lng: stops[0].lng! }); + this.zoom.set(13); + return; + } + const b = computeBounds(stops); + if (b) map.fitBounds(b, 48); // >=2 stops: fitBounds overrides center/zoom imperatively + }); } protected onMarkerClick(s: ItineraryStop): void { From 16f18ba14ea1f00d235eb360c0f6db283363f697 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 21:56:10 -0700 Subject: [PATCH 06/13] feat(ag-ui): plumb GOOGLE_MAPS_MAP_ID through the env mechanism --- examples/ag-ui/angular/scripts/inject-env.mjs | 4 +++- .../ag-ui/angular/src/environments/environment.development.ts | 1 + examples/ag-ui/angular/src/environments/environment.ts | 1 + examples/ag-ui/angular/src/environments/generated-keys.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/ag-ui/angular/scripts/inject-env.mjs b/examples/ag-ui/angular/scripts/inject-env.mjs index 47aa0beb..93500c61 100644 --- a/examples/ag-ui/angular/scripts/inject-env.mjs +++ b/examples/ag-ui/angular/scripts/inject-env.mjs @@ -20,13 +20,15 @@ function readDotEnv() { const env = { ...readDotEnv(), ...process.env }; const key = env.GOOGLE_MAPS_API_KEY ?? ''; +const mapId = env.GOOGLE_MAPS_MAP_ID ?? ''; const targetPath = resolve(__dirname, '../src/environments/generated-keys.local.ts'); const contents = `// SPDX-License-Identifier: MIT // AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand. export const GENERATED_KEYS = { googleMaps: ${JSON.stringify(key)}, + googleMapsMapId: ${JSON.stringify(mapId)}, } as const; `; writeFileSync(targetPath, contents); -console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length})`); +console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`); diff --git a/examples/ag-ui/angular/src/environments/environment.development.ts b/examples/ag-ui/angular/src/environments/environment.development.ts index 034218f6..4deaa83b 100644 --- a/examples/ag-ui/angular/src/environments/environment.development.ts +++ b/examples/ag-ui/angular/src/environments/environment.development.ts @@ -7,4 +7,5 @@ export const environment = { telemetry: { enabled: false, sampleRate: 1 }, license: undefined as string | undefined, googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, }; diff --git a/examples/ag-ui/angular/src/environments/environment.ts b/examples/ag-ui/angular/src/environments/environment.ts index b6dc8f39..12a99d0c 100644 --- a/examples/ag-ui/angular/src/environments/environment.ts +++ b/examples/ag-ui/angular/src/environments/environment.ts @@ -7,4 +7,5 @@ export const environment = { telemetry: { enabled: false, sampleRate: 1 }, license: undefined as string | undefined, googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, }; diff --git a/examples/ag-ui/angular/src/environments/generated-keys.ts b/examples/ag-ui/angular/src/environments/generated-keys.ts index 86059ef4..7da7b655 100644 --- a/examples/ag-ui/angular/src/environments/generated-keys.ts +++ b/examples/ag-ui/angular/src/environments/generated-keys.ts @@ -4,4 +4,5 @@ // Ships empty (CI has no key); local/preview builds get the real value. export const GENERATED_KEYS = { googleMaps: '', + googleMapsMapId: '', } as const; From c00f23593f60de5b4da9bac078a730c493fe4540 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 21:58:52 -0700 Subject: [PATCH 07/13] feat(ag-ui): load Maps marker library for advanced markers --- examples/ag-ui/angular/src/app/google-maps-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ag-ui/angular/src/app/google-maps-loader.ts b/examples/ag-ui/angular/src/app/google-maps-loader.ts index 2cfc4b78..297899ee 100644 --- a/examples/ag-ui/angular/src/app/google-maps-loader.ts +++ b/examples/ag-ui/angular/src/app/google-maps-loader.ts @@ -50,7 +50,7 @@ export class GoogleMapsLoader { } const script = doc.createElement('script'); - script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding,marker`; script.async = true; script.setAttribute('data-google-maps', ''); script.addEventListener('load', () => this.loaded.set(true)); From fabb7a246a269a608df3cd87fd2ffd75a9e0f601 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 24 Jun 2026 22:01:34 -0700 Subject: [PATCH 08/13] feat(ag-ui): migrate to AdvancedMarkerElement + cloud dark map style --- .../angular/src/app/map-canvas.component.ts | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/examples/ag-ui/angular/src/app/map-canvas.component.ts b/examples/ag-ui/angular/src/app/map-canvas.component.ts index bb965692..2eeff251 100644 --- a/examples/ag-ui/angular/src/app/map-canvas.component.ts +++ b/examples/ag-ui/angular/src/app/map-canvas.component.ts @@ -10,25 +10,11 @@ import { viewChild, viewChildren, } from '@angular/core'; -import { GoogleMap, MapInfoWindow, MapMarker, MapPolyline } from '@angular/google-maps'; +import { GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline } from '@angular/google-maps'; import { ItineraryStop, ItineraryStore } from './itinerary-store'; import { computeBounds } from './map-bounds'; import { GoogleMapsLoader } from './google-maps-loader'; - -const DARK_STYLE: google.maps.MapTypeStyle[] = [ - { elementType: 'geometry', stylers: [{ color: '#1d2c4d' }] }, - { elementType: 'labels.text.fill', stylers: [{ color: '#8ec3b9' }] }, - { elementType: 'labels.text.stroke', stylers: [{ color: '#1a3646' }] }, - { featureType: 'administrative.country', elementType: 'geometry.stroke', stylers: [{ color: '#4b6878' }] }, - { featureType: 'landscape.man_made', elementType: 'geometry.stroke', stylers: [{ color: '#334e87' }] }, - { featureType: 'landscape.natural', elementType: 'geometry', stylers: [{ color: '#023e58' }] }, - { featureType: 'poi', elementType: 'geometry', stylers: [{ color: '#283d6a' }] }, - { featureType: 'road', elementType: 'geometry', stylers: [{ color: '#304a7d' }] }, - { featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#98a5be' }] }, - { featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#283d6a' }] }, - { featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0e1626' }] }, - { featureType: 'water', elementType: 'labels.text.fill', stylers: [{ color: '#4e6d70' }] }, -]; +import { environment } from '../environments/environment'; const DAY_COLORS = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']; const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 }; @@ -36,7 +22,7 @@ const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 }; @Component({ selector: 'app-map-canvas', standalone: true, - imports: [GoogleMap, MapInfoWindow, MapMarker, MapPolyline], + imports: [GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline], changeDetection: ChangeDetectionStrategy.OnPush, template: ` +# AG-UI Itinerary example (Angular) + +A trip-planner demo where the agent edits a live itinerary UI over the +[AG-UI](https://github.com/cacheplane/angular-agent-framework) transport. It has a +chat/panel layout and an **App mode** that swaps the panel for a full-bleed +Google Map cockpit. + +## Google Maps (App mode) + +App mode renders a Google Map, which needs a Maps JavaScript API key. The key is +read from the repo-root `.env` at build time (via `scripts/inject-env.mjs`, which +writes a gitignored `src/environments/generated-keys.local.ts`). Add to the root +`.env`: + +``` +GOOGLE_MAPS_API_KEY=your_api_key_here +``` + +Without a key, App mode's map stays disabled (the rest of the demo still works). + +### Dark map theme (optional) + +App-mode markers use Google's **Advanced Markers**, which require the map to have +a **Map ID** — and a map with a Map ID takes its styling from a **cloud-based map +style** (the inline JSON dark theme no longer applies). To get the dark theme: + +1. In the [Google Cloud Console](https://console.cloud.google.com/google/maps-apis/studio/maps), + create a **vector Map ID** and a **dark map style**, and associate them. +2. Add the id to the root `.env`: + + ``` + GOOGLE_MAPS_MAP_ID=your_map_id_here + ``` + +Without `GOOGLE_MAPS_MAP_ID`, the demo falls back to Google's `DEMO_MAP_ID` and +renders a **light** map — markers and routes still work, only the dark styling is +absent. From 0c6e8aa126b5ca88a349f14b564208811931550b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 10:17:58 -0700 Subject: [PATCH 10/13] fix(ag-ui): fitBounds reserves the App-mode panel insets so no marker hides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map is full-bleed under the floating itinerary overlay (left) and the chat sidebar drawer (right), but fitBounds used uniform 48px padding — so it framed stops across the whole width and the rightmost marker landed under the chat rail. Measure each panel's footprint live (adapts to the drawer's open/closed state + responsive widths) and pass it as asymmetric fitBounds padding; fall back to uniform 48 when the panels are absent (unit tests / non-App-mode). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../angular/src/app/map-canvas.component.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/examples/ag-ui/angular/src/app/map-canvas.component.ts b/examples/ag-ui/angular/src/app/map-canvas.component.ts index 2eeff251..62cdd2a7 100644 --- a/examples/ag-ui/angular/src/app/map-canvas.component.ts +++ b/examples/ag-ui/angular/src/app/map-canvas.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, + ElementRef, computed, effect, inject, @@ -78,6 +79,7 @@ const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 }; export class MapCanvasComponent { protected readonly store = inject(ItineraryStore); protected readonly loader = inject(GoogleMapsLoader); + private readonly hostRef = inject>(ElementRef); protected readonly center = signal(PARIS_CENTER); protected readonly zoom = signal(12); // A mapId is REQUIRED for advanced markers; a mapId map ignores inline JSON @@ -149,10 +151,43 @@ export class MapCanvasComponent { return; } const b = computeBounds(stops); - if (b) map.fitBounds(b, 48); // >=2 stops: fitBounds overrides center/zoom imperatively + if (b) map.fitBounds(b, this.fitPadding()); // >=2 stops: fitBounds overrides center/zoom imperatively }); } + /** + * fitBounds padding (px per side) that reserves the App-mode panels floating + * over the full-bleed map, so a stop never frames *underneath* them — the + * reported bug was the rightmost marker hiding under the open chat rail. + * + * Measured live (not hardcoded) so it adapts to the chat drawer's open/closed + * state and the responsive panel widths: + * - left = our floating itinerary overlay card's footprint + * - right = the chat sidebar drawer's footprint (the gap its push leaves + * between `.chat-sidebar__content` and the map's right edge) + * Falls back to a uniform base when the panels aren't present (unit tests, + * non-App-mode layouts), preserving the prior behavior. + */ + private fitPadding(): google.maps.Padding { + const BASE = 48; + const GAP = 24; + const pad: google.maps.Padding = { top: BASE, right: BASE, bottom: BASE, left: BASE }; + const mapRect = this.hostRef.nativeElement.getBoundingClientRect(); + if (mapRect.width === 0) return pad; // not laid out (e.g. jsdom) — uniform + + const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay'); + if (overlay) { + const r = overlay.getBoundingClientRect(); + if (r.width > 0) pad.left = Math.max(BASE, Math.round(r.right - mapRect.left + GAP)); + } + const content = document.querySelector('.chat-sidebar__content'); + if (content) { + const occupied = mapRect.right - content.getBoundingClientRect().right; + if (occupied > 1) pad.right = Math.max(BASE, Math.round(occupied + GAP)); + } + return pad; + } + protected onMarkerClick(s: ItineraryStop): void { this.store.focus(s.id); } From c0907bae61cf38b6ddc10c692b3a5c1e4da620be Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 10:17:58 -0700 Subject: [PATCH 11/13] feat(ag-ui): App mode coexists with popup; embed toggles App mode off App mode previously forced the sidebar route and disabled the other mode buttons. New rule: App mode layers over the map via the sidebar (right rail) OR popup (floating bubble), so switching between those keeps App mode on; embed is full-bleed chat that would cover the map, so selecting it turns App mode off. The persist effect now restores the actual mode on reload (seeding mode() from location.pathname to dodge the bootstrap race) and coerces a stray embed+appmode=on to sidebar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/app/shell/ag-ui-shell.component.html | 3 +- .../src/app/shell/ag-ui-shell.component.ts | 36 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html index 77c1170d..82a7cbe5 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html @@ -7,8 +7,7 @@ class="ag-ui-shell__segmented-button" [class.is-active]="mode() === option.value" [attr.aria-pressed]="mode() === option.value" - [disabled]="appMode() === 'on' && option.value !== 'sidebar'" - [attr.title]="appMode() === 'on' && option.value !== 'sidebar' ? 'Sidebar mode while App mode is on' : null" + [attr.title]="appMode() === 'on' && option.value === 'embed' ? 'Embed turns App mode off' : null" (click)="onModeChange(option.value)" >{{ option.label }} } diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts index fa8dc7d2..6f70e900 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts @@ -70,7 +70,11 @@ export class AgUiShell { readonly hasMapsKey = (environment.googleMapsApiKey as string).length > 0; // ── Mode from the active route ─────────────────────────────────────────── - readonly mode = signal(this.parseMode(this.router.url)); + // Seed from the real browser path (router.url isn't settled at bootstrap, so + // a fresh reload of e.g. /popup would otherwise read as the default /embed). + readonly mode = signal( + this.parseMode(this.document.defaultView?.location?.pathname ?? this.router.url), + ); protected readonly modeOptions: readonly { value: DemoMode; label: string }[] = [ { value: 'embed', label: 'Embed' }, { value: 'popup', label: 'Popup' }, @@ -169,27 +173,37 @@ export class AgUiShell { scheme: this.colorScheme() === DEFAULTS.scheme ? null : this.colorScheme(), appmode: this.appMode() === 'off' ? null : this.appMode(), }; - // App mode's layout requires the sidebar route (chat as the right rail - // beside the map). On a fresh load with App mode persisted on, a - // route-relative navigate([]) resolves against the not-yet-settled - // initial navigation and lands on the default /embed — mounting - // EmbedMode over the map. Navigate to the absolute /sidebar whenever - // App mode is on so a reload restores the cockpit; otherwise keep the - // current route ([]) and just sync query params. - const commands = this.appMode() === 'on' ? ['/', 'sidebar'] : []; + // App mode is compatible with the sidebar AND popup routes (chat as a + // right rail / floating bubble over the map) but mutually exclusive with + // embed (full-bleed chat would cover the map). When App mode is on, + // navigate to the ABSOLUTE current mode so a fresh reload restores the + // cockpit (a route-relative [] resolves against the not-yet-settled + // initial navigation and lands on /embed); coerce a stray embed (e.g. a + // direct /embed?appmode=on) to the default sidebar layout. App mode off: + // keep the current route ([]) and just sync query params. + const m = this.mode(); + const commands = this.appMode() === 'on' ? ['/', m === 'embed' ? 'sidebar' : m] : []; void this.router.navigate(commands, { queryParams: q, queryParamsHandling: 'merge', replaceUrl: true }); }); } protected onModeChange(next: DemoMode | string): void { if (!(MODES as readonly string[]).includes(next as string)) return; + // Embed can't coexist with App mode (its full-bleed chat covers the map), + // so selecting Embed while App mode is on turns App mode off. Popup and + // Sidebar layer over the map, so they leave App mode untouched. + if (next === 'embed' && this.appMode() === 'on') { + this.appMode.set('off'); + this.persistence.write('appMode', 'off'); + } void this.router.navigate(['/', next], { queryParamsHandling: 'preserve' }); } onAppModeChange(v: 'on' | 'off'): void { this.appMode.set(v); this.persistence.write('appMode', v); - // Routing is handled by the persist effect, which forces /sidebar whenever - // App mode is on (toggle and reload alike) and keeps the current route off. + // Routing is handled by the persist effect: turning App mode on navigates to + // the current map-compatible mode (coercing embed → sidebar); turning it off + // keeps the current route. } onModelChange(v: string): void { this.model.set(v); this.persistence.write('model', v); } protected onEffortChange(v: string): void { this.effort.set(v); this.persistence.write('effort', v); } From 005cdce7c285ef2985aff35c7ec4b40f14e3badd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 10:56:36 -0700 Subject: [PATCH 12/13] fix(ag-ui): map occupies its own column beside the sidebar drawer (not full width) In Sidebar mode the chat drawer is a solid right rail, but the full-bleed map extended under it, so fitBounds framed stops across the hidden width. Inset the map by the chat lib's published drawer footprint (--ngaf-chat-occupy-right) so it occupies its own column and markers center within the visible area; Popup mode mounts no drawer, so the var is 0 and the map stays full-bleed under the floating bubble. The descendant selector + width:auto beat the map component's own :host { width: 100% } (an over-constrained left/right/width otherwise keeps the full width and silently ignores the inset). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../angular/src/app/shell/ag-ui-shell.component.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css index b14269e4..387355a1 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css @@ -247,9 +247,18 @@ position: relative; display: flex; } -.ag-ui-shell__map { +/* In Sidebar mode the chat drawer is a solid right rail; shrink the map out + from under it so it occupies its own column (not the full browser width) and + markers frame within the visible area. The chat lib publishes the open + drawer's footprint as --ngaf-chat-occupy-right; in Popup mode no drawer + mounts so the var is unset (0) and the map stays full-bleed under the + floating bubble. The descendant selector + width:auto are required to beat + the map component's own `:host { width: 100% }` — otherwise an over- + constrained left/right/width keeps the full width and ignores the inset. */ +.ag-ui-shell__app-body .ag-ui-shell__map { position: absolute; - inset: 0; + inset: 0 var(--ngaf-chat-occupy-right, 0px) 0 0; + width: auto; } .ag-ui-shell__itinerary-overlay { position: absolute; From f088214be71200639cd7d87504d6d9455ce14db3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 27 Jun 2026 15:29:11 -0700 Subject: [PATCH 13/13] wip(ag-ui): map into chat-sidebar flex content slot (replaces absolute/occupy hack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App mode now renders the map inside SidebarMode's content slot, so it fills the area left of the drawer via the lib's flex layout (PR #740) — no shell-level absolute positioning or --ngaf-chat-occupy-right inset. Popup mode keeps the full-bleed shell map. Adds a ResizeObserver in MapCanvasComponent that re-triggers google.maps resize + re-fit when the content slot resizes (the push shrink / mode toggles), so tiles don't render grey at the stale size. NEEDS VERIFICATION in a reliable browser: the agent's flaky headless/extension tooling renders the map only intermittently. One good render confirmed the 981px column + vector map + polyline, but advanced-marker pins did not attach in that pass — unclear if real or a tooling artifact. Verify pins + tiles + the sidebar↔popup toggle before merging. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../angular/src/app/map-canvas.component.ts | 51 ++++++++++++++----- .../src/app/modes/sidebar-mode.component.ts | 25 +++++---- .../src/app/shell/ag-ui-shell.component.css | 16 ++---- .../src/app/shell/ag-ui-shell.component.html | 7 ++- 4 files changed, 63 insertions(+), 36 deletions(-) diff --git a/examples/ag-ui/angular/src/app/map-canvas.component.ts b/examples/ag-ui/angular/src/app/map-canvas.component.ts index 62cdd2a7..179f8ab6 100644 --- a/examples/ag-ui/angular/src/app/map-canvas.component.ts +++ b/examples/ag-ui/angular/src/app/map-canvas.component.ts @@ -3,7 +3,9 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, + afterNextRender, computed, effect, inject, @@ -80,6 +82,7 @@ export class MapCanvasComponent { protected readonly store = inject(ItineraryStore); protected readonly loader = inject(GoogleMapsLoader); private readonly hostRef = inject>(ElementRef); + private readonly destroyRef = inject(DestroyRef); protected readonly center = signal(PARIS_CENTER); protected readonly zoom = signal(12); // A mapId is REQUIRED for advanced markers; a mapId map ignores inline JSON @@ -138,23 +141,43 @@ export class MapCanvasComponent { // and reframing to all stops never fight (they fire on different signals). effect(() => { const map = this.googleMap(); - const stops = this.stopsWithCoords(); - if (!map) return; // is behind @if (loader.loaded()) — not mounted yet - if (stops.length === 0) { - this.center.set(PARIS_CENTER); - this.zoom.set(12); - return; - } - if (stops.length === 1) { - this.center.set({ lat: stops[0].lat!, lng: stops[0].lng! }); - this.zoom.set(13); - return; - } - const b = computeBounds(stops); - if (b) map.fitBounds(b, this.fitPadding()); // >=2 stops: fitBounds overrides center/zoom imperatively + this.stopsWithCoords(); // re-frame on structural change (add/remove/geocode) + if (map) this.frameToBounds(map); // null while is behind the loader gate + }); + + // The map lives in the chat-sidebar's flex content slot, whose width changes + // when the drawer pushes it (and on mode toggles). Google Maps caches its + // viewport size at construction, so without a resize event the tiles render + // grey and fitBounds frames the stale size. Re-sync on every container resize. + afterNextRender(() => { + const ro = new ResizeObserver(() => { + const map = this.googleMap(); + if (!map?.googleMap) return; + google.maps.event.trigger(map.googleMap, 'resize'); + this.frameToBounds(map); + }); + ro.observe(this.hostRef.nativeElement); + this.destroyRef.onDestroy(() => ro.disconnect()); }); } + /** Frame the map to all coord'd stops (>=2: fitBounds; 1: center+zoom; 0: Paris). */ + private frameToBounds(map: GoogleMap): void { + const stops = this.stopsWithCoords(); + if (stops.length === 0) { + this.center.set(PARIS_CENTER); + this.zoom.set(12); + return; + } + if (stops.length === 1) { + this.center.set({ lat: stops[0].lat!, lng: stops[0].lng! }); + this.zoom.set(13); + return; + } + const b = computeBounds(stops); + if (b) map.fitBounds(b, this.fitPadding()); + } + /** * fitBounds padding (px per side) that reserves the App-mode panels floating * over the full-bleed map, so a stop never frames *underneath* them — the diff --git a/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts b/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts index 5189df6f..6f36ddea 100644 --- a/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts +++ b/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts @@ -2,12 +2,13 @@ import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { ChatSidebarComponent, a2uiBasicCatalog } from '@threadplane/chat'; import { AgUiShell } from '../shell/ag-ui-shell.component'; +import { MapCanvasComponent } from '../map-canvas.component'; import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; @Component({ selector: 'sidebar-mode', standalone: true, - imports: [ChatSidebarComponent, WelcomeSuggestionsComponent], + imports: [ChatSidebarComponent, MapCanvasComponent, WelcomeSuggestionsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - + } `, styles: [` :host { display: block; flex: 1; min-height: 0; position: relative; } - /* Projected into chat-sidebar's default content slot so [pushContent] - * applies its right-margin push to this background when the panel - * opens. Sized to fill the visible area below the toolbar. */ + /* The map fills the chat-sidebar content slot (which is flex:1 at threaded + * height); [pushContent] applies the right-margin push for the open drawer. */ + .sidebar-mode__map { display: block; height: 100%; } .sidebar-mode__background { display: grid; place-items: center; diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css index 387355a1..835a6896 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css @@ -247,18 +247,12 @@ position: relative; display: flex; } -/* In Sidebar mode the chat drawer is a solid right rail; shrink the map out - from under it so it occupies its own column (not the full browser width) and - markers frame within the visible area. The chat lib publishes the open - drawer's footprint as --ngaf-chat-occupy-right; in Popup mode no drawer - mounts so the var is unset (0) and the map stays full-bleed under the - floating bubble. The descendant selector + width:auto are required to beat - the map component's own `:host { width: 100% }` — otherwise an over- - constrained left/right/width keeps the full width and ignores the inset. */ -.ag-ui-shell__app-body .ag-ui-shell__map { +/* Popup mode only: the map is the full-bleed backdrop with the chat bubble + floating over it. (Sidebar mode renders the map inside the chat-sidebar's + flex content slot instead — see SidebarMode — so it gets its column there.) */ +.ag-ui-shell__map { position: absolute; - inset: 0 var(--ngaf-chat-occupy-right, 0px) 0 0; - width: auto; + inset: 0; } .ag-ui-shell__itinerary-overlay { position: absolute; diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html index 82a7cbe5..07e5bdd3 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html @@ -56,7 +56,12 @@ @if (appMode() === 'on') {
- + + @if (mode() === 'popup') { + + }