Skip to content
Merged
788 changes: 788 additions & 0 deletions docs/superpowers/plans/2026-05-17-sidenav-polish.md

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions docs/superpowers/specs/2026-05-17-sidenav-polish-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Chat sidenav polish — Design

**Status:** Approved
**Date:** 2026-05-17
**Goal:** Rework `chat-sidenav` so the minimized state is functionally useful, the New chat / New project visual hierarchy is clear, and the footer is customizable via slots.

## Why now

User reviewed the production demo (`demo.cacheplane.ai/embed`) and flagged that:
- The minimized sidenav is "not usable" — three icons (`+`, `>`, magnifying glass) with no way to start a new chat or see threads
- The "+ New project" button styling doesn't feel aligned with the chat-input pill family
- The "New chat" affordance — which should be the biggest CTA in the entire product — is missing or invisible at the top level
- The sidenav lacks a footer for consumer-customizable controls (e.g. theme switcher)

Sub-project A of a two-part polish pass. Sub-project B (chat-surface polish — dropdown width, scroll fade, message-actions padding) ships as a separate follow-up.

## Decisions locked during brainstorming

| Decision | Choice |
|---|---|
| Minimized state direction | **Icon rail** — every action gets a permanent icon button; tooltip on hover |
| Threads in minimized rail | **Actions only** (no thread avatars / dots / popovers). Threads visible only when expanded. |
| New chat button style | **Primary CTA pill** — filled accent (`var(--ngaf-chat-primary)`), rounded 9999px radius, biggest button in the sidenav |
| New project button style | **Muted secondary pill** — dark fill, 1px border, same radius family. Tonally below New chat. |
| Footer | **Two slots** — `[sidenavFooterLeft]` + `[sidenavFooterRight]`, separated by top divider. Both empty by default. |
| Expand/collapse toggle | Moves from top-right into footer's right slot (last child) |
| Theme switcher | Renders inside the right footer slot in the canonical demo (consumer-provided; not part of the lib) |
| Search position | Stays at top, styled like an input field (dark fill + light border) |

## Architecture

`chat-sidenav` already has slot infrastructure: `[sidenavHeader]`, `[sidenavPrimary]`, `[sidenavSections]`, `[sidenavAccount]`, plus a `newChat` output. The polish extends this surface without breaking the existing API.

**New slot selectors (additive, content-projection):**
- `[sidenavFooterLeft]` — leftmost child in the footer row (hidden when minimized)
- `[sidenavFooterRight]` — rightmost child in the footer row (always rendered; consumer-provided controls)

**Existing slot `[sidenavAccount]`** is deprecated in this design: same semantic role (footer-leaning) but the new left/right split is more flexible. Migration: keep `[sidenavAccount]` working but document it as legacy; consumers should migrate to the named slots.

**Built-in New chat / New project buttons:**
The lib provides default rendering for both buttons inside `chat-sidenav`'s template, wired to the existing `newChat` and `newProjectRequested` outputs. Consumers who project something via `[sidenavPrimary]` can override.

**Minimized state collapses footer:**
When `mode === 'collapsed'`, the footer shows only the right-slot contents (theme + expand toggle stacked vertically). The left slot is hidden via CSS (`display: none`).

## Visual treatment

### New chat button (primary CTA)

```css
.chat-sidenav__new-chat {
background: var(--ngaf-chat-primary);
color: var(--ngaf-chat-on-primary);
border: 0;
padding: 10px 16px;
border-radius: 9999px;
font-size: 13px;
font-weight: 600;
display: flex; align-items: center; gap: 8px;
cursor: pointer;
width: 100%;
}
.chat-sidenav__new-chat:hover { filter: brightness(1.1); }
```

In **minimized** mode, the button collapses to a 32×32 icon-only square (same border-radius family, just smaller). Tooltip reveals "New chat".

### New project button (secondary)

```css
.chat-sidenav__new-project {
background: var(--ngaf-chat-surface);
color: var(--ngaf-chat-text-muted);
border: 1px solid var(--ngaf-chat-separator);
padding: 8px 14px;
border-radius: 9999px;
font-size: 12px;
display: flex; align-items: center; gap: 8px;
cursor: pointer;
width: 100%;
}
.chat-sidenav__new-project:hover {
background: var(--ngaf-chat-surface-alt);
color: var(--ngaf-chat-text);
}
```

In **minimized** mode: 32×32 icon-only square with the same surface fill.

### Search

Renders as an input-field affordance (dark fill, light border, magnifying-glass prefix). Already styled this way; the spec preserves and explicitly tokens it.

### Footer

```css
.chat-sidenav__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-top: 1px solid var(--ngaf-chat-separator);
gap: 8px;
}

:host([data-mode="collapsed"]) .chat-sidenav__footer {
flex-direction: column;
align-items: center;
padding: 10px 4px;
}

:host([data-mode="collapsed"]) .chat-sidenav__footer-left {
display: none;
}
```

**Right-side composition:** the lib renders the expand/collapse toggle as a sibling AFTER the `[sidenavFooterRight]` projection within a shared flex row, so consumer-projected controls appear visually left of the toggle. Both live inside a `.chat-sidenav__footer-right` container.

```html
<div class="chat-sidenav__footer">
<div class="chat-sidenav__footer-left">
<ng-content select="[sidenavFooterLeft]" />
</div>
<div class="chat-sidenav__footer-right">
<ng-content select="[sidenavFooterRight]" />
<button class="chat-sidenav__toggle" (click)="modeChange.emit(...)">→</button>
</div>
</div>
```

## Files touched

### Library — `libs/chat/`

1. **`libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts`** *(modify)*
- Add `[sidenavFooterLeft]` and `[sidenavFooterRight]` ng-content selectors to the template
- Render default "+ New chat" + "+ New project" buttons (wired to existing outputs)
- Move the expand toggle from `[sidenavHeader]` area to inside the footer's right slot (as a lib-rendered last child)
- Deprecate `[sidenavAccount]` (keep it working; add a JSDoc note)
- Mark new outputs are NOT needed — reuses existing `newChat` + `newProjectRequested`

2. **`libs/chat/src/lib/styles/chat-sidenav.styles.ts`** *(modify)*
- Add `.chat-sidenav__new-chat`, `.chat-sidenav__new-project`, `.chat-sidenav__footer`, `.chat-sidenav__footer-left`, `.chat-sidenav__footer-right` classes
- Add `:host([data-mode="collapsed"])` rules: footer goes column, left slot hides, buttons collapse to 32×32 icon squares
- Keep existing styles for back-compat where applicable

3. **`libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts`** *(modify)*
- Add tests: New chat button renders + emits `newChat`; New project button renders + emits `newProjectRequested`; footer slots project content; collapsed mode hides left slot; expand toggle present in right slot

### Demo — `examples/chat/angular/`

4. **`examples/chat/angular/src/app/shell/demo-shell.component.ts`** *(modify)*
- Add a theme-switcher button (a small `<button (click)="onToggleColorScheme()">` styled as a 28×28 icon) projected into `[sidenavFooterRight]`
- Remove the previous theme-switcher location (currently inside chat-debug palette — keep that too; not removing, just adding the sidenav-footer instance)

### Out of scope (separate sub-project B)
- Dropdown width
- Scroll fade above chat input
- Message-actions-bar padding

## Behavior

| State | What's visible |
|---|---|
| Expanded, no projects | New chat (primary pill) → New project (secondary pill) → Search → RECENT list → footer (left empty, right: theme + expand toggle) |
| Expanded, projects exist | Same, plus PROJECTS section with project list |
| Collapsed | Icon-only New chat (accent fill) → New project (gray) → Search icon → empty space → footer (vertical stack: theme + expand toggle); left slot hidden |
| Drawer (mobile) | Same as expanded but with overlay backdrop; no minimize affordance |

## Testing

### Component spec

`chat-sidenav.component.spec.ts` — 8 new vitest cases:
1. Renders default "+ New chat" button when no `[sidenavPrimary]` projected
2. Renders default "+ New project" button below New chat
3. Click on New chat emits `newChat`
4. Click on New project emits `newProjectRequested`
5. `[sidenavFooterLeft]` content projects into the footer's left position
6. `[sidenavFooterRight]` content projects into the footer's right position
7. `mode="collapsed"` hides left footer slot (`display: none`)
8. Expand toggle is the last child of the right footer slot

### Demo smoke (CHECKLIST.md)

Add three manual checks under a new "Sidenav polish" section:
- Click "+ New chat" → starts a new conversation
- Theme switcher (footer right) → toggles light/dark
- Collapse the sidenav → see icon-only New chat + New project + Search + theme + expand toggle, in that order; verify tooltips appear on hover

### Visual regression

Not formal — but a screenshot in the PR body showing expanded + collapsed states matches the mockup approved during brainstorming.

## Out of scope

- Replacing the existing `[sidenavAccount]` slot (deprecate, don't remove — back-compat)
- Tooltip implementation (use native `title="..."` attribute; richer popover tooltip is a separate feature)
- Drawer mode redesign (only changing expanded + collapsed; drawer keeps current behavior)
- Animations / transitions on the collapse toggle
- Account / user-avatar functionality

## References

- `libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts` — existing slot infrastructure (`sidenavHeader`, `sidenavPrimary`, `sidenavSections`, `sidenavAccount`) and outputs (`newChat`, `threadSelected`, `searchOpened`, `newProjectRequested`)
- `libs/chat/src/lib/styles/chat-sidenav.styles.ts` — existing sidenav CSS (~200 lines)
- Visual companion mockup: `.superpowers/brainstorm/53365-1778990640/content/sidenav-full-layout.html`
- Sub-project B follow-up: chat-surface polish (dropdown width, scroll fade, message-actions padding) — separate spec + PR
17 changes: 17 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,20 @@
flex-direction: column;
gap: 8px;
}

.demo-shell__theme-toggle {
width: 28px;
height: 28px;
border-radius: 8px;
border: 0;
background: transparent;
color: var(--ngaf-chat-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.demo-shell__theme-toggle:hover {
background: var(--ngaf-chat-surface-alt);
color: var(--ngaf-chat-text);
}
28 changes: 27 additions & 1 deletion examples/chat/angular/src/app/shell/demo-shell.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,33 @@
(searchOpened)="paletteOpen.set(true)"
(openChange)="onSidenavOpenChange($event)"
(modeChange)="onSidenavModeChange($event)"
/>
>
<button
sidenavFooterRight
type="button"
class="demo-shell__theme-toggle"
[attr.aria-label]="colorScheme() === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
(click)="onColorSchemeChange(colorScheme() === 'dark' ? 'light' : 'dark')"
>
@if (colorScheme() === 'dark') {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
} @else {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
}
</button>
</chat-sidenav>

<div
class="demo-shell__main"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts
// SPDX-License-Identifier: MIT
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { ChatSidenavComponent } from './chat-sidenav.component';
Expand Down Expand Up @@ -122,29 +123,28 @@ describe('ChatSidenavComponent', () => {

it('renders the collapse chevron in expanded mode with "Collapse sidenav" label', () => {
const fixture = render({ mode: 'expanded' });
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement;
expect(btn).not.toBeNull();
expect(btn.getAttribute('aria-label')).toBe('Collapse sidenav');
});

it('renders the expand chevron in collapsed mode with "Expand sidenav" label', () => {
const fixture = render({ mode: 'collapsed' });
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement;
expect(btn).not.toBeNull();
expect(btn.getAttribute('aria-label')).toBe('Expand sidenav');
});

it('omits the collapse chevron in drawer mode', () => {
const fixture = render({ mode: 'drawer' });
expect(fixture.nativeElement.querySelector('.chat-sidenav__action--collapse')).toBeNull();
expect(fixture.nativeElement.querySelector('.chat-sidenav__toggle')).toBeNull();
});

it('renders a topbar containing the new-chat and collapse buttons in expanded mode', () => {
it('renders a topbar containing the new-chat button in expanded mode', () => {
const fixture = render({ mode: 'expanded' });
const topbar = fixture.nativeElement.querySelector('.chat-sidenav__topbar') as HTMLElement;
expect(topbar).not.toBeNull();
expect(topbar.querySelector('.chat-sidenav__action--new')).not.toBeNull();
expect(topbar.querySelector('.chat-sidenav__action--collapse')).not.toBeNull();
});

it('search button is the only action in .chat-sidenav__actions row', () => {
Expand Down Expand Up @@ -176,7 +176,7 @@ describe('ChatSidenavComponent', () => {
const fixture = render({ mode: 'expanded' });
let last: string | undefined;
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement;
btn.click();
expect(last).toBe('collapsed');
});
Expand All @@ -185,7 +185,7 @@ describe('ChatSidenavComponent', () => {
const fixture = render({ mode: 'collapsed' });
let last: string | undefined;
fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; });
const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement;
const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement;
btn.click();
expect(last).toBe('expanded');
});
Expand Down Expand Up @@ -266,3 +266,74 @@ describe('ChatSidenavComponent', () => {
expect(emits).toBe(1);
});
});

describe('ChatSidenavComponent — footer slots', () => {
it('renders [sidenavFooterLeft] projected content in the left footer position', async () => {
@Component({
standalone: true,
imports: [ChatSidenavComponent],
template: `<chat-sidenav><span sidenavFooterLeft data-test="left-slot">L</span></chat-sidenav>`,
})
class HostLeft {}
TestBed.configureTestingModule({});
const fx = TestBed.createComponent(HostLeft);
fx.detectChanges();
const leftContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-left');
expect(leftContainer).toBeTruthy();
expect(leftContainer.querySelector('[data-test="left-slot"]')?.textContent).toBe('L');
});

it('renders [sidenavFooterRight] projected content in the right footer position', () => {
@Component({
standalone: true,
imports: [ChatSidenavComponent],
template: `<chat-sidenav><span sidenavFooterRight data-test="right-slot">R</span></chat-sidenav>`,
})
class HostRight {}
TestBed.configureTestingModule({});
const fx = TestBed.createComponent(HostRight);
fx.detectChanges();
const rightContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-right');
expect(rightContainer).toBeTruthy();
expect(rightContainer.querySelector('[data-test="right-slot"]')?.textContent).toBe('R');
});

it('renders the collapse toggle as the last child of the right footer container', () => {
TestBed.configureTestingModule({ imports: [ChatSidenavComponent] });
const fx = TestBed.createComponent(ChatSidenavComponent);
fx.detectChanges();
const rightContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-right');
expect(rightContainer).toBeTruthy();
const lastChild = rightContainer.children[rightContainer.children.length - 1];
expect(lastChild?.classList?.contains('chat-sidenav__toggle')).toBe(true);
});

it('removes the legacy collapse button from the topbar', () => {
TestBed.configureTestingModule({ imports: [ChatSidenavComponent] });
const fx = TestBed.createComponent(ChatSidenavComponent);
fx.detectChanges();
const topbar = fx.nativeElement.querySelector('.chat-sidenav__topbar');
expect(topbar?.querySelector('.chat-sidenav__action--collapse')).toBeFalsy();
});

it('clicking the new footer toggle emits modeChange', () => {
TestBed.configureTestingModule({ imports: [ChatSidenavComponent] });
const fx = TestBed.createComponent(ChatSidenavComponent);
fx.detectChanges();
let captured: string | null = null;
fx.componentInstance.modeChange.subscribe((m) => (captured = m));
const toggle = fx.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement;
toggle.click();
expect(captured).toBe('collapsed');
});
});

describe('ChatSidenavComponent — New chat primary CTA', () => {
it('renders the new-chat button with a primary-pill styling token', () => {
// Styles array is the second member of @Component decorator metadata.
const styles = (ChatSidenavComponent as unknown as { ɵcmp: { styles: string[] } }).ɵcmp.styles.join('\n');
// Primary pill family: matches chat-input send button.
expect(styles).toMatch(/\.chat-sidenav__action--new[^{]*\{[^}]*background:\s*var\(--ngaf-chat-primary/);
expect(styles).toMatch(/\.chat-sidenav__action--new[^{]*\{[^}]*border-radius:\s*9999px/);
});
});
Loading
Loading