From 9b0ed64b6ea1c010fa02b653086a11b82dc84692 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 11:14:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(examples-chat):=20palette=20v2=20=E2=80=94?= =?UTF-8?q?=20status=20pill=20+=20shadcn-styled=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the floating vertical column palette with a Next.js dev-tools-style status pill (top-right) that expands into a shadcn-refined panel with grouped sections (Mode / Model / Appearance / Action). - Pill collapsed state shows live model + mode; status dot pulses while streaming. - Panel uses a switch component for the debug toggle, segmented control for mode, and native visually replaced by trigger) ─── */ + +.palette-select { + position: relative; + display: inline-flex; align-items: center; - gap: 8px; - background: transparent; - border: 1px solid #303540; - color: inherit; - padding: 6px 8px; + justify-content: space-between; + gap: 6px; + min-width: 140px; + background: #09090b; + border: 1px solid #27272a; border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + color: #fafafa; cursor: pointer; - text-align: left; } -.palette__toggle.is-on { +.palette-select:focus-within { border-color: #4f8df5; + outline: 2px solid rgba(79, 141, 245, 0.3); + outline-offset: 1px; } -.palette__toggle-dot { - width: 10px; height: 10px; border-radius: 50%; - background: #303540; +.palette-select:hover { background: #0f0f12; } +.palette-select__caret { + color: #71717a; + font-size: 10px; } -.palette__toggle.is-on .palette__toggle-dot { - background: #4f8df5; -} - -.palette__action { +.palette-select select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + border: 0; background: transparent; - border: 1px solid #303540; + font: inherit; color: inherit; - padding: 6px 8px; - border-radius: 6px; - cursor: pointer; } -.palette__collapse { - background: transparent; +/* ── Switch (Debug toggle) ───────────────────────────────────────────── */ + +.palette-switch { + position: relative; + width: 36px; + height: 20px; + background: #27272a; border: 0; - color: #8a92a3; + border-radius: 999px; + cursor: pointer; + padding: 0; + transition: background 150ms ease; +} +.palette-switch.is-on { background: #4f8df5; } +.palette-switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: #fafafa; + border-radius: 50%; + transition: transform 150ms ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} +.palette-switch.is-on .palette-switch__thumb { transform: translateX(16px); } + +/* ── Action button ───────────────────────────────────────────────────── */ + +.palette-action { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + background: #27272a; + color: #fafafa; + border: 1px solid #3f3f46; + border-radius: 8px; + padding: 8px; + font-size: 13px; + font-weight: 500; + font-family: inherit; cursor: pointer; - align-self: flex-end; - font-size: 14px; + transition: background 120ms ease; +} +.palette-action:hover { background: #3f3f46; } +.palette-action__icon { font-size: 14px; } + +/* ── Responsive ──────────────────────────────────────────────────────── */ + +@media (max-width: 480px) { + .palette-panel { + width: calc(100vw - 24px); + } } diff --git a/examples/chat/angular/src/app/shell/control-palette.component.html b/examples/chat/angular/src/app/shell/control-palette.component.html index c8dbd0ef3..9df61ffb8 100644 --- a/examples/chat/angular/src/app/shell/control-palette.component.html +++ b/examples/chat/angular/src/app/shell/control-palette.component.html @@ -1,96 +1,147 @@ @if (collapsed()) { } @else { -
-
- - + + class="palette-panel__close" + aria-label="Close control palette" + (click)="close()" + >× + - +
+

Mode

+
+ + + +
+
- +
- +
+

Model

+
+ +
+ {{ labelFor(model(), modelOptions()) }} + + +
+
+
+ +
+ {{ labelFor(effort(), effortOptions()) }} + + +
+
+
+ +
+ {{ labelFor(genUiMode(), genUiOptions()) }} + + +
+
+
- +
- +
+

Appearance

+
+ +
+ {{ labelFor(theme(), themeOptions()) }} + + +
+
+
+ Debug overlay + +
+
- +
- +
+ +
} diff --git a/examples/chat/angular/src/app/shell/control-palette.component.spec.ts b/examples/chat/angular/src/app/shell/control-palette.component.spec.ts new file mode 100644 index 000000000..5c907fdc9 --- /dev/null +++ b/examples/chat/angular/src/app/shell/control-palette.component.spec.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ControlPalette } from './control-palette.component'; +import { PalettePersistence } from './palette-persistence.service'; + +@Component({ + standalone: true, + imports: [ControlPalette], + template: ``, +}) +class HostComponent { + debug = signal(false); + streaming = signal(false); +} + +class StubPersistence { + private store: Record = {}; + read(key: string): T | undefined { return this.store[key] as T | undefined; } + write(key: string, value: T): void { this.store[key] = value; } +} + +describe('ControlPalette — pill / panel toggle', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ provide: PalettePersistence, useClass: StubPersistence }], + }); + }); + + it('starts in the pill state (collapsed) on first run', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + expect(fx.nativeElement.querySelector('.palette-panel')).toBeNull(); + }); + + it('clicking the pill opens the panel', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeNull(); + }); + + it('clicking the close button collapses the panel back to a pill', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-panel__close') as HTMLButtonElement).click(); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('Escape keydown closes the panel', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('document click outside the palette closes it', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + + const outsideTarget = document.createElement('div'); + document.body.appendChild(outsideTarget); + outsideTarget.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fx.detectChanges(); + document.body.removeChild(outsideTarget); + + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('document click INSIDE the panel does not close it', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + + const title = fx.nativeElement.querySelector('.palette-panel__title') as HTMLElement; + title.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fx.detectChanges(); + + expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); + }); + + it('streaming=true adds the streaming class on the dot when pill is visible', () => { + const fx = TestBed.createComponent(HostComponent); + fx.componentInstance.streaming.set(true); + fx.detectChanges(); + expect( + fx.nativeElement.querySelector('.palette-pill__dot--streaming'), + ).toBeTruthy(); + }); + + it('debug switch toggles aria-checked via output', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + const sw = fx.nativeElement.querySelector('.palette-switch') as HTMLButtonElement; + expect(sw.getAttribute('aria-checked')).toBe('false'); + sw.click(); + fx.detectChanges(); + expect(sw.getAttribute('aria-checked')).toBe('true'); + }); +}); diff --git a/examples/chat/angular/src/app/shell/control-palette.component.ts b/examples/chat/angular/src/app/shell/control-palette.component.ts index ffdc18382..60537dd1c 100644 --- a/examples/chat/angular/src/app/shell/control-palette.component.ts +++ b/examples/chat/angular/src/app/shell/control-palette.component.ts @@ -7,6 +7,8 @@ import { signal, inject, effect, + ElementRef, + HostListener, } from '@angular/core'; import { PalettePersistence } from './palette-persistence.service'; import type { DemoMode } from './demo-shell.component'; @@ -20,6 +22,7 @@ import type { DemoMode } from './demo-shell.component'; }) export class ControlPalette { private readonly persistence = inject(PalettePersistence); + private readonly elementRef = inject>(ElementRef); readonly mode = input.required(); readonly model = input.required(); @@ -31,6 +34,8 @@ export class ControlPalette { readonly theme = input.required(); readonly themeOptions = input.required(); readonly debugOpen = input.required(); + /** True while the agent is streaming. Drives the status-dot pulse. */ + readonly streaming = input(false); readonly modeChange = output(); readonly modelChange = output(); @@ -40,7 +45,12 @@ export class ControlPalette { readonly debugOpenChange = output(); readonly newConversation = output(); - protected readonly collapsed = signal(this.persistence.read('collapsed') ?? false); + /** + * Whether the palette is collapsed to its status-pill state. Defaults + * to true (pill = resting state, matching Next.js dev tools). Persisted + * across reloads via PalettePersistence. + */ + protected readonly collapsed = signal(this.persistence.read('collapsed') ?? true); constructor() { effect(() => { @@ -48,8 +58,12 @@ export class ControlPalette { }); } - protected toggleCollapsed(): void { - this.collapsed.update((c) => !c); + protected expand(): void { + this.collapsed.set(false); + } + + protected close(): void { + this.collapsed.set(true); } protected pickMode(next: DemoMode): void { @@ -83,4 +97,36 @@ export class ControlPalette { protected emitNewConversation(): void { this.newConversation.emit(); } + + /** + * Close the panel on document-level clicks outside the palette. + * No-ops when already collapsed; checks event.target containment so + * inside-panel clicks don't close. + */ + @HostListener('document:click', ['$event']) + protected onDocumentClick(event: MouseEvent): void { + if (this.collapsed()) return; + const target = event.target as Node | null; + if (target && this.elementRef.nativeElement.contains(target)) return; + this.close(); + } + + /** Close on Escape anywhere in the document while the panel is open. */ + @HostListener('document:keydown.escape') + protected onEscape(): void { + if (!this.collapsed()) this.close(); + } + + /** + * Selected-option label for a value across an options list. Used by the + * styled select trigger to show the human-friendly label rather than + * the raw value. + */ + protected labelFor( + value: string, + options: readonly { value: string; label: string }[], + ): string { + const match = options.find(o => o.value === value); + return match?.label ?? value; + } } diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html index 0cb6be6e5..a33c8ef95 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -18,6 +18,7 @@ [theme]="theme()" [themeOptions]="themeOptions()" [debugOpen]="debugOpen()" + [streaming]="agent.status() === 'running'" (modeChange)="onModeChange($event)" (modelChange)="onModelChange($event)" (effortChange)="onEffortChange($event)"