diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 0e90c98f6..3a3e7f466 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3281,7 +3281,7 @@ { "name": "ChatSelectComponent", "kind": "class", - "description": "Generic single-select dropdown. Designed to slot into the chat input pill\n(via [chatInputModelSelect]) but usable anywhere.\n\nInputs:\n options — array of { value, label, disabled? }; required\n value — currently selected value (two-way via model())\n placeholder — trigger label when no option matches; default 'Select'\n disabled — disables the trigger; default false\n menuLabel — aria-label for the popover; defaults to placeholder", + "description": "Generic single-select dropdown. Designed to slot into the chat input pill\n(via [chatInputModelSelect]) but usable anywhere.\n\nThe popover is rendered through a CDK connected overlay (a body-level portal)\nrather than an absolutely-positioned child, so it is never clipped by an\nancestor's `overflow` and never trapped by an ancestor `transform` (e.g. a\nsliding chat-sidebar panel). CDK's flexible position strategy flips and\nshifts the menu to keep it inside the viewport.\n\nInputs:\n options — array of { value, label, disabled? }; required\n value — currently selected value (two-way via model())\n placeholder — trigger label when no option matches; default 'Select'\n disabled — disables the trigger; default false\n menuLabel — aria-label for the popover; defaults to placeholder\n panelClass — extra class(es) on the overlay panel, for consumer styling\n (the menu is portaled to the body, so `::ng-deep chat-select\n .chat-select__menu` no longer reaches it — target the panel\n class instead).", "params": [], "examples": [], "properties": [ @@ -3315,6 +3315,24 @@ "description": "", "optional": false }, + { + "name": "overlayPositions", + "type": "ConnectedPosition[]", + "description": "", + "optional": false + }, + { + "name": "panelClass", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "panelClasses", + "type": "Signal", + "description": "", + "optional": false + }, { "name": "placeholder", "type": "InputSignal", diff --git a/docs/superpowers/plans/2026-06-25-ag-ui-app-mode-promo.md b/docs/superpowers/plans/2026-06-25-ag-ui-app-mode-promo.md new file mode 100644 index 000000000..4732734b6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-ag-ui-app-mode-promo.md @@ -0,0 +1,431 @@ +# AG-UI App-mode promo hero — 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:** Replace the placeholder launcher hint in sidebar mode (App mode off) with a preview-led marketing hero that sells the App-mode cockpit and the Threadplane primitives, with a CTA that enables App mode. + +**Architecture:** A new isolated standalone `AppModePromoComponent` (signal `hasMapsKey` input + `enable` output) renders a centered poster card: a static Paris map image backdrop with an always-dark caption bar (eyebrow, headline, four Threadplane pills, CTA). `SidebarMode` renders it in place of the hint, wiring the shell's `hasMapsKey` and `onAppModeChange('on')`. The map image is a user-provided screenshot committed to `public/`; a dark fallback background keeps the card intact until it lands. + +**Tech Stack:** Angular 21 (standalone, zoneless, signals, OnPush), Material Symbols Outlined icons (ligatures), vitest + TestBed (unit), Playwright (e2e), `sips` (image processing). Spec: `docs/superpowers/specs/2026-06-25-ag-ui-app-mode-promo-design.md`. + +--- + +## File Structure + +- Create: `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts` — the hero component (template + styles inline, matching `itinerary-panel.component.ts` style). +- Create: `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.spec.ts` — unit tests. +- Modify: `examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts` — render the promo instead of the hint; drop the now-dead `.sidebar-mode__hint` style. +- Create: `examples/ag-ui/angular/e2e/app-mode-promo.spec.ts` — e2e (promo visible in sidebar mode, key-independent assertions). +- Add (asset, when provided): `examples/ag-ui/angular/public/app-mode-preview.webp` — processed from the user's `app-mode-preview-raw.png`. + +--- + +## Task 1: AppModePromoComponent (TDD) + +**Files:** +- Create: `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts` +- Test: `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { AppModePromoComponent } from './app-mode-promo.component'; + +function setup(hasMapsKey: boolean) { + TestBed.configureTestingModule({ imports: [AppModePromoComponent] }); + const fixture = TestBed.createComponent(AppModePromoComponent); + fixture.componentRef.setInput('hasMapsKey', hasMapsKey); + fixture.detectChanges(); + return fixture; +} + +describe('AppModePromoComponent', () => { + it('renders the headline, four capability pills, and the CTA', () => { + const el: HTMLElement = setup(true).nativeElement; + expect(el.textContent).toContain('See your trip come alive on a live map'); + expect(el.querySelectorAll('.promo__pill').length).toBe(4); + const cta = el.querySelector('.promo__cta'); + expect(cta).toBeTruthy(); + expect(cta!.disabled).toBe(false); + }); + + it('emits enable when the CTA is clicked', () => { + const fixture = setup(true); + let emitted = 0; + fixture.componentInstance.enable.subscribe(() => (emitted += 1)); + fixture.nativeElement.querySelector('.promo__cta')!.click(); + expect(emitted).toBe(1); + }); + + it('disables the CTA and shows the key note when hasMapsKey is false', () => { + const el: HTMLElement = setup(false).nativeElement; + const cta = el.querySelector('.promo__cta'); + expect(cta!.disabled).toBe(true); + expect(el.textContent).toContain('GOOGLE_MAPS_API_KEY'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test examples-ag-ui-angular` +Expected: FAIL — cannot resolve `./app-mode-promo.component` (module does not exist yet). + +- [ ] **Step 3: Write the component** + +Create `examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +/** + * Marketing hero shown in sidebar mode while App mode is off. Sells the + * App-mode map cockpit and the Threadplane primitives behind it, with a CTA + * that enables App mode. + * + * Isolated contract — no shell coupling: + * - `hasMapsKey`: whether GOOGLE_MAPS_API_KEY is configured (gates the CTA). + * - `enable`: emitted when the user clicks the CTA. + */ +@Component({ + selector: 'app-mode-promo', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ Preview of the App-mode map cockpit +
+
+ + + Built with Threadplane + +

See your trip come alive on a live map

+

A map cockpit where the agent edits your itinerary in real time.

+
    +
  • Client tools
  • +
  • Generative UI
  • +
  • Human-in-the-loop
  • +
  • Shared state
  • +
+
+
+ + @if (!hasMapsKey()) { +

Set GOOGLE_MAPS_API_KEY to enable

+ } +
+
+
+ `, + styles: [` + :host { display: block; width: 100%; } + .promo { + position: relative; + width: min(780px, 100%); + margin: 0 auto; + aspect-ratio: 16 / 10; + border-radius: var(--ngaf-chat-radius-card, 12px); + overflow: hidden; + background: #0e1626; + border: 1px solid var(--ngaf-chat-separator, rgba(255, 255, 255, 0.12)); + animation: promo-rise 320ms ease both; + } + .promo__img { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; display: block; + } + .promo__caption { + position: absolute; left: 0; right: 0; bottom: 0; + display: flex; flex-wrap: wrap; align-items: center; gap: 16px; + padding: 16px 20px; + background: rgba(8, 15, 28, 0.96); + border-top: 1px solid rgba(255, 255, 255, 0.12); + } + .promo__copy { flex: 1 1 320px; min-width: 0; } + .promo__eyebrow { + display: inline-flex; align-items: center; gap: 6px; + background: rgba(37, 99, 235, 0.18); color: #93b4f5; + font-size: 12px; padding: 3px 10px; border-radius: 8px; margin-bottom: 9px; + } + .promo__title { font-size: 20px; font-weight: 600; color: #f2f5fb; line-height: 1.3; margin: 0 0 4px; } + .promo__subtitle { font-size: 13px; color: #9aa6bd; line-height: 1.5; margin: 0 0 12px; } + .promo__pills { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; } + .promo__pill { + display: inline-flex; align-items: center; gap: 6px; + background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); + color: #c2cbdc; font-size: 12px; padding: 5px 10px; border-radius: 8px; + } + .promo__action { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } + .promo__cta { + display: inline-flex; align-items: center; gap: 8px; + background: var(--ngaf-chat-primary, #2563eb); color: var(--ngaf-chat-on-primary, #fff); + border: none; font: inherit; font-size: 14px; font-weight: 600; + padding: 11px 18px; border-radius: 8px; cursor: pointer; + } + .promo__cta:disabled { opacity: 0.5; cursor: not-allowed; } + .promo__note { font-size: 12px; color: #9aa6bd; margin: 0; } + .promo__note code { font-family: var(--ngaf-chat-font-mono, monospace); } + .promo__icon { font-family: 'Material Symbols Outlined', sans-serif; font-size: 18px; line-height: 1; } + .promo__icon--sm { font-size: 15px; } + @keyframes promo-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + @media (prefers-reduced-motion: reduce) { .promo { animation: none; } } + `], +}) +export class AppModePromoComponent { + /** Whether GOOGLE_MAPS_API_KEY is configured; gates the CTA. */ + readonly hasMapsKey = input(false); + /** Emitted when the user clicks the "Enable app mode" CTA. */ + readonly enable = output(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test examples-ag-ui-angular` +Expected: PASS — the three `AppModePromoComponent` tests pass alongside the existing suite. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts examples/ag-ui/angular/src/app/modes/app-mode-promo.component.spec.ts +git commit -m "feat(ag-ui): App-mode promo hero component" +``` + +--- + +## Task 2: Wire the promo into SidebarMode + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts` + +- [ ] **Step 1: Replace the hint with the promo in the template** + +In `sidebar-mode.component.ts`, replace this block: + +```html + +``` + +with: + +```html + +``` + +- [ ] **Step 2: Import the component** + +In `sidebar-mode.component.ts`, add the import near the other imports: + +```ts +import { AppModePromoComponent } from './app-mode-promo.component'; +``` + +and add `AppModePromoComponent` to the `@Component({ imports: [...] })` array (alongside `ChatSidebarComponent`, `WelcomeSuggestionsComponent`). + +- [ ] **Step 3: Remove the now-dead hint style** + +In the same file's `styles`, delete the `.sidebar-mode__hint` rule if present (the `.sidebar-mode__background` centering rule stays — it now centers the promo card). If no `.sidebar-mode__hint` rule exists, skip. + +- [ ] **Step 4: Verify it builds** + +Run: `npx nx build examples-ag-ui-angular --configuration=development` +Expected: "Application bundle generation complete." with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts +git commit -m "feat(ag-ui): render App-mode promo in sidebar mode" +``` + +--- + +## Task 3: e2e — promo is shown in sidebar mode + +The CTA's enabled/clickable state depends on a Maps key (absent in CI), so the e2e asserts only key-independent facts: the promo renders with its headline and four pills in plain sidebar mode. The CTA emit is covered by the unit test (Task 1); the full click→cockpit path is covered by live-smoke (Task 5). + +**Files:** +- Create: `examples/ag-ui/angular/e2e/app-mode-promo.spec.ts` + +- [ ] **Step 1: Write the e2e test** + +Create `examples/ag-ui/angular/e2e/app-mode-promo.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +// In sidebar mode with App mode off, the left area shows a marketing hero for +// App mode (not the old launcher hint). A direct /sidebar URL bounces to /embed +// on first load (the shell persist effect's route-relative navigate), so we +// reach sidebar mode by clicking the toolbar "Sidebar" button after load. +test('sidebar mode shows the App-mode promo with the Threadplane pills', async ({ page }) => { + await openDemo(page, '/'); + + await page + .locator('.ag-ui-shell__segmented-button') + .filter({ hasText: 'Sidebar' }) + .click(); + + const promo = page.locator('app-mode-promo'); + await expect(promo).toBeVisible(); + await expect(promo).toContainText('See your trip come alive on a live map'); + await expect(promo.locator('.promo__pill')).toHaveCount(4); +}); +``` + +- [ ] **Step 2: Run the e2e test** + +Ensure the frontend dev server (:4201) and agent (:8000) are running (see Task 5 for the serve commands), then run: + +`npx nx e2e examples-ag-ui-angular --grep "App-mode promo"` + +Expected: PASS (1 test). If the runner needs the standard port, run the full `npx nx e2e examples-ag-ui-angular` and confirm the new test passes among the suite. + +- [ ] **Step 3: Commit** + +```bash +git add examples/ag-ui/angular/e2e/app-mode-promo.spec.ts +git commit -m "test(ag-ui): e2e for the App-mode promo in sidebar mode" +``` + +--- + +## Task 4: Static map image asset + +The component references `/app-mode-preview.webp` and falls back to a dark background (`.promo { background: #0e1626 }`) when the file is absent — so the build and tests are green without it. This task processes the user-provided screenshot into the committed asset. + +**Files:** +- Input (user-provided): `examples/ag-ui/angular/public/app-mode-preview-raw.png` +- Add: `examples/ag-ui/angular/public/app-mode-preview.webp` + +- [ ] **Step 1: Confirm the raw screenshot is present** + +Run: `ls -la examples/ag-ui/angular/public/app-mode-preview-raw.png` +Expected: the file exists. If it does NOT exist yet, STOP this task — the dark fallback keeps everything green; resume when the user drops the screenshot. + +- [ ] **Step 2: Resize + convert to webp** + +Run: + +```bash +cd examples/ag-ui/angular/public +sips -Z 1600 -s format webp app-mode-preview-raw.png --out app-mode-preview.webp +ls -la app-mode-preview.webp +``` + +Expected: `app-mode-preview.webp` created, max dimension 1600px (aspect preserved; `object-fit: cover` in the component handles final framing), file size well under 300 KB. If `sips` reports webp is unsupported on this macOS, fall back to JPEG (`-s format jpeg --out app-mode-preview.jpg`) and update the `` in `app-mode-promo.component.ts` to `/app-mode-preview.jpg`. + +- [ ] **Step 3: Remove the raw source** + +Run: `rm examples/ag-ui/angular/public/app-mode-preview-raw.png` + +- [ ] **Step 4: Commit** + +```bash +git add examples/ag-ui/angular/public/app-mode-preview.webp +git commit -m "feat(ag-ui): add App-mode promo map background image" +``` + +--- + +## Task 5: Verify and ship + +**Files:** none (verification + push) + +- [ ] **Step 1: Lint** + +Run: `npx nx lint examples-ag-ui-angular` +Expected: 0 errors (pre-existing warnings are fine). + +- [ ] **Step 2: Unit tests** + +Run: `npx nx test examples-ag-ui-angular` +Expected: PASS, including the three `AppModePromoComponent` tests. + +- [ ] **Step 3: Build + re-inject the Maps key** + +The `inject-env` Nx target rewrites `generated-keys.local.ts` with an empty key on a keyless build, so re-inject after building: + +```bash +npx nx build examples-ag-ui-angular --configuration=development +export GOOGLE_MAPS_API_KEY="$(grep -E '^GOOGLE_MAPS_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '"')" +GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY" node examples/ag-ui/angular/scripts/inject-env.mjs +``` + +Expected: build green; "wrote generated-keys.local.ts (key length: 39)". + +- [ ] **Step 4: Live-smoke the full click-through (with the local key)** + +Start the servers if not running: + +```bash +# agent (:8000) — OPENAI_API_KEY only, AG_UI_INTERNAL_TOKEN unset +cd examples/ag-ui/python && env -u AG_UI_INTERNAL_TOKEN OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '"')" uv run uvicorn src.server:app --port 8000 & +# frontend (:4201) — with the Maps key +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/ag-ui-app-mode-mapfix && GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY" npx nx serve examples-ag-ui-angular --port 4201 & +``` + +Then in the browser (DOM probes, not screenshots — the WebGL map doesn't capture in the harness): +1. Load `/`, click the "Sidebar" toolbar button → confirm `app-mode-promo` is visible and its CTA is **enabled** (key present). +2. Click the CTA → confirm `appMode` flips on: `.ag-ui-shell__app-body` present, `app-map-canvas` mounted, `app-itinerary-panel.ag-ui-shell__itinerary-overlay` present, and the promo is gone. +3. Reload → confirm it stays in the cockpit (`/sidebar?appmode=on`, not bounced to embed). + +Expected: all three hold. + +- [ ] **Step 5: Push to PR #736** + +```bash +git push origin worktree-ag-ui-app-mode-mapfix +``` + +Expected: branch updated; PR #736 reflects the promo commits. + +--- + +## Self-Review notes + +- **Spec coverage:** component + isolated input/output (Task 1); placement under the existing gate + dead-style removal (Task 2); static image asset with dark fallback + processing (Task 4); CTA→enable→cockpit and no-key disable+note (Tasks 1, 5); responsive wrap + a11y/motion (CSS in Task 1); tests (Tasks 1, 3, 5). All spec sections map to a task. +- **No stale hint assertions:** grep confirmed only the component sources reference the old hint copy — no test updates needed. `examples/chat` keeps its own hint (App mode is AG-UI-only); not touched. +- **Type consistency:** `hasMapsKey` (input) and `enable` (output) names are identical across the component, its spec, and the `SidebarMode` template binding. CSS class names (`.promo__pill`, `.promo__cta`) match between component, unit test, and e2e. diff --git a/docs/superpowers/plans/2026-06-27-chat-overlay-primitive.md b/docs/superpowers/plans/2026-06-27-chat-overlay-primitive.md new file mode 100644 index 000000000..cd908416b --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-chat-overlay-primitive.md @@ -0,0 +1,1015 @@ +# Hand-rolled connected-overlay primitive — 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:** Replace `@threadplane/chat`'s `@angular/cdk` peer dependency with a small, public, zero-runtime-dep connected-overlay primitive (body portal + flip/clamp positioning + live reposition + a11y), with `chat-select` as the first consumer. + +**Architecture:** A pure positioning function (`connected-position.ts`), a singleton body-portal container with self-injected structural CSS (`overlay-container.ts`), and a declarative directive pair (`[chatOverlayOrigin]` + `[chatConnectedOverlay]` on an ``) that portals template content to the container, positions it connected to the origin, repositions live on scroll/resize, and handles outside-click / focus-return / Tab-close. `chat-select` swaps its `cdk*` overlay attributes for the `chat*` equivalents. + +**Tech Stack:** Angular 21 (standalone, signals, `effect`, `input`/`output`, `DestroyRef`), TypeScript, Vitest (unit), Playwright (e2e). DOM APIs only — no `@angular/cdk`. + +**Working directory:** worktree `/Users/blove/repos/angular-agent-framework/.claude/worktrees/ag-ui-app-mode-mapfix`, branch `blove/ag-ui-app-mode-promo`. All paths below are repo-relative. + +**Reference:** `~/repos/components/src/cdk/overlay` (CDK overlay source) for positioning/lifecycle/a11y semantics. Spec: `docs/superpowers/specs/2026-06-27-chat-overlay-primitive-design.md`. + +--- + +## Task 1: Pure connected-position function + +**Files:** +- Create: `libs/chat/src/lib/primitives/overlay/connected-position.ts` +- Test: `libs/chat/src/lib/primitives/overlay/connected-position.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/chat/src/lib/primitives/overlay/connected-position.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { computeConnectedPosition, narrowViewport, type ConnectedPosition } from './connected-position'; + +const ABOVE_RIGHT: ConnectedPosition = { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -8 }; +const BELOW_RIGHT: ConnectedPosition = { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }; +const POSITIONS = [ABOVE_RIGHT, BELOW_RIGHT]; + +function rect(left: number, top: number, width: number, height: number) { + return { left, top, width, height, right: left + width, bottom: top + height }; +} + +describe('computeConnectedPosition', () => { + const viewport = narrowViewport({ innerWidth: 1000, innerHeight: 800 }, 8); + + it('uses the first position when it fully fits (above, right-aligned)', () => { + const origin = rect(600, 400, 120, 32); // mid-screen trigger + const r = computeConnectedPosition({ originRect: origin, overlaySize: { width: 200, height: 150 }, viewport, positions: POSITIONS }); + // above: overlay bottom sits 8px above origin top (400) → top = 400 - 8 - 150 = 242 + expect(r.top).toBe(242); + // end-aligned: overlay right edge = origin right (720) → left = 720 - 200 = 520 + expect(r.left).toBe(520); + expect(r.position).toBe(ABOVE_RIGHT); + }); + + it('flips to the second position when the first overflows the top edge', () => { + const origin = rect(600, 20, 120, 32); // near top → no room above for a 150-tall menu + const r = computeConnectedPosition({ originRect: origin, overlaySize: { width: 200, height: 150 }, viewport, positions: POSITIONS }); + // below: overlay top = origin bottom (52) + 8 = 60 + expect(r.top).toBe(60); + expect(r.position).toBe(BELOW_RIGHT); + }); + + it('pushes onto screen when no position fully fits (clamps within viewport)', () => { + const origin = rect(950, 400, 40, 32); // far right; 400-wide menu cannot fit either way + const r = computeConnectedPosition({ originRect: origin, overlaySize: { width: 400, height: 150 }, viewport, positions: POSITIONS }); + // right edge clamped to viewport.right (992) → left = 992 - 400 = 592; never < margin + expect(r.left).toBe(592); + expect(r.left).toBeGreaterThanOrEqual(8); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx nx test chat -- connected-position` +Expected: FAIL — cannot resolve `./connected-position`. + +- [ ] **Step 3: Write the implementation** + +Create `libs/chat/src/lib/primitives/overlay/connected-position.ts`: + +```ts +// libs/chat/src/lib/primitives/overlay/connected-position.ts +// SPDX-License-Identifier: MIT +// +// Minimal port of CDK's FlexibleConnectedPositionStrategy fit logic +// (~/repos/components/src/cdk/overlay/position/flexible-connected-position-strategy.ts): +// _getOriginPoint + _getOverlayPoint + _getOverlayFit + _pushOverlayOnScreen. +// Omits flexible-dimensions, grow-after-open, RTL, and virtual-keyboard handling. + +export type HorizontalConnectionPos = 'start' | 'center' | 'end'; +export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; + +export interface ConnectedPosition { + originX: HorizontalConnectionPos; + originY: VerticalConnectionPos; + overlayX: HorizontalConnectionPos; + overlayY: VerticalConnectionPos; + offsetX?: number; + offsetY?: number; +} + +export interface Size { + width: number; + height: number; +} +interface Point { + x: number; + y: number; +} +export interface Rect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export interface OverlayPositionResult { + top: number; + left: number; + position: ConnectedPosition; +} + +export interface ComputeArgs { + originRect: Rect; + overlaySize: Size; + /** Viewport rect, already narrowed by the desired margin. */ + viewport: Rect; + positions: ConnectedPosition[]; +} + +function originPoint(origin: Rect, pos: ConnectedPosition): Point { + const x = pos.originX === 'center' ? origin.left + origin.width / 2 : pos.originX === 'start' ? origin.left : origin.right; + const y = pos.originY === 'center' ? origin.top + origin.height / 2 : pos.originY === 'top' ? origin.top : origin.bottom; + return { x, y }; +} + +function overlayPoint(origin: Point, size: Size, pos: ConnectedPosition): Point { + let x = origin.x; + if (pos.overlayX === 'center') x -= size.width / 2; + else if (pos.overlayX === 'end') x -= size.width; + let y = origin.y; + if (pos.overlayY === 'center') y -= size.height / 2; + else if (pos.overlayY === 'bottom') y -= size.height; + return { x: x + (pos.offsetX ?? 0), y: y + (pos.offsetY ?? 0) }; +} + +function fitArea(point: Point, size: Size, viewport: Rect): { area: number; fits: boolean } { + const left = point.x; + const right = point.x + size.width; + const top = point.y; + const bottom = point.y + size.height; + const visibleW = Math.max(0, Math.min(right, viewport.right) - Math.max(left, viewport.left)); + const visibleH = Math.max(0, Math.min(bottom, viewport.bottom) - Math.max(top, viewport.top)); + const fits = left >= viewport.left && right <= viewport.right && top >= viewport.top && bottom <= viewport.bottom; + return { area: visibleW * visibleH, fits }; +} + +function pushOnScreen(point: Point, size: Size, viewport: Rect): Point { + const maxLeft = viewport.right - size.width; + const maxTop = viewport.bottom - size.height; + return { + x: Math.max(viewport.left, Math.min(point.x, maxLeft)), + y: Math.max(viewport.top, Math.min(point.y, maxTop)), + }; +} + +export function computeConnectedPosition(args: ComputeArgs): OverlayPositionResult { + const { originRect, overlaySize, viewport, positions } = args; + let best: { point: Point; pos: ConnectedPosition; area: number } | null = null; + + for (const pos of positions) { + const point = overlayPoint(originPoint(originRect, pos), overlaySize, pos); + const { area, fits } = fitArea(point, overlaySize, viewport); + if (fits) { + return { top: point.y, left: point.x, position: pos }; + } + if (!best || area > best.area) best = { point, pos, area }; + } + + const pushed = pushOnScreen(best!.point, overlaySize, viewport); + return { top: pushed.y, left: pushed.x, position: best!.pos }; +} + +export function narrowViewport(win: { innerWidth: number; innerHeight: number }, margin: number): Rect { + return { + left: margin, + top: margin, + right: win.innerWidth - margin, + bottom: win.innerHeight - margin, + width: win.innerWidth - 2 * margin, + height: win.innerHeight - 2 * margin, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx nx test chat -- connected-position` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/overlay/connected-position.ts libs/chat/src/lib/primitives/overlay/connected-position.spec.ts +git commit -m "feat(chat): connected-position fit/flip/push function (overlay primitive)" +``` + +--- + +## Task 2: Body-portal container + +**Files:** +- Create: `libs/chat/src/lib/primitives/overlay/overlay-container.ts` +- Test: `libs/chat/src/lib/primitives/overlay/overlay-container.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/chat/src/lib/primitives/overlay/overlay-container.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, afterEach } from 'vitest'; +import { getOverlayContainer } from './overlay-container'; + +afterEach(() => { + document.querySelector('.chat-overlay-container')?.remove(); + document.getElementById('chat-overlay-structure')?.remove(); +}); + +describe('getOverlayContainer', () => { + it('creates a single body-level container and injects structural CSS once', () => { + const a = getOverlayContainer(document); + const b = getOverlayContainer(document); + expect(a).toBe(b); // singleton + expect(a.parentElement).toBe(document.body); + expect(document.querySelectorAll('.chat-overlay-container').length).toBe(1); + expect(document.querySelectorAll('#chat-overlay-structure').length).toBe(1); + expect(document.getElementById('chat-overlay-structure')!.textContent).toContain('position: fixed'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx nx test chat -- overlay-container` +Expected: FAIL — cannot resolve `./overlay-container`. + +- [ ] **Step 3: Write the implementation** + +Create `libs/chat/src/lib/primitives/overlay/overlay-container.ts`: + +```ts +// libs/chat/src/lib/primitives/overlay/overlay-container.ts +// SPDX-License-Identifier: MIT + +const CONTAINER_CLASS = 'chat-overlay-container'; +const STYLE_ID = 'chat-overlay-structure'; + +// Structural CSS, injected once into (same pattern as ROOT_TOKEN_STYLES +// in chat-tokens.ts) so consumers need not import any stylesheet. +const STRUCTURE_CSS = ` +.${CONTAINER_CLASS} { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; +} +.chat-overlay-pane { + position: absolute; + pointer-events: auto; +} +`; + +/** Returns the single shared overlay container appended to , creating it + * (and injecting structural CSS) on first call. */ +export function getOverlayContainer(doc: Document): HTMLElement { + const existing = doc.querySelector('.' + CONTAINER_CLASS); + if (existing) return existing; + + if (!doc.getElementById(STYLE_ID)) { + const style = doc.createElement('style'); + style.id = STYLE_ID; + style.textContent = STRUCTURE_CSS; + doc.head.appendChild(style); + } + + const container = doc.createElement('div'); + container.className = CONTAINER_CLASS; + doc.body.appendChild(container); + return container; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx nx test chat -- overlay-container` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/overlay/overlay-container.ts libs/chat/src/lib/primitives/overlay/overlay-container.spec.ts +git commit -m "feat(chat): body-portal overlay container with self-injected structural CSS" +``` + +--- + +## Task 3: Connected-overlay directives + +**Files:** +- Create: `libs/chat/src/lib/primitives/overlay/connected-overlay.directive.ts` +- Test: `libs/chat/src/lib/primitives/overlay/connected-overlay.directive.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/chat/src/lib/primitives/overlay/connected-overlay.directive.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatConnectedOverlayDirective, ChatOverlayOriginDirective } from './connected-overlay.directive'; +import type { ConnectedPosition } from './connected-position'; + +const POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, +]; + +@Component({ + standalone: true, + imports: [ChatConnectedOverlayDirective, ChatOverlayOriginDirective], + template: ` + + + + + `, +}) +class HostComponent { + readonly open = signal(false); + readonly positions = POSITIONS; +} + +describe('ChatConnectedOverlayDirective', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + document.querySelector('.chat-overlay-container')?.remove(); + }); + + const pane = () => document.querySelector('.chat-overlay-container .chat-overlay-pane'); + + it('portals content to the overlay container when open, with the panel class', () => { + expect(pane()).toBeNull(); + fixture.componentInstance.open.set(true); + fixture.detectChanges(); + const p = pane(); + expect(p).not.toBeNull(); + expect(p!.classList.contains('test-panel')).toBe(true); + expect(p!.querySelector('.menu-content')?.textContent).toContain('hello'); + }); + + it('removes the pane when closed', () => { + fixture.componentInstance.open.set(true); + fixture.detectChanges(); + fixture.componentInstance.open.set(false); + fixture.detectChanges(); + expect(pane()).toBeNull(); + }); + + it('emits outsideClick on a document mousedown outside the pane and origin', () => { + fixture.componentInstance.open.set(true); + fixture.detectChanges(); + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + fixture.detectChanges(); + expect(pane()).toBeNull(); // host closed via (outsideClick) + }); + + it('tears down the pane when the host is destroyed', () => { + fixture.componentInstance.open.set(true); + fixture.detectChanges(); + fixture.destroy(); + expect(pane()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx nx test chat -- connected-overlay.directive` +Expected: FAIL — cannot resolve `./connected-overlay.directive`. + +- [ ] **Step 3: Write the implementation** + +Create `libs/chat/src/lib/primitives/overlay/connected-overlay.directive.ts`: + +```ts +// libs/chat/src/lib/primitives/overlay/connected-overlay.directive.ts +// SPDX-License-Identifier: MIT +import { + DestroyRef, + Directive, + ElementRef, + type EmbeddedViewRef, + TemplateRef, + ViewContainerRef, + DOCUMENT, + effect, + inject, + input, + output, +} from '@angular/core'; +import { getOverlayContainer } from './overlay-container'; +import { computeConnectedPosition, narrowViewport, type ConnectedPosition } from './connected-position'; + +/** Marks the anchor element a connected overlay positions against. */ +@Directive({ + selector: '[chatOverlayOrigin]', + standalone: true, + exportAs: 'chatOverlayOrigin', +}) +export class ChatOverlayOriginDirective { + readonly elementRef = inject(ElementRef) as ElementRef; +} + +const VIEWPORT_MARGIN = 8; + +/** + * Applied to an ``. When `chatOverlayOpen` is true, the template + * content is portaled into the shared body-level overlay container and + * positioned connected to `chatOverlayOrigin`, repositioning live on + * scroll/resize. Closes on outside mousedown (via the `chatOverlayOutsideClick` + * output) and Tab; returns focus to the origin when focus was inside the pane. + */ +@Directive({ + selector: '[chatConnectedOverlay]', + standalone: true, +}) +export class ChatConnectedOverlayDirective { + readonly origin = input.required({ alias: 'chatOverlayOrigin' }); + readonly open = input(false, { alias: 'chatOverlayOpen' }); + readonly positions = input([], { alias: 'chatOverlayPositions' }); + readonly panelClass = input('', { alias: 'chatOverlayPanelClass' }); + + /** Emits the pane element once attached (consumers focus content from it). */ + readonly attached = output({ alias: 'chatOverlayAttached' }); + readonly outsideClick = output({ alias: 'chatOverlayOutsideClick' }); + readonly detached = output({ alias: 'chatOverlayDetach' }); + + private readonly templateRef = inject(TemplateRef); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly document = inject(DOCUMENT); + + private pane: HTMLElement | null = null; + private viewRef: EmbeddedViewRef | null = null; + private resizeObs: ResizeObserver | null = null; + private rafId = 0; + private previouslyFocused: HTMLElement | null = null; + + private readonly onScrollOrResize = () => this.scheduleReposition(); + private readonly onDocMouseDown = (e: MouseEvent) => { + if (!this.pane) return; + const path = e.composedPath(); + if (path.includes(this.pane) || path.includes(this.origin().elementRef.nativeElement)) return; + this.outsideClick.emit(e); + }; + private readonly onKeydown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !this.pane) return; + const active = this.document.activeElement; + if (this.pane.contains(active) || active === this.origin().elementRef.nativeElement) { + this.detached.emit(); + } + }; + + constructor() { + effect(() => { + if (this.open()) this.attach(); + else this.dispose(); + }); + inject(DestroyRef).onDestroy(() => this.dispose()); + } + + private attach(): void { + if (this.pane) return; + const win = this.document.defaultView; + if (!win) return; // SSR / detached document + + this.previouslyFocused = this.document.activeElement as HTMLElement | null; + + const pane = this.document.createElement('div'); + pane.className = 'chat-overlay-pane'; + for (const c of this.normalizePanelClass()) pane.classList.add(c); + getOverlayContainer(this.document).appendChild(pane); + + this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); + this.viewRef.detectChanges(); + for (const node of this.viewRef.rootNodes) pane.appendChild(node as Node); + + this.pane = pane; + this.reposition(); + + win.addEventListener('scroll', this.onScrollOrResize, { capture: true, passive: true }); + win.addEventListener('resize', this.onScrollOrResize, { passive: true }); + this.document.addEventListener('mousedown', this.onDocMouseDown, true); + this.document.addEventListener('keydown', this.onKeydown, true); + if (typeof win.ResizeObserver === 'function') { + this.resizeObs = new win.ResizeObserver(() => this.scheduleReposition()); + this.resizeObs.observe(this.origin().elementRef.nativeElement); + this.resizeObs.observe(pane); + } + + this.attached.emit(pane); + } + + private scheduleReposition(): void { + const win = this.document.defaultView; + if (!win || !this.pane) return; + if (this.rafId) win.cancelAnimationFrame(this.rafId); + this.rafId = win.requestAnimationFrame(() => this.reposition()); + } + + private reposition(): void { + const win = this.document.defaultView; + if (!win || !this.pane) return; + const r = this.pane.getBoundingClientRect(); + const result = computeConnectedPosition({ + originRect: this.origin().elementRef.nativeElement.getBoundingClientRect(), + overlaySize: { width: r.width, height: r.height }, + viewport: narrowViewport(win, VIEWPORT_MARGIN), + positions: this.positions(), + }); + this.pane.style.top = `${Math.round(result.top)}px`; + this.pane.style.left = `${Math.round(result.left)}px`; + } + + private dispose(): void { + const win = this.document.defaultView; + if (this.rafId && win) win.cancelAnimationFrame(this.rafId); + this.rafId = 0; + if (win) { + win.removeEventListener('scroll', this.onScrollOrResize, { capture: true } as EventListenerOptions); + win.removeEventListener('resize', this.onScrollOrResize); + } + this.document.removeEventListener('mousedown', this.onDocMouseDown, true); + this.document.removeEventListener('keydown', this.onKeydown, true); + this.resizeObs?.disconnect(); + this.resizeObs = null; + + const focusWasInPane = !!this.pane && this.pane.contains(this.document.activeElement); + this.viewRef?.destroy(); + this.viewRef = null; + this.pane?.remove(); + this.pane = null; + + if (focusWasInPane && this.previouslyFocused) this.previouslyFocused.focus(); + this.previouslyFocused = null; + } + + private normalizePanelClass(): string[] { + const pc = this.panelClass(); + return Array.isArray(pc) ? pc : pc ? [pc] : []; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx nx test chat -- connected-overlay.directive` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/overlay/connected-overlay.directive.ts libs/chat/src/lib/primitives/overlay/connected-overlay.directive.spec.ts +git commit -m "feat(chat): ChatConnectedOverlay + ChatOverlayOrigin directives (body portal, live reposition)" +``` + +--- + +## Task 4: Export the primitive from the public API + +**Files:** +- Modify: `libs/chat/src/public-api.ts` (after line 84, the `ChatSelectComponent` exports) + +- [ ] **Step 1: Add the exports** + +In `libs/chat/src/public-api.ts`, immediately after the `ChatSelectOption` export (line 84), add: + +```ts +export { ChatOverlayOriginDirective, ChatConnectedOverlayDirective } from './lib/primitives/overlay/connected-overlay.directive'; +export type { ConnectedPosition, OverlayPositionResult } from './lib/primitives/overlay/connected-position'; +``` + +- [ ] **Step 2: Verify the lib still builds** + +Run: `npx nx build chat` +Expected: build succeeds, no unresolved exports. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export connected-overlay primitive from public API" +``` + +--- + +## Task 5: Migrate chat-select to the new primitive + drop @angular/cdk + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-select/chat-select.component.ts` +- Modify: `libs/chat/package.json` (remove `@angular/cdk` peer dep) + +- [ ] **Step 1: Replace the component (CDK → chat overlay)** + +Overwrite `libs/chat/src/lib/primitives/chat-select/chat-select.component.ts` with: + +```ts +// libs/chat/src/lib/primitives/chat-select/chat-select.component.ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + inject, + input, + model, + signal, + DOCUMENT, +} from '@angular/core'; +import { ChatConnectedOverlayDirective, ChatOverlayOriginDirective } from '../overlay/connected-overlay.directive'; +import type { ConnectedPosition } from '../overlay/connected-position'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SELECT_STYLES } from '../../styles/chat-select.styles'; + +export interface ChatSelectOption { + value: string; + label: string; + description?: string; + disabled?: boolean; +} + +// Unique listbox id per instance, for aria-controls (multiple selects per page). +let nextChatSelectId = 0; + +/** + * Generic single-select dropdown. The menu renders through the chat + * connected-overlay primitive (a body-level portal), so it is never clipped by + * an ancestor's `overflow` and never trapped by an ancestor `transform`. + * + * Inputs: options (required), value (two-way), placeholder, disabled, menuLabel, + * panelClass (extra class(es) on the overlay pane — the menu is portaled, so + * `::ng-deep chat-select .chat-select__menu` no longer reaches it). + */ +@Component({ + selector: 'chat-select', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ChatConnectedOverlayDirective, ChatOverlayOriginDirective], + styles: [CHAT_HOST_TOKENS, CHAT_SELECT_STYLES], + template: ` + + +
+ @for (opt of options(); track opt.value) { + + } +
+
+ `, +}) +export class ChatSelectComponent { + readonly options = input.required(); + readonly value = model(''); + readonly placeholder = input('Select'); + readonly disabled = input(false); + readonly menuLabel = input(undefined); + readonly panelClass = input(''); + + protected readonly open = signal(false); + protected readonly menuId = `chat-select-menu-${nextChatSelectId++}`; + + // Above-right preferred (input pill sits at the bottom), then below, then the + // left-aligned variants. The positioner flips/clamps to keep it in view. + protected readonly overlayPositions: ConnectedPosition[] = [ + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -8 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, + ]; + + protected readonly panelClasses = computed(() => { + const extra = this.panelClass(); + const list = Array.isArray(extra) ? extra : extra ? [extra] : []; + return ['chat-select__overlay', ...list]; + }); + + protected readonly currentLabel = computed(() => { + const v = this.value(); + return this.options().find((o) => o.value === v)?.label ?? this.placeholder(); + }); + + private readonly hostEl = inject(ElementRef).nativeElement as HTMLElement; + private readonly document = inject(DOCUMENT); + // The overlay primitive emits the pane element on attach; options live there + // (portaled out of the host), so option queries go through it. + private menuPane: HTMLElement | null = null; + + protected onAttached(pane: HTMLElement): void { + this.menuPane = pane; + this.focusOption(0); + } + + protected toggle(): void { + if (this.disabled()) return; + this.open.update((v) => !v); + } + + protected selectOption(opt: ChatSelectOption): void { + if (opt.disabled) return; + this.value.set(opt.value); + this.open.set(false); + } + + protected onTriggerKeydown(e: KeyboardEvent): void { + if (this.disabled()) return; + if (e.key === 'Escape' && this.open()) { + e.preventDefault(); + this.open.set(false); + return; + } + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + this.open.set(true); + // focus happens in onAttached once the pane is live + } + } + + protected onMenuKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.open.set(false); + this.queryTrigger()?.focus(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + this.moveFocus(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Enter' || e.key === ' ') { + const t = e.target as HTMLElement; + if (t.classList.contains('chat-select__option')) { + e.preventDefault(); + (t as HTMLButtonElement).click(); + } + } + } + + private focusOption(index: number): void { + this.queryOptions()[index]?.focus(); + } + + private moveFocus(dir: 1 | -1): void { + const opts = this.queryOptions().filter((b) => !b.disabled); + if (!opts.length) return; + const active = this.document.activeElement as HTMLElement | null; + const idx = active ? opts.indexOf(active as HTMLButtonElement) : -1; + opts[(idx + dir + opts.length) % opts.length]?.focus(); + } + + private queryOptions(): HTMLButtonElement[] { + const root = this.menuPane; + return root ? Array.from(root.querySelectorAll('.chat-select__option')) : []; + } + + private queryTrigger(): HTMLButtonElement | null { + return this.hostEl.querySelector('.chat-select__trigger'); + } +} +``` + +- [ ] **Step 2: Remove the @angular/cdk peer dependency** + +In `libs/chat/package.json`, delete the line: + +```json + "@angular/cdk": "^20.0.0 || ^21.0.0", +``` + +(from the `peerDependencies` block — leave `@angular/core` etc. intact). + +- [ ] **Step 3: Verify no remaining @angular/cdk usage in the lib** + +Run: `grep -rn "@angular/cdk" libs/chat/src` +Expected: no matches (empty output). + +- [ ] **Step 4: Build + lint the lib** + +Run: `npx nx build chat && npx nx lint chat` +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-select/chat-select.component.ts libs/chat/package.json +git commit -m "refactor(chat): chat-select uses the in-lib overlay primitive; drop @angular/cdk peer dep" +``` + +--- + +## Task 6: Update the chat-select unit spec for the renamed container + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts` + +- [ ] **Step 1: Repoint the overlay queries** + +The spec currently injects CDK's `OverlayContainer`. Replace that with direct DOM queries against `.chat-overlay-container`. Make these edits: + +Remove the import line: +```ts +import { OverlayContainer } from '@angular/cdk/overlay'; +``` + +Replace the `overlay` setup. Change: +```ts + let overlay: HTMLElement; +``` +to: +```ts + const overlayRoot = () => document.querySelector('.chat-overlay-container') as HTMLElement | null; +``` + +In `beforeEach`, remove: +```ts + overlay = TestBed.inject(OverlayContainer).getContainerElement(); +``` + +Update the helpers: +```ts + const menu = () => overlayRoot()?.querySelector('.chat-select__menu') ?? null; + const optionEls = () => overlayRoot()?.querySelectorAll('.chat-select__option') ?? ([] as unknown as NodeListOf); +``` + +In `afterEach`, after `fixture.destroy();` add: +```ts + overlayRoot()?.remove(); +``` + +- [ ] **Step 2: Run the spec** + +Run: `npx nx test chat -- chat-select` +Expected: PASS (all chat-select tests). + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts +git commit -m "test(chat): chat-select spec queries .chat-overlay-container" +``` + +--- + +## Task 7: Repoint consumer e2e selectors (chat example) + +**Files:** +- Modify: `examples/chat/angular/e2e/test-helpers.ts` +- Modify: `examples/chat/angular/e2e/model-picker.spec.ts` + +- [ ] **Step 1: Update the shared toolbar helper** + +In `examples/chat/angular/e2e/test-helpers.ts`, inside `selectToolbarOption`, change: +```ts + const menu = page.locator('.cdk-overlay-container .chat-select__menu'); +``` +to: +```ts + const menu = page.locator('.chat-overlay-container .chat-select__menu'); +``` + +- [ ] **Step 2: Update the model-picker spec** + +In `examples/chat/angular/e2e/model-picker.spec.ts`, change: +```ts + const modelMenu = page.locator('.cdk-overlay-container .chat-select__menu'); +``` +to: +```ts + const modelMenu = page.locator('.chat-overlay-container .chat-select__menu'); +``` + +- [ ] **Step 3: Confirm no other cdk-overlay-container references remain in examples** + +Run: `grep -rn "cdk-overlay-container" examples` +Expected: no matches. + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/e2e/test-helpers.ts examples/chat/angular/e2e/model-picker.spec.ts +git commit -m "test(chat-e2e): query .chat-overlay-container for the portaled select menu" +``` + +--- + +## Task 8: Full verification + live Chrome + final commit + +**Files:** none (verification only; commit any incidental fixes). + +- [ ] **Step 1: Lint + unit across the lib and both examples** + +Run: +```bash +npx nx run-many -t lint test -p chat examples-ag-ui-angular examples-chat-angular +``` +Expected: all succeed. + +- [ ] **Step 2: e2e — ag-ui then chat (free ports first)** + +Run: +```bash +for p in 4201 4200 8000 2024; do lsof -ti:$p | xargs kill -9 2>/dev/null; done; sleep 2 +npx nx e2e examples-ag-ui-angular +for p in 4201 4200 8000 2024; do lsof -ti:$p | xargs kill -9 2>/dev/null; done; sleep 2 +npx nx e2e examples-chat-angular +``` +Expected: ag-ui 28/28, chat 42/42. (If a chat spec times out on first run, re-run once — the aimock harness is occasionally flaky on cold start; a clean second run is authoritative.) + +- [ ] **Step 3: Live Chrome smoke (ag-ui dev server)** + +Start the ag-ui dev server with keys (Maps + OpenAI) per the repo runbook, then in Chrome verify: +1. `/sidebar` (App mode off) → open "More prompts": menu is in `.chat-overlay-container`, fully within the viewport, NOT clipped by the panel. +2. `/embed` → open the input-pill model picker: opens upward, within viewport. +3. Toolbar select (e.g. Effort): opens downward (flips), within viewport. +4. With the More-prompts menu open, scroll the page: the menu repositions to stay anchored (live reposition). +5. Keyboard: ArrowDown into options, Escape closes and returns focus to the trigger. + +- [ ] **Step 4: Final commit (if any incidental fixes were needed)** + +```bash +git add -A +git commit -m "test(chat): verify hand-rolled overlay across consumers (lint/unit/e2e/live)" || echo "nothing to commit" +git push origin blove/ag-ui-app-mode-promo +``` + +--- + +## Notes for the implementer + +- Run unit tests with `npx nx test chat -- ` (vitest filter by filename). +- The embedded-view-then-move-nodes portal pattern (Task 3, `attach()`) is exactly how CDK's `DomPortalOutlet` works — the view stays registered with the host's change detection (so signals inside update), while its DOM nodes live in the pane. +- Do NOT reintroduce `@angular/cdk` anywhere in `libs/chat`. The whole point is zero peer deps. +- Theme tokens (`--ngaf-chat-*`) are on `:root`, so the portaled menu keeps its colors. Consumer width overrides ride on `panelClass` (`welcome-suggestions__menu-panel` in both example apps) — already in place from the prior CDK migration; do not change them. diff --git a/docs/superpowers/specs/2026-06-25-ag-ui-app-mode-promo-design.md b/docs/superpowers/specs/2026-06-25-ag-ui-app-mode-promo-design.md new file mode 100644 index 000000000..f4ee8f75a --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-ag-ui-app-mode-promo-design.md @@ -0,0 +1,145 @@ +# AG-UI App-mode promo hero — design + +**Date:** 2026-06-25 +**Status:** Approved (pending spec review) +**Branch:** `worktree-ag-ui-app-mode-mapfix` (PR #736 line of work) + +## Context + +The canonical AG-UI demo (`examples/ag-ui/angular`) has two layouts: plain +chat modes (embed/popup/sidebar) and an "App mode" map cockpit (full-bleed +Google Map + floating itinerary overlay + chat sidebar), toggled from the +toolbar. The itinerary panel is now App-mode-only (see PR #736 commit +`a8c2209d`). + +When the user is in **sidebar mode with App mode off**, the area to the left +of the chat panel (`.sidebar-mode__background`, projected into +``'s default content slot) currently shows only a placeholder +line: "Use the launcher (right edge) to dismiss or re-open the chat panel." +That space is wasted. We want it to **market the App-mode cockpit and the +Threadplane primitives that power it**, with a primary CTA to turn App mode on. + +## Goal + +Replace the placeholder hint with a polished, preview-led marketing hero that: +1. Shows what App mode looks like (a real Paris map backdrop). +2. Credits the Threadplane framework capabilities the demo exercises. +3. Provides a single primary CTA that enables App mode and drops the user into + the cockpit. + +This is a demo-selling surface, not a travel feature — the pills advertise the +**framework**, not the trip app. + +## Placement & component + +- New standalone component `AppModePromoComponent` + (`examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts`), + `ChangeDetectionStrategy.OnPush`. Sits beside `sidebar-mode.component.ts` and + `welcome-suggestions.ts`. +- **Isolated contract — no direct shell coupling:** + - Input `hasMapsKey: boolean` — whether `GOOGLE_MAPS_API_KEY` is configured. + - Output `enable: void` (EventEmitter) — emitted when the CTA is clicked. +- `SidebarMode` renders it in place of the `.sidebar-mode__hint` paragraph, + under the **same gate** already present: + + ```html + @if (shell.appMode() !== 'on') { + + } + ``` + + `shell.hasMapsKey` and `shell.onAppModeChange` already exist on `AgUiShell`. + +## Visual design + +A centered, bounded "poster" card — not a full-bleed page fill — vertically +centered in the existing `place-items: center` background. Max-width ~780px, +aspect ratio ~16:10. + +**Layers (back to front):** +1. **Map backdrop** — the committed static screenshot (light Google Maps of + Paris) via an `` with `object-fit: cover` so it fills the card at any + size ("resize to fit"). The map is clean (no baked-in pins); the poster sells + via the caption, not drawn overlays. (Optional future enhancement: layer a + few stylized seed pins/route over the map — out of scope for v1.) +2. **Caption panel** — anchored to the bottom, full width of the card, **flat + solid dark** fill (`rgba(8,15,28,0.96)`, no gradient — gradients flash during + render) with a 0.5px top border. Sits over the light map for strong contrast. + Contains, top to bottom: + - Eyebrow chip: "Built with Threadplane" (map/stack icon), framing the pills + as framework features. + - Headline (~20px/500, light): "See your trip come alive on a live map". + - Subcopy (one line, muted): "A map cockpit where the agent edits your + itinerary in real time." + - Feature pills (wrap): **Client tools**, **Generative UI**, + **Human-in-the-loop**, **Shared state** — each a small pill with a Material + Symbols icon. These name the actual `@threadplane/*` primitives the demo + uses (`client-tools.ts`: `action`/`view`/`ask` client tools, A2UI catalog, + confirm-to-resume, shared signals `ItineraryStore`). + - Primary CTA button: "Enable app mode" (map icon + arrow). + +**Theme:** the caption panel is intentionally always-dark and the map image +always-light, so the poster reads consistently in both light and dark demo +themes (like a photo with a dark caption bar); the surrounding background area +keeps its themed tokens. Hardcode the dark caption surface and its light text +since they overlay the fixed-light map image. The CTA accent should still map +to the demo's `--ngaf-chat-primary` / `--ngaf-chat-on-primary` tokens so the +primary action matches the rest of the UI. + +## Static image asset + +- **Path:** `examples/ag-ui/angular/public/app-mode-preview.webp` (the `public/` + dir is served at root per `project.json` assets glob → referenced as + `/app-mode-preview.webp`). +- **Source:** user-provided screenshot of a Paris Google Map (saved first as + `public/app-mode-preview-raw.png`). +- **Processing:** resize/crop to the poster aspect and export an optimized + `.webp` locally with `sips` (macOS, no external API). Target ~1600×1000 (2×), + budget < 300 KB. +- **Placeholder:** until the real asset is processed, commit a lightweight + stand-in at the same path so the build, unit tests, and e2e stay green. +- ``: "Preview of the App-mode map cockpit". + +## Behavior + +- **CTA click** → emits `enable` → `shell.onAppModeChange('on')` → App mode on, + routed to the cockpit. The reload-route fix (PR #736 commit `d165937f`) + guarantees a reload restores the cockpit rather than bouncing to embed. +- **No-key fallback:** when `hasMapsKey` is false, the CTA is `disabled` with a + small note "Set `GOOGLE_MAPS_API_KEY` to enable" (mirrors the toolbar toggle's + disabled+title behavior). The hero still renders so the capability is still + marketed. +- **Responsive:** the caption row uses `flex-wrap`; when the chat panel pushes + the area narrow, the CTA drops below the copy. The poster `max-width` prevents + over-stretching on very wide viewports. + +## Accessibility & motion + +- CTA is a real `