From 8177da3dfec8d5664865c7c9f8e3d837d1d240a7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:56:10 -0700 Subject: [PATCH 01/10] docs(spec): mobile sidenav scrim + responsive polish design Locks decisions for the drawer-mode polish pass: split scrim into a new chat-sidenav-scrim primitive (rendered as a sibling, escapes the host stacking context), add right-edge drawer shadow, move the demo's hamburger into the toolbar's flex row, and promote z-index hardcodes to documented CSS variables. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-mobile-sidenav-scrim-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-mobile-sidenav-scrim-design.md diff --git a/docs/superpowers/specs/2026-05-19-mobile-sidenav-scrim-design.md b/docs/superpowers/specs/2026-05-19-mobile-sidenav-scrim-design.md new file mode 100644 index 00000000..8c84e559 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-mobile-sidenav-scrim-design.md @@ -0,0 +1,250 @@ +# Mobile sidenav scrim + responsive polish — Design + +**Status:** Approved +**Date:** 2026-05-19 +**Goal:** Fix the chat-sidenav drawer's broken mobile interaction (scrim renders over content, can't scroll/tap), elevate the drawer with a right-edge shadow, move the demo's hamburger out of the toolbar's overlap, and document the chat lib's z-index layers as CSS-variable tokens. + +## Why now + +On a 375px viewport, `demo.threadplane.ai` shows the drawer mode is unusable: clicking inside the open drawer hits a `.chat-sidenav__scrim` button rather than the drawer contents, scrolling is blocked, and the floating hamburger button sits on top of the demo's top toolbar. These are stacking-context bugs and missing affordances — high-impact polish for the first impression a mobile visitor gets. + +## Root cause + +`chat-sidenav` renders its scrim **inside** its own host element: + +```html +@if (mode() === 'drawer' && open()) { + + } + `, + styles: [ + ` + :host { display: contents; } + .chat-sidenav-scrim__button { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: var(--ngaf-chat-z-drawer-scrim, 1000); + border: 0; + padding: 0; + cursor: pointer; + } + `, + ], +}) +export class ChatSidenavScrimComponent { + readonly open = input(false); + readonly close = output(); +} +``` + +`:host { display: contents; }` so the component host doesn't create a stacking context — the fixed-positioned button is the only painted element and competes at the document root. + +Exported from `libs/chat/src/public-api.ts`. + +#### B. `chat-sidenav` stops rendering its scrim + +Template change in `chat-sidenav.component.ts`: delete the `@if (mode() === 'drawer' && open()) { +} +
+
...
+ ... +
+``` + +New: + +```html +
+ @if (sidenavMode() === 'drawer' && !drawerOpen()) { + + } +
...
+ ... +
+``` + +CSS for `.demo-shell__hamburger` in `demo-shell.component.css`: + +```css +.demo-shell__hamburger { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text); + border-radius: 8px; + font-size: 18px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.demo-shell__hamburger:hover { + background: var(--ngaf-chat-surface-alt); +} +``` + +(Drop the old `position: fixed; top: 12; left: 12; z-index: 1100; box-shadow` etc. — it's now a normal flex child.) + +#### F. Scrim wiring in demo-shell + +`demo-shell.component.ts` imports `ChatSidenavScrimComponent` and adds to `imports: [...]`. + +`demo-shell.component.html` adds the scrim as a sibling of chat-sidenav, just before it: + +```html + +... +``` + +The scrim is now a top-level sibling of `` in the demo-shell template — outside the chat-sidenav host's stacking context — so its z-index 1000 sits cleanly between content (z auto / z 50) and drawer (z 1001). + +## Files touched + +### Lib (`libs/chat/`) +- **Create**: `src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts` +- **Create**: `src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts` +- **Modify**: `src/public-api.ts` — add export +- **Modify**: `src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — drop the scrim template block +- **Modify**: `src/lib/styles/chat-sidenav.styles.ts` — drop `.chat-sidenav__scrim` rule, add drawer box-shadow, use z-index token +- **Modify**: `src/lib/styles/chat-tokens.ts` — add three z-index CSS variables +- **Modify**: `src/lib/compositions/chat-sidebar/chat-sidebar.component.ts` — use `var(--ngaf-chat-z-overlay-content, 30)` for panel z +- **Modify**: `src/lib/compositions/chat-popup/chat-popup.component.ts` — same + +### Demo (`examples/chat/angular/`) +- **Modify**: `src/app/shell/demo-shell.component.ts` — import `ChatSidenavScrimComponent` +- **Modify**: `src/app/shell/demo-shell.component.html` — render `` sibling; move hamburger into toolbar +- **Modify**: `src/app/shell/demo-shell.component.css` — drop fixed-position hamburger styles, add flex-child styles + +### Release +- **Modify**: all 7 publishable libs `package.json` — 0.0.43 → 0.0.44 + +## Testing + +### Unit +- `chat-sidenav-scrim.component.spec.ts` (new): + - Renders only when `[open]="true"` + - Click on the scrim emits `(close)` + - Has accessible name "Close conversations" +- `chat-sidenav.component.spec.ts`: remove any test asserting the old `.chat-sidenav__scrim` element inside chat-sidenav. Replace with an assertion that `.chat-sidenav__scrim` is NOT present (responsibility moved out). +- `chat-sidenav.styles.spec.ts`: assert the new box-shadow rule + the z-index uses the CSS var (`var(--ngaf-chat-z-drawer, 1001)`). +- `chat-tokens` spec (if exists): assert the three new variables are declared. + +### Manual smoke (chrome-mcp) +- Resize to 375×812. Reload demo. +- Hamburger renders as the FIRST element in the toolbar (not floating). +- Click hamburger → drawer slides in, scrim covers the rest of the viewport. +- Click an element INSIDE the drawer (e.g. a thread) → does NOT close the drawer; thread is selected. +- Scroll the threads list → it scrolls inside the drawer. +- Drawer has a visible right-edge shadow. +- Click the scrim (any pixel outside the drawer) → drawer closes. +- `elementFromPoint` at drawer center returns a thread item / drawer chrome, NOT the scrim. + +## Out of scope + +- chat-input layout at narrow widths (separate follow-up) +- chat-sidebar / chat-popup mobile geometry (separate follow-up) +- Touch target size audit across all interactive elements (separate follow-up) +- Mobile-specific theme tweaks +- Persistent drawer state for tablet sizes + +## References + +- Drawer current source: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts:60-68` (template), `libs/chat/src/lib/styles/chat-sidenav.styles.ts:35-66` (drawer CSS) +- Hamburger current source: `examples/chat/angular/src/app/shell/demo-shell.component.html:2-10`, `examples/chat/angular/src/app/shell/demo-shell.component.css:16-38` +- Stacking-context primer: [MDN — The stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context) From 7ba0762c36abba84c3881694313b02d2a434b80b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 13:13:17 -0700 Subject: [PATCH 02/10] docs(plan): mobile sidenav scrim implementation plan --- .../plans/2026-05-19-mobile-sidenav-scrim.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-mobile-sidenav-scrim.md diff --git a/docs/superpowers/plans/2026-05-19-mobile-sidenav-scrim.md b/docs/superpowers/plans/2026-05-19-mobile-sidenav-scrim.md new file mode 100644 index 00000000..bafe39b8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-mobile-sidenav-scrim.md @@ -0,0 +1,597 @@ +# Mobile Sidenav Scrim + Responsive Polish 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:** Move the chat-sidenav drawer scrim into its own primitive (so it escapes the host's stacking context and stops intercepting drawer interaction), add a right-edge drawer shadow, move the demo's hamburger into the toolbar's flex row, and promote z-index hardcodes to documented CSS variables. + +**Architecture:** New `` primitive in `@ngaf/chat` is rendered as a sibling of `` by the consumer (demo wires it). `chat-sidenav` stops rendering its internal scrim. Three z-index layer tokens land in chat-tokens.ts and replace the hardcodes across the lib. + +**Tech Stack:** Angular 20+ standalone components, vitest, TypeScript-template-literal CSS, no new dependencies (no CDK). + +**Branch:** `claude/mobile-sidenav-scrim` (off `origin/main`; spec committed at `8177da3d`). + +**Spec:** `docs/superpowers/specs/2026-05-19-mobile-sidenav-scrim-design.md` + +--- + +## File map + +| File | Change | +|---|---| +| `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts` | **Create** — new component | +| `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts` | **Create** — unit tests | +| `libs/chat/src/public-api.ts` | Add export | +| `libs/chat/src/lib/styles/chat-tokens.ts` | Add `LAYER_TOKENS` block with 3 z-index vars; concat into `CHAT_HOST_TOKENS` | +| `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` | Drop the scrim template block | +| `libs/chat/src/lib/styles/chat-sidenav.styles.ts` | Drop `.chat-sidenav__scrim` rule; add drawer box-shadow; use z-index token | +| `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` | Update — no scrim assertion; new layer-token + shadow assertion | +| `libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts` | Replace `z-index: 30` with `var(--ngaf-chat-z-overlay-content, 30)` (2 places) | +| `libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts` | Replace `z-index: 30` with the same var | +| `examples/chat/angular/src/app/shell/demo-shell.component.ts` | Import `ChatSidenavScrimComponent` | +| `examples/chat/angular/src/app/shell/demo-shell.component.html` | Render `` sibling; move hamburger INTO `.demo-shell__toolbar` | +| `examples/chat/angular/src/app/shell/demo-shell.component.css` | Replace fixed-position hamburger styles with flex-child styles | +| `libs/{a2ui,ag-ui,chat,langgraph,licensing,render,telemetry}/package.json` | 0.0.43 → 0.0.44 | + +--- + +## Task 1: Add `LAYER_TOKENS` z-index CSS variables in chat-tokens.ts + +**Files:** +- Modify: `libs/chat/src/lib/styles/chat-tokens.ts` + +- [ ] **Step 1: Add a new LAYER_TOKENS block** + +In `libs/chat/src/lib/styles/chat-tokens.ts`, find the existing `SPACING_TOKENS` constant (around line 108). Add a new constant immediately after it, BEFORE `EDGE_CLAIM_TOKENS`: + +```ts +const LAYER_TOKENS = ` + /* Z-index layers — documented for consumers + future primitives. + * Default values listed; overridable per-app via :root or :host. */ + --ngaf-chat-z-overlay-content: 30; /* chat-sidebar panel, chat-popup window */ + --ngaf-chat-z-drawer-scrim: 1000; /* chat-sidenav-scrim backdrop */ + --ngaf-chat-z-drawer: 1001; /* chat-sidenav drawer mode host */ +`; +``` + +- [ ] **Step 2: Include LAYER_TOKENS in `ROOT_TOKEN_STYLES`** + +In the same file, find the `ROOT_TOKEN_STYLES` export (around line 319). The current `:root` block concatenates color/geometry/typography/spacing/edge-claim tokens: + +```ts +export const ROOT_TOKEN_STYLES = ` + ... + :root { + ${LIGHT_TOKENS} + ${GEOMETRY_TOKENS} + ${TYPOGRAPHY_TOKENS} + ${SPACING_TOKENS} + ${EDGE_CLAIM_TOKENS} + } +``` + +Add `${LAYER_TOKENS}` to that same `:root` block, placed alphabetically/logically between `${SPACING_TOKENS}` and `${EDGE_CLAIM_TOKENS}`: + +```ts + :root { + ${LIGHT_TOKENS} + ${GEOMETRY_TOKENS} + ${TYPOGRAPHY_TOKENS} + ${SPACING_TOKENS} + ${LAYER_TOKENS} + ${EDGE_CLAIM_TOKENS} + } +``` + +- [ ] **Step 3: Run the chat lib tests** + +Run: `pnpm nx test chat` +Expected: green (no behavior change yet). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/styles/chat-tokens.ts +git commit -m "feat(chat): add z-index layer tokens (overlay-content, drawer-scrim, drawer)" +``` + +--- + +## Task 2: Create the `chat-sidenav-scrim` primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts` + +- [ ] **Step 1: Write the failing tests first** + +Create `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatSidenavScrimComponent } from './chat-sidenav-scrim.component'; + +describe('ChatSidenavScrimComponent', () => { + let fx: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChatSidenavScrimComponent] }); + fx = TestBed.createComponent(ChatSidenavScrimComponent); + }); + + it('renders nothing when [open] is false (default)', () => { + fx.detectChanges(); + expect(fx.nativeElement.querySelector('button')).toBeNull(); + }); + + it('renders the scrim button when [open] is true', () => { + fx.componentRef.setInput('open', true); + fx.detectChanges(); + const btn = fx.nativeElement.querySelector('button.chat-sidenav-scrim__button') as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn.getAttribute('aria-label')).toBe('Close conversations'); + }); + + it('emits (close) on click', () => { + fx.componentRef.setInput('open', true); + fx.detectChanges(); + let closed = false; + fx.componentInstance.close.subscribe(() => { closed = true; }); + const btn = fx.nativeElement.querySelector('button.chat-sidenav-scrim__button') as HTMLButtonElement; + btn.click(); + expect(closed).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +Run: `pnpm nx test chat -- --runTestsByPath libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts` +Expected: FAIL — component doesn't exist. + +- [ ] **Step 3: Create the component** + +Create `libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts`: + +```ts +// libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +/** + * Backdrop scrim for chat-sidenav's drawer mode, rendered as a sibling of + * so its z-index sits cleanly between the page content + * and the drawer host (escapes the drawer host's stacking context). + * + * Usage: + * + * + */ +@Component({ + selector: 'chat-sidenav-scrim', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (open()) { + + } + `, + styles: [ + ` + :host { display: contents; } + .chat-sidenav-scrim__button { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: var(--ngaf-chat-z-drawer-scrim, 1000); + border: 0; + padding: 0; + cursor: pointer; + } + `, + ], +}) +export class ChatSidenavScrimComponent { + /** When true, render the backdrop button covering the viewport. */ + readonly open = input(false); + /** Fires when the user clicks the backdrop. */ + readonly close = output(); +} +``` + +- [ ] **Step 4: Run the spec to verify it passes** + +Run: `pnpm nx test chat -- --runTestsByPath libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.ts libs/chat/src/lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component.spec.ts +git commit -m "feat(chat): add chat-sidenav-scrim primitive" +``` + +--- + +## Task 3: Export the new primitive from the public API + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add the export** + +In `libs/chat/src/public-api.ts`, find the existing exports for other chat-sidenav-related symbols (e.g. `ChatSidenavComponent`). Add a sibling export: + +```ts +export { ChatSidenavScrimComponent } from './lib/primitives/chat-sidenav-scrim/chat-sidenav-scrim.component'; +``` + +Place it alphabetically near `ChatSidenavComponent`. + +- [ ] **Step 2: Run tests** + +Run: `pnpm nx test chat` +Expected: green. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export ChatSidenavScrimComponent from public API" +``` + +--- + +## Task 4: Drop the internal scrim from `chat-sidenav` + add drawer shadow + use z-index token + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` +- Modify: `libs/chat/src/lib/styles/chat-sidenav.styles.ts` +- Modify: `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` + +- [ ] **Step 1: Write the failing spec assertions** + +In `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts`, find any existing test that asserts the `.chat-sidenav__scrim` element renders (search for `chat-sidenav__scrim`). REPLACE that test with one that asserts the scrim is GONE: + +```ts +it('does NOT render an internal .chat-sidenav__scrim — scrim is owned by chat-sidenav-scrim primitive', () => { + const fx = TestBed.createComponent(ChatSidenavComponent); + fx.componentRef.setInput('mode', 'drawer'); + fx.componentRef.setInput('open', true); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.chat-sidenav__scrim')).toBeNull(); +}); +``` + +Add a styles-string assertion to the spec (or to `chat-sidenav.styles.spec.ts` if that file exists in the project). Pattern from prior PRs: + +```ts +import { CHAT_SIDENAV_STYLES } from '../../styles/chat-sidenav.styles'; + +describe('CHAT_SIDENAV_STYLES — drawer elevation + z-index token', () => { + const normalized = CHAT_SIDENAV_STYLES.replace(/\s+/g, ' '); + it('uses the --ngaf-chat-z-drawer token for the drawer host z-index', () => { + expect(normalized).toMatch( + /:host\(\[data-mode="drawer"\]\)\s*\{[^}]*z-index:\s*var\(--ngaf-chat-z-drawer,\s*1001\)\s*;/, + ); + }); + it('applies a right-edge box-shadow when the drawer is open', () => { + expect(normalized).toMatch( + /:host\(\[data-mode="drawer"\]\[data-open="true"\]\)\s*\{[^}]*box-shadow:\s*8px\s+0\s+32px\s+rgba\(0,\s*0,\s*0,\s*0\.18\)\s*;/, + ); + }); + it('no longer declares the .chat-sidenav__scrim selector', () => { + expect(normalized).not.toMatch(/\.chat-sidenav__scrim\s*\{/); + }); +}); +``` + +(If `chat-sidenav.styles.spec.ts` doesn't exist, create it with the imports above + this describe block.) + +- [ ] **Step 2: Run specs to see the failures** + +Run: `pnpm nx test chat -- --runTestsByPath libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts` +Expected: FAIL on the "no internal scrim" test. + +Run: `pnpm nx test chat -- --runTestsByPath libs/chat/src/lib/styles/chat-sidenav.styles.spec.ts` (if you created it) +Expected: FAIL on the three new assertions. + +- [ ] **Step 3: Update the chat-sidenav template — drop the scrim** + +In `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts`, find the template block (around line 60). Locate: + +```html +@if (mode() === 'drawer' && open()) { + +} +