From e83d09d0c7b229627202e073264264ad8b6d2a11 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 15:49:30 -0700 Subject: [PATCH 01/14] docs(spec): settings gear + logout design Adds a settings popover (model, layout, logout) to the playground. Captures DOM/CSS layout-preset strategy, localStorage schema, and the Basic Auth poison-cache logout flow before implementation. --- .../2026-04-22-settings-and-logout-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-settings-and-logout-design.md diff --git a/docs/superpowers/specs/2026-04-22-settings-and-logout-design.md b/docs/superpowers/specs/2026-04-22-settings-and-logout-design.md new file mode 100644 index 0000000..533b19a --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-settings-and-logout-design.md @@ -0,0 +1,196 @@ +# Settings Gear and Logout — Design + +**Date:** 2026-04-22 +**Scope:** Playground UI (`playground/`) plus two small server additions (`api/agent.ts`, new `api/logout.ts`). + +## Goal + +Add a settings gear to the playground header that opens a popover with three sections: + +1. **Model** — pick the LLM used by the real-agent backend. +2. **Layout** — switch between three layout presets, toggle the AI panel, reset panel sizes. +3. **Account** — log out of HTTP Basic Auth. + +All preferences persist in `localStorage`. The current playground UX (three panels, drag-to-resize, real/mock agent fallback) is preserved; the settings popover augments it. + +## Non-goals + +- Theming / dark mode toggle (playground is already single-theme). +- Per-preset layout editors beyond the preset + resize controls described here. +- Server-side user accounts. Auth stays HTTP Basic via the existing middleware. +- Broadening the model picker into a capability matrix (cost, latency, vision, etc.) — just slugs. + +## User-facing behavior + +### Settings gear + +- A ⚙️ button is added to the playground header, top-right. Accessible via keyboard (`button type="button"`, focusable, `aria-label="Open settings"`, `aria-expanded` reflects popover state). +- Clicking opens a popover anchored to the gear. Click outside, press `Esc`, or click the gear again closes it. +- The popover is rendered as a sibling of the gear (not inside a modal layer) so it uses CSS for positioning (`position: absolute; top: 100%; right: 0`). + +### Popover: Model section + +- Section heading: "Model". +- ` + + + + + + +
+

AI

+
+
+ + + +
+

UI

+
+
+ + + +``` + +Also replace the existing `
` block with (adds the gear + popover root): + +```html +
+
+

stream-ui

+

Human → Agent → Streamed UI. Try: make a button, build a form, show a list, card.

+
+
+ + +
+
+``` + +- [ ] **Step 2: Replace the grid CSS block** + +In `playground/style.css`, locate the `.grid { … }` rule and the `.region-*` / `.resizer-*` rules (roughly lines 42–95 based on the current file). Replace them with the following single block: + +```css +header { + margin-bottom: 1rem; + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.header-main { + flex: 1 1 auto; + min-width: 0; +} +.header-actions { + position: relative; + flex: 0 0 auto; +} +.settings-btn { + background: transparent; + border: 1px solid rgba(127, 127, 127, 0.3); + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0.35rem 0.5rem; + border-radius: 0.35rem; +} +.settings-btn:hover { + background: rgba(127, 127, 127, 0.12); +} +.settings-popover { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + min-width: 260px; + max-width: 320px; + background: Canvas; + color: CanvasText; + border: 1px solid rgba(127, 127, 127, 0.35); + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 0.75rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.settings-popover[hidden] { + display: none; +} +.settings-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.settings-section h3 { + margin: 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; +} +.settings-layout-presets { + display: flex; + gap: 0.35rem; +} +.settings-layout-presets button { + flex: 1; + padding: 0.35rem 0.4rem; + border: 1px solid rgba(127, 127, 127, 0.3); + background: transparent; + color: inherit; + border-radius: 0.35rem; + cursor: pointer; + font-size: 0.8rem; +} +.settings-layout-presets button[aria-pressed='true'] { + background: rgba(100, 150, 255, 0.2); + border-color: rgba(100, 150, 255, 0.6); +} +.settings-logout { + background: rgba(220, 60, 60, 0.15); + border: 1px solid rgba(220, 60, 60, 0.5); + color: rgb(220, 60, 60); + padding: 0.4rem 0.6rem; + border-radius: 0.35rem; + cursor: pointer; + font-size: 0.85rem; +} +.settings-logout:hover { + background: rgba(220, 60, 60, 0.25); +} + +.grid { + display: grid; + gap: 0.5rem; + flex: 1; + min-height: 0; +} + +.region { + min-width: 0; + min-height: 0; +} + +.resizer { + background: rgba(127, 127, 127, 0.25); + border-radius: 3px; + transition: background 120ms ease; + user-select: none; + touch-action: none; +} +.resizer:hover, +.resizer.dragging { + background: rgba(100, 150, 255, 0.6); +} +.resizer.axis-row { + cursor: row-resize; +} +.resizer.axis-col { + cursor: col-resize; +} + +/* ─── Preset: default (CHAT + AI on top row, UI fills bottom) ─── */ +.grid[data-layout='default'] { + grid-template-columns: minmax(160px, var(--col-chat, 1fr)) 6px minmax(160px, var(--col-ai, 1fr)); + grid-template-rows: minmax(80px, var(--row-top, 1fr)) 6px minmax(0, var(--row-ui, 3fr)); + grid-template-areas: + 'chat chat-ai ai' + 'top-bottom top-bottom top-bottom' + 'ui ui ui'; +} +.grid[data-layout='default'] .region-chat { grid-area: chat; } +.grid[data-layout='default'] .region-ai { grid-area: ai; } +.grid[data-layout='default'] .region-ui { grid-area: ui; } +.grid[data-layout='default'] .resizer[data-pair='chat-ai'] { grid-area: chat-ai; } +.grid[data-layout='default'] .resizer[data-pair='top-bottom'] { grid-area: top-bottom; } +.grid[data-layout='default'] .resizer[data-pair='ai-ui'] { display: none; } + +.grid[data-layout='default'][data-hide-ai] { + grid-template-columns: 1fr; + grid-template-rows: minmax(80px, var(--row-top, 1fr)) 6px minmax(0, var(--row-ui, 3fr)); + grid-template-areas: + 'chat' + 'top-bottom' + 'ui'; +} +.grid[data-layout='default'][data-hide-ai] .region-ai, +.grid[data-layout='default'][data-hide-ai] .resizer[data-pair='chat-ai'] { display: none; } + +/* ─── Preset: sideBySide (CHAT | AI | UI in one row) ─── */ +.grid[data-layout='sideBySide'] { + grid-template-rows: 1fr; + grid-template-columns: + minmax(160px, var(--col-chat, 1fr)) + 6px + minmax(160px, var(--col-ai, 1fr)) + 6px + minmax(160px, var(--col-ui, 1fr)); + grid-template-areas: 'chat chat-ai ai ai-ui ui'; +} +.grid[data-layout='sideBySide'] .region-chat { grid-area: chat; } +.grid[data-layout='sideBySide'] .region-ai { grid-area: ai; } +.grid[data-layout='sideBySide'] .region-ui { grid-area: ui; } +.grid[data-layout='sideBySide'] .resizer[data-pair='chat-ai'] { grid-area: chat-ai; } +.grid[data-layout='sideBySide'] .resizer[data-pair='ai-ui'] { grid-area: ai-ui; } +.grid[data-layout='sideBySide'] .resizer[data-pair='top-bottom'] { display: none; } + +.grid[data-layout='sideBySide'][data-hide-ai] { + grid-template-columns: + minmax(160px, var(--col-chat, 1fr)) + 6px + minmax(160px, var(--col-ui, 1fr)); + grid-template-areas: 'chat chat-ai ui'; +} +.grid[data-layout='sideBySide'][data-hide-ai] .region-ai, +.grid[data-layout='sideBySide'][data-hide-ai] .resizer[data-pair='ai-ui'] { display: none; } +.grid[data-layout='sideBySide'][data-hide-ai] .resizer[data-pair='chat-ai'] { grid-area: chat-ai; } + +/* ─── Preset: stacked (CHAT / AI / UI in one column) ─── */ +.grid[data-layout='stacked'] { + grid-template-columns: 1fr; + grid-template-rows: + minmax(80px, var(--row-chat, 1fr)) + 6px + minmax(80px, var(--row-ai, 1fr)) + 6px + minmax(80px, var(--row-ui, 1fr)); + grid-template-areas: + 'chat' + 'chat-ai' + 'ai' + 'ai-ui' + 'ui'; +} +.grid[data-layout='stacked'] .region-chat { grid-area: chat; } +.grid[data-layout='stacked'] .region-ai { grid-area: ai; } +.grid[data-layout='stacked'] .region-ui { grid-area: ui; } +.grid[data-layout='stacked'] .resizer[data-pair='chat-ai'] { grid-area: chat-ai; } +.grid[data-layout='stacked'] .resizer[data-pair='ai-ui'] { grid-area: ai-ui; } +.grid[data-layout='stacked'] .resizer[data-pair='top-bottom'] { display: none; } + +.grid[data-layout='stacked'][data-hide-ai] { + grid-template-rows: + minmax(80px, var(--row-chat, 1fr)) + 6px + minmax(80px, var(--row-ui, 1fr)); + grid-template-areas: + 'chat' + 'chat-ai' + 'ui'; +} +.grid[data-layout='stacked'][data-hide-ai] .region-ai, +.grid[data-layout='stacked'][data-hide-ai] .resizer[data-pair='ai-ui'] { display: none; } +``` + +- [ ] **Step 3: Open the playground and confirm default layout still renders** + +Start the preview server if not running, navigate to the playground, confirm three panels + two resizers render as before. (JS still uses old resize logic at this point — resize may misbehave until Task 7. Visual confirmation is enough here.) + +- [ ] **Step 4: Commit** + +```bash +git add playground/index.html playground/style.css +git commit -m "feat(playground): grid + CSS presets and gear/popover scaffolding" +``` + +--- + +## Task 7: Client layout engine + per-preset resize + +**Files:** +- Modify: `playground/main.ts:129-265` (the panel-resizing section) + +- [ ] **Step 1: Delete the old resize code** + +In `playground/main.ts`, delete the entire block starting at the comment `// ─── panel resizing ─────────────────────────────────────────────────────` down to the closing `}` that ends the `if (grid) { … }` block. This removes: + +- The `PANEL_SIZES_KEY`, `MIN_PANEL_PX`, and `PanelSizes` type +- `loadPanelSizes`, `applyPanelSizes`, `savePanelSizes`, `measurePanelSizes`, `clampPair` +- The `if (grid) { … }` that wires pointer handlers + +Leave the preceding `const grid = document.getElementById('grid') as HTMLDivElement | null` line in place. + +- [ ] **Step 2: Add the new layout engine** + +Immediately after the `const grid = …` line, insert: + +```ts +import { + type LayoutPreset, + type ResizerPair, + readSettings, + writeSettings, +} from './settings' + +const MIN_FRACTION = 0.08 + +type Axis = 'row' | 'col' + +// For each preset, declare which pair each resizer controls and on what axis. +const PAIR_AXIS: Record>> = { + default: { 'chat-ai': 'col', 'top-bottom': 'row' }, + sideBySide: { 'chat-ai': 'col', 'ai-ui': 'col' }, + stacked: { 'chat-ai': 'row', 'ai-ui': 'row' }, +} + +// For each pair on each preset, list the two CSS custom-property track names +// (first + second region) that participate in the drag. +const PAIR_TRACKS: Record>> = { + default: { + 'chat-ai': ['--col-chat', '--col-ai'], + 'top-bottom': ['--row-top', '--row-ui'], + }, + sideBySide: { + 'chat-ai': ['--col-chat', '--col-ai'], + 'ai-ui': ['--col-ai', '--col-ui'], + }, + stacked: { + 'chat-ai': ['--row-chat', '--row-ai'], + 'ai-ui': ['--row-ai', '--row-ui'], + }, +} + +// Which two regions (by CSS class fragment) each pair resizes. +const PAIR_REGIONS: Record = { + 'chat-ai': ['region-chat', 'region-ai'], + 'ai-ui': ['region-ai', 'region-ui'], + 'top-bottom': ['region-chat', 'region-ui'], // "top" is chat row, "bottom" is ui +} + +function applyLayout(g: HTMLDivElement): void { + const s = readSettings() + g.dataset.layout = s.layout + if (s.hideAI) g.dataset.hideAi = '' + else delete g.dataset.hideAi + + // apply sizes as CSS custom properties (fractions as fr) + const saved = s.sizes[s.layout] + const axisMap = PAIR_AXIS[s.layout] + for (const [pair, tracks] of Object.entries(PAIR_TRACKS[s.layout]) as [ + ResizerPair, + [string, string], + ][]) { + const v = saved[pair] + if (typeof v === 'number' && v > 0 && v < 1) { + g.style.setProperty(tracks[0], `${v}fr`) + g.style.setProperty(tracks[1], `${1 - v}fr`) + } else { + g.style.removeProperty(tracks[0]) + g.style.removeProperty(tracks[1]) + } + } + + // axis classes on resizers (used only for cursor styling; CSS handles display) + for (const r of g.querySelectorAll('.resizer')) { + const pair = r.dataset.pair as ResizerPair | undefined + r.classList.remove('axis-row', 'axis-col') + if (!pair) continue + const axis = axisMap[pair] + if (axis) r.classList.add(`axis-${axis}`) + } +} + +function wireResizers(g: HTMLDivElement): void { + for (const resizer of g.querySelectorAll('.resizer')) { + resizer.addEventListener('pointerdown', (e) => { + const pair = resizer.dataset.pair as ResizerPair | undefined + if (!pair) return + const layout = (g.dataset.layout as LayoutPreset) ?? 'default' + const axis = PAIR_AXIS[layout][pair] + const tracks = PAIR_TRACKS[layout][pair] + if (!axis || !tracks) return + + e.preventDefault() + resizer.setPointerCapture(e.pointerId) + resizer.classList.add('dragging') + document.body.classList.add(axis === 'col' ? 'resizing-col' : 'resizing-row') + + const [firstClass, secondClass] = PAIR_REGIONS[pair] + const first = g.querySelector(`.${firstClass}`) + const second = g.querySelector(`.${secondClass}`) + if (!first || !second) return + + const firstRect = first.getBoundingClientRect() + const secondRect = second.getBoundingClientRect() + const totalPx = + axis === 'col' ? firstRect.width + secondRect.width : firstRect.height + secondRect.height + const startFirstPx = axis === 'col' ? firstRect.width : firstRect.height + const startCoord = axis === 'col' ? e.clientX : e.clientY + + const onMove = (ev: PointerEvent) => { + const coord = axis === 'col' ? ev.clientX : ev.clientY + const delta = coord - startCoord + let nextFirst = (startFirstPx + delta) / totalPx + nextFirst = Math.max(MIN_FRACTION, Math.min(1 - MIN_FRACTION, nextFirst)) + g.style.setProperty(tracks[0], `${nextFirst}fr`) + g.style.setProperty(tracks[1], `${1 - nextFirst}fr`) + } + + const onEnd = (ev: PointerEvent) => { + resizer.releasePointerCapture(ev.pointerId) + resizer.classList.remove('dragging') + document.body.classList.remove('resizing-col', 'resizing-row') + resizer.removeEventListener('pointermove', onMove) + resizer.removeEventListener('pointerup', onEnd) + resizer.removeEventListener('pointercancel', onEnd) + + // persist the final fraction + const firstNow = (axis === 'col' ? first.getBoundingClientRect().width : first.getBoundingClientRect().height) + const secondNow = (axis === 'col' ? second.getBoundingClientRect().width : second.getBoundingClientRect().height) + const total = firstNow + secondNow + if (total <= 0) return + const fraction = firstNow / total + writeSettings({ sizes: { [layout]: { [pair]: fraction } } }) + } + + resizer.addEventListener('pointermove', onMove) + resizer.addEventListener('pointerup', onEnd) + resizer.addEventListener('pointercancel', onEnd) + }) + } +} + +if (grid) { + applyLayout(grid) + wireResizers(grid) +} +``` + +(Move the `import { … } from './settings'` statement to the top of the file next to the existing imports — TypeScript requires imports at the top. Keep the rest of the block inline in place of the deleted resize code.) + +- [ ] **Step 3: Verify typecheck + lint** + +Run: `bun run typecheck && bun run lint` +Expected: both pass. + +- [ ] **Step 4: Preview the playground** + +Start preview if needed, load the page, drag both resizers in the default preset, reload, confirm sizes persisted. Open devtools → Application → Local Storage → inspect `sui.layout.sizes.default` for the fractions. + +- [ ] **Step 5: Commit** + +```bash +git add playground/main.ts +git commit -m "feat(playground): layout engine with per-preset resizers" +``` + +--- + +## Task 8: Settings popover UI + +**Files:** +- Create: `playground/settings-ui.ts` +- Modify: `playground/main.ts` (add one-line mount call near the top-level bootstrap) + +- [ ] **Step 1: Write the popover module** + +Create `playground/settings-ui.ts`: + +```ts +import { + type LayoutPreset, + LAYOUT_PRESETS, + MODEL_PRESETS, + clearSizes, + readSettings, + writeSettings, +} from './settings' + +export type PopoverCallbacks = { + onLayoutChange: () => void + onLogout: () => void +} + +const PRESET_LABELS: Record = { + default: 'Default', + sideBySide: 'Side-by-side', + stacked: 'Stacked', +} + +export function mountSettingsPopover( + btn: HTMLButtonElement, + popover: HTMLDivElement, + cb: PopoverCallbacks, +): void { + render() + + btn.addEventListener('click', () => { + const open = popover.hasAttribute('hidden') + if (open) show() + else hide() + }) + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !popover.hasAttribute('hidden')) hide() + }) + + document.addEventListener('pointerdown', (e) => { + if (popover.hasAttribute('hidden')) return + const target = e.target as Node + if (popover.contains(target) || btn.contains(target)) return + hide() + }) + + function show() { + render() + popover.removeAttribute('hidden') + btn.setAttribute('aria-expanded', 'true') + } + + function hide() { + popover.setAttribute('hidden', '') + btn.setAttribute('aria-expanded', 'false') + } + + function render() { + const s = readSettings() + const isCustomModel = !MODEL_PRESETS.includes(s.model) + popover.replaceChildren( + section('Model', [modelSelect(s.model, isCustomModel), customModelInput(s.model, isCustomModel)]), + section('Layout', [presetRow(s.layout), hideAIRow(s.hideAI), resetSizesRow(s.layout)]), + section('Account', [logoutRow()]), + ) + } + + function section(title: string, children: HTMLElement[]): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'settings-section' + const h = document.createElement('h3') + h.textContent = title + wrap.append(h, ...children) + return wrap + } + + function modelSelect(current: string, isCustom: boolean): HTMLElement { + const sel = document.createElement('select') + for (const m of MODEL_PRESETS) { + const opt = document.createElement('option') + opt.value = m + opt.textContent = m + if (!isCustom && m === current) opt.selected = true + sel.appendChild(opt) + } + const customOpt = document.createElement('option') + customOpt.value = '__custom__' + customOpt.textContent = 'Custom…' + if (isCustom) customOpt.selected = true + sel.appendChild(customOpt) + + sel.addEventListener('change', () => { + if (sel.value === '__custom__') { + writeSettings({ model: current || '' }) + } else { + writeSettings({ model: sel.value }) + } + render() + }) + return sel + } + + function customModelInput(current: string, isCustom: boolean): HTMLElement { + const input = document.createElement('input') + input.type = 'text' + input.placeholder = 'provider/model-slug' + input.value = isCustom ? current : '' + input.hidden = !isCustom + input.addEventListener('input', () => { + writeSettings({ model: input.value.trim() }) + }) + return input + } + + function presetRow(current: LayoutPreset): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'settings-layout-presets' + wrap.setAttribute('role', 'radiogroup') + for (const p of LAYOUT_PRESETS) { + const b = document.createElement('button') + b.type = 'button' + b.textContent = PRESET_LABELS[p] + b.setAttribute('role', 'radio') + b.setAttribute('aria-pressed', String(p === current)) + b.addEventListener('click', () => { + writeSettings({ layout: p }) + render() + cb.onLayoutChange() + }) + wrap.appendChild(b) + } + return wrap + } + + function hideAIRow(current: boolean): HTMLElement { + const label = document.createElement('label') + label.style.display = 'flex' + label.style.alignItems = 'center' + label.style.gap = '0.4rem' + const box = document.createElement('input') + box.type = 'checkbox' + box.checked = current + box.addEventListener('change', () => { + writeSettings({ hideAI: box.checked }) + cb.onLayoutChange() + }) + const text = document.createElement('span') + text.textContent = 'Hide AI panel' + label.append(box, text) + return label + } + + function resetSizesRow(active: LayoutPreset): HTMLElement { + const b = document.createElement('button') + b.type = 'button' + b.textContent = `Reset sizes (${PRESET_LABELS[active]})` + b.addEventListener('click', () => { + clearSizes(active) + cb.onLayoutChange() + }) + return b + } + + function logoutRow(): HTMLElement { + const b = document.createElement('button') + b.type = 'button' + b.className = 'settings-logout' + b.textContent = 'Log out' + b.title = 'You may need to close this tab in some browsers' + b.addEventListener('click', () => cb.onLogout()) + return b + } +} +``` + +- [ ] **Step 2: Mount the popover from `main.ts`** + +`applyLayout` is file-local in `main.ts`, so `settings-ui.ts` gets re-apply behaviour via a callback. Add this wiring to `playground/main.ts`: + +1. With the other top-of-file imports, add: + + ```ts + import { mountSettingsPopover } from './settings-ui' + ``` + +2. Directly after the `if (grid) { applyLayout(grid); wireResizers(grid) }` block from Task 7, append: + + ```ts + function doLogout(): void { + // implemented in Task 10 + } + + const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement | null + const settingsPopover = document.getElementById('settings-popover') as HTMLDivElement | null + if (settingsBtn && settingsPopover && grid) { + mountSettingsPopover(settingsBtn, settingsPopover, { + onLayoutChange: () => applyLayout(grid), + onLogout: () => doLogout(), + }) + } + ``` + +- [ ] **Step 3: Typecheck and lint** + +Run: `bun run typecheck && bun run lint` +Expected: both pass. + +- [ ] **Step 4: Preview verification** + +- Click ⚙️ — popover opens. +- Click outside — closes. +- Press Esc — closes. +- Switch between Default / Side-by-side / Stacked — grid updates live. +- Toggle "Hide AI panel" — AI region hides; preset adapts. +- Drag a resizer in each preset; reload; sizes persist per preset (check `sui.layout.sizes.*`). +- Click "Reset sizes" — active preset's sizes clear, reverts to CSS defaults. +- Select "openai/gpt-5" — verify `sui.model` updates in localStorage. +- Select "Custom…" — text input appears, type a slug — verify `sui.model` updates. + +Take a screenshot via the preview tools for the record. + +- [ ] **Step 5: Commit** + +```bash +git add playground/settings-ui.ts playground/main.ts +git commit -m "feat(playground): settings popover (model, layout, logout)" +``` + +--- + +## Task 9: Send model in `/api/agent` requests + +**Files:** +- Modify: `playground/main.ts:750` (the `realAgent` fetch body) + +- [ ] **Step 1: Include `model` in the POST body** + +In `playground/main.ts`, inside `async function* realAgent(msgs: PlaygroundMessage[])`, replace: + +```ts + response = await fetch('/api/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: msgs }), + }) +``` + +with: + +```ts + const { model } = readSettings() + response = await fetch('/api/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: msgs, model }), + }) +``` + +(`readSettings` is already imported in Task 7.) + +- [ ] **Step 2: Preview end-to-end** + +Start the full playground (`bun run playground:full`) with a valid `AI_GATEWAY_API_KEY`. Pick a non-default model in the popover, send a prompt, confirm the response and that the network tab's POST body includes `"model":"openai/gpt-5"` (or whichever you picked). + +- [ ] **Step 3: Commit** + +```bash +git add playground/main.ts +git commit -m "feat(playground): include chosen model in agent requests" +``` + +--- + +## Task 10: Wire the Logout button + +**Files:** +- Modify: `playground/main.ts` (the `doLogout` placeholder from Task 8) + +- [ ] **Step 1: Implement `doLogout`** + +Replace the `doLogout` placeholder added in Task 8 with: + +```ts +async function doLogout(): Promise { + try { + await fetch('/api/logout', { method: 'POST', cache: 'no-store' }) + } catch { + // ignore — we still want to reload + } + try { + // Poison Chrome's cached Basic Auth creds: a request with bogus creds + // replaces the cache entry so the next navigation re-prompts. + await fetch('/', { + method: 'GET', + cache: 'no-store', + headers: { Authorization: `Basic ${btoa('logout:logout')}` }, + }) + } catch { + // ignore + } + window.location.reload() +} +``` + +- [ ] **Step 2: Preview the flow** + +In a test deployment with `BASIC_AUTH_PASS` set: +- Log in. +- Open ⚙️, click "Log out". +- Browser should re-prompt for Basic Auth credentials. + +If running locally without Basic Auth (no `BASIC_AUTH_PASS`), the middleware is a no-op; the reload still happens but no re-prompt — that's expected. Confirm at minimum that `/api/logout` returns 401 and the page reloads. + +- [ ] **Step 3: Commit** + +```bash +git add playground/main.ts +git commit -m "feat(playground): log out via 401 + cache-poison + reload" +``` + +--- + +## Task 11: Ship gate + +**Files:** none (verification only) + +- [ ] **Step 1: Lint** + +Run: `bun run lint` +Expected: passes. If biome flags style issues, run `bun run format` and re-lint. + +- [ ] **Step 2: Typecheck** + +Run: `bun run typecheck` +Expected: passes. + +- [ ] **Step 3: Tests** + +Run: `bun run test` +Expected: every suite passes, including new `playground/settings.test.ts` and `api/model.test.ts`. + +- [ ] **Step 4: Library build (unchanged, sanity check)** + +Run: `bun run build` +Expected: `dist/` rebuilds cleanly. + +- [ ] **Step 5: Playground build (final proof)** + +Run: `bun run playground:build` +Expected: vite produces `playground/dist/` without errors or warnings related to the new code. + +- [ ] **Step 6: Final commit (only if format/build produced incidental changes)** + +```bash +git status +# If anything is modified: +git add -A +git commit -m "chore: format and build artifacts from settings/logout feature" +``` From 2be5278d0fbab91152a26a2114e77a87eae1e566 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:01:46 -0700 Subject: [PATCH 03/14] test(config): include playground and api test files --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 3421eb9..5bf67a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ root: '.', test: { environment: 'happy-dom', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'playground/**/*.test.ts', 'api/**/*.test.ts'], }, }) From be2ace88466fbff1465c4f79ab655adb075c864b Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:04:17 -0700 Subject: [PATCH 04/14] feat(playground): settings data layer with per-preset size storage --- playground/settings.test.ts | 44 ++++++++++++++++ playground/settings.ts | 101 ++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 playground/settings.test.ts create mode 100644 playground/settings.ts diff --git a/playground/settings.test.ts b/playground/settings.test.ts new file mode 100644 index 0000000..8f1dcba --- /dev/null +++ b/playground/settings.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test } from 'vitest' +import { DEFAULT_MODEL, type LayoutPreset, readSettings, writeSettings } from './settings' + +beforeEach(() => { + localStorage.clear() +}) + +describe('readSettings', () => { + test('returns defaults when localStorage is empty', () => { + const s = readSettings() + expect(s.model).toBe(DEFAULT_MODEL) + expect(s.layout).toBe('default') + expect(s.hideAI).toBe(false) + expect(s.sizes).toEqual({ default: {}, sideBySide: {}, stacked: {} }) + }) + + test('round-trips written values', () => { + writeSettings({ model: 'openai/gpt-5', layout: 'stacked', hideAI: true }) + const s = readSettings() + expect(s.model).toBe('openai/gpt-5') + expect(s.layout).toBe('stacked') + expect(s.hideAI).toBe(true) + }) + + test('sizes are keyed per preset', () => { + writeSettings({ sizes: { default: { 'chat-ai': 0.4 } } }) + writeSettings({ sizes: { sideBySide: { 'ai-ui': 0.5 } } }) + const s = readSettings() + expect(s.sizes.default['chat-ai']).toBe(0.4) + expect(s.sizes.sideBySide['ai-ui']).toBe(0.5) + expect(s.sizes.stacked).toEqual({}) + }) + + test('tolerates corrupt JSON in size keys', () => { + localStorage.setItem('sui.layout.sizes.default', 'not-json{{{') + const s = readSettings() + expect(s.sizes.default).toEqual({}) + }) + + test('unknown layout preset falls back to default', () => { + localStorage.setItem('sui.layout.preset', 'wat') + expect(readSettings().layout).toBe('default') + }) +}) diff --git a/playground/settings.ts b/playground/settings.ts new file mode 100644 index 0000000..2419183 --- /dev/null +++ b/playground/settings.ts @@ -0,0 +1,101 @@ +export const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6' + +export const MODEL_PRESETS: ReadonlyArray = [ + 'anthropic/claude-sonnet-4-6', + 'anthropic/claude-opus-4-7', + 'anthropic/claude-haiku-4-5', + 'openai/gpt-5', + 'openai/gpt-4o', + 'google/gemini-2.5-pro', +] + +export type LayoutPreset = 'default' | 'sideBySide' | 'stacked' +export const LAYOUT_PRESETS: ReadonlyArray = ['default', 'sideBySide', 'stacked'] + +export type ResizerPair = 'chat-ai' | 'ai-ui' | 'top-bottom' +export type SizeMap = Partial> + +export type SuiSettings = { + model: string + layout: LayoutPreset + hideAI: boolean + sizes: Record +} + +const KEY_MODEL = 'sui.model' +const KEY_LAYOUT = 'sui.layout.preset' +const KEY_HIDE_AI = 'sui.layout.hideAI' +const sizesKey = (p: LayoutPreset) => `sui.layout.sizes.${p}` + +function readJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key) + if (!raw) return fallback + const parsed = JSON.parse(raw) as unknown + return (parsed ?? fallback) as T + } catch { + return fallback + } +} + +function readSizeMap(preset: LayoutPreset): SizeMap { + const raw = readJSON(sizesKey(preset), {}) + if (!raw || typeof raw !== 'object') return {} + const out: SizeMap = {} + for (const [k, v] of Object.entries(raw as Record)) { + if ((k === 'chat-ai' || k === 'ai-ui' || k === 'top-bottom') && typeof v === 'number') { + out[k] = v + } + } + return out +} + +export function readSettings(): SuiSettings { + const rawLayout = localStorage.getItem(KEY_LAYOUT) + const layout: LayoutPreset = + rawLayout === 'default' || rawLayout === 'sideBySide' || rawLayout === 'stacked' + ? rawLayout + : 'default' + return { + model: localStorage.getItem(KEY_MODEL) ?? DEFAULT_MODEL, + layout, + hideAI: localStorage.getItem(KEY_HIDE_AI) === 'true', + sizes: { + default: readSizeMap('default'), + sideBySide: readSizeMap('sideBySide'), + stacked: readSizeMap('stacked'), + }, + } +} + +export type SettingsPatch = Partial<{ + model: string + layout: LayoutPreset + hideAI: boolean + sizes: Partial> +}> + +export function writeSettings(patch: SettingsPatch): void { + try { + if (patch.model !== undefined) localStorage.setItem(KEY_MODEL, patch.model) + if (patch.layout !== undefined) localStorage.setItem(KEY_LAYOUT, patch.layout) + if (patch.hideAI !== undefined) localStorage.setItem(KEY_HIDE_AI, String(patch.hideAI)) + if (patch.sizes !== undefined) { + for (const [preset, map] of Object.entries(patch.sizes) as [LayoutPreset, SizeMap][]) { + const existing = readSizeMap(preset) + const merged = { ...existing, ...map } + localStorage.setItem(sizesKey(preset), JSON.stringify(merged)) + } + } + } catch { + // quota / disabled storage — best effort + } +} + +export function clearSizes(preset: LayoutPreset): void { + try { + localStorage.removeItem(sizesKey(preset)) + } catch { + // ignore + } +} From 9b39f4b0ecd9151534dbbd7a9eaa5fee9fb807fc Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:06:47 -0700 Subject: [PATCH 05/14] chore: gitignore .superpowers/ brainstorm artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 39e7e8a..352ff4a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ coverage/ .claude/settings.local.json .vercel +.superpowers/ From 4053319a445a144d9a376be074aeae67cec99e76 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:07:32 -0700 Subject: [PATCH 06/14] feat(api): resolveModel helper validates client model override --- api/model.test.ts | 36 ++++++++++++++++++++++++++++++++++++ api/model.ts | 14 ++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 api/model.test.ts create mode 100644 api/model.ts diff --git a/api/model.test.ts b/api/model.test.ts new file mode 100644 index 0000000..d44c4db --- /dev/null +++ b/api/model.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest' +import { DEFAULT_MODEL, resolveModel } from './model' + +describe('resolveModel', () => { + test('returns default when nothing supplied', () => { + expect(resolveModel(undefined, {})).toBe(DEFAULT_MODEL) + }) + + test('uses AI_MODEL env when body model absent', () => { + expect(resolveModel({}, { AI_MODEL: 'anthropic/claude-opus-4-7' })).toBe( + 'anthropic/claude-opus-4-7', + ) + }) + + test('accepts valid body.model over env', () => { + expect( + resolveModel({ model: 'openai/gpt-5' }, { AI_MODEL: 'anthropic/claude-sonnet-4-6' }), + ).toBe('openai/gpt-5') + }) + + test('ignores empty string body.model', () => { + expect(resolveModel({ model: '' }, { AI_MODEL: 'openai/gpt-4o' })).toBe('openai/gpt-4o') + }) + + test('rejects malformed slug (no slash)', () => { + expect(resolveModel({ model: 'claude-sonnet-4-6' }, {})).toBe(DEFAULT_MODEL) + }) + + test('rejects slug with spaces or quotes', () => { + expect(resolveModel({ model: 'anthropic/claude 4; drop table' }, {})).toBe(DEFAULT_MODEL) + }) + + test('accepts slug with dots, dashes, underscores', () => { + expect(resolveModel({ model: 'vendor.x/my-model_v1.2' }, {})).toBe('vendor.x/my-model_v1.2') + }) +}) diff --git a/api/model.ts b/api/model.ts new file mode 100644 index 0000000..4054e61 --- /dev/null +++ b/api/model.ts @@ -0,0 +1,14 @@ +export const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6' + +const MODEL_SLUG = /^[\w.-]+\/[\w.-]+$/ + +export function resolveModel( + body: { model?: unknown } | undefined, + env: { AI_MODEL?: string }, +): string { + const candidate = body && typeof body.model === 'string' ? body.model : '' + if (candidate && MODEL_SLUG.test(candidate)) return candidate + const fromEnv = env.AI_MODEL + if (fromEnv && MODEL_SLUG.test(fromEnv)) return fromEnv + return DEFAULT_MODEL +} From ffa7060a9f5ebd8945ce1fc90ebfc4d953f16154 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:10:20 -0700 Subject: [PATCH 07/14] feat(api/agent): honor client-supplied model with validation --- api/agent.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/agent.ts b/api/agent.ts index ff35b3a..b06bb4d 100644 --- a/api/agent.ts +++ b/api/agent.ts @@ -2,13 +2,12 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' import { stepCountIs, streamText, tool } from 'ai' import { z } from 'zod' import { BUILTIN_KINDS } from '../src/types.js' +import { resolveModel } from './model.js' if (!process.env.AI_GATEWAY_API_KEY && process.env.VERCEL_AI_GATEWAY_API_KEY) { process.env.AI_GATEWAY_API_KEY = process.env.VERCEL_AI_GATEWAY_API_KEY } -const MODEL = process.env.AI_MODEL ?? 'anthropic/claude-sonnet-4-6' - const componentSpecSchema = z .object({ kind: z.string().describe('Component kind, e.g. "button", "card", "alert"'), @@ -135,6 +134,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return } + const model = resolveModel(body as { model?: unknown } | undefined, process.env) + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') res.setHeader('Cache-Control', 'no-cache, no-transform') res.setHeader('Connection', 'keep-alive') @@ -148,7 +149,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { try { const result = streamText({ - model: MODEL, + model, system: systemPrompt, messages: toCoreMessages(messages), tools: { From 1de1a757821c04ce7081a73e538ff66e7faf41d0 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:12:17 -0700 Subject: [PATCH 08/14] feat(api/logout): 401 endpoint to trigger re-auth --- api/logout.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 api/logout.ts diff --git a/api/logout.ts b/api/logout.ts new file mode 100644 index 0000000..5045012 --- /dev/null +++ b/api/logout.ts @@ -0,0 +1,6 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' + +export default function handler(_req: VercelRequest, res: VercelResponse) { + res.setHeader('WWW-Authenticate', 'Basic realm="stream-ui playground"') + res.status(401).send('Logged out') +} From 7b8a80ed8da99ef3a2d805a586e31a578e92823e Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:14:48 -0700 Subject: [PATCH 09/14] feat(playground): grid + CSS presets and gear/popover scaffolding --- playground/index.html | 18 ++- playground/style.css | 256 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 243 insertions(+), 31 deletions(-) diff --git a/playground/index.html b/playground/index.html index 7a22f94..5342fb1 100644 --- a/playground/index.html +++ b/playground/index.html @@ -8,11 +8,17 @@
-

stream-ui

-

Human → Agent → Streamed UI. Try: make a button, build a form, show a list, card.

+
+

stream-ui

+

Human → Agent → Streamed UI. Try: make a button, build a form, show a list, card.

+
+
+ + +
-
+

CHAT

@@ -26,19 +32,21 @@

CHAT

- +

AI

- +

UI

+ +
diff --git a/playground/style.css b/playground/style.css index 8992916..2edeaea 100644 --- a/playground/style.css +++ b/playground/style.css @@ -19,6 +19,93 @@ body { header { margin-bottom: 1rem; flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.header-main { + flex: 1 1 auto; + min-width: 0; +} +.header-actions { + position: relative; + flex: 0 0 auto; +} +.settings-btn { + background: transparent; + border: 1px solid rgba(127, 127, 127, 0.3); + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0.35rem 0.5rem; + border-radius: 0.35rem; +} +.settings-btn:hover { + background: rgba(127, 127, 127, 0.12); +} +.settings-popover { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + min-width: 260px; + max-width: 320px; + background: Canvas; + color: CanvasText; + border: 1px solid rgba(127, 127, 127, 0.35); + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 0.75rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.settings-popover[hidden] { + display: none; +} +.settings-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.settings-section h3 { + margin: 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; +} +.settings-layout-presets { + display: flex; + gap: 0.35rem; +} +.settings-layout-presets button { + flex: 1; + padding: 0.35rem 0.4rem; + border: 1px solid rgba(127, 127, 127, 0.3); + background: transparent; + color: inherit; + border-radius: 0.35rem; + cursor: pointer; + font-size: 0.8rem; +} +.settings-layout-presets button[aria-pressed="true"] { + background: rgba(100, 150, 255, 0.2); + border-color: rgba(100, 150, 255, 0.6); +} +.settings-logout { + background: rgba(220, 60, 60, 0.15); + border: 1px solid rgba(220, 60, 60, 0.5); + color: rgb(220, 60, 60); + padding: 0.4rem 0.6rem; + border-radius: 0.35rem; + cursor: pointer; + font-size: 0.85rem; +} +.settings-logout:hover { + background: rgba(220, 60, 60, 0.25); } h1 { @@ -41,31 +128,14 @@ code { .grid { display: grid; - grid-template-columns: minmax(160px, 1fr) 6px minmax(160px, 1fr); - grid-template-rows: minmax(80px, 1fr) 6px minmax(0, 3fr); - grid-template-areas: - "chat vres ai" - "hres hres hres" - "ui ui ui"; gap: 0.5rem; flex: 1; min-height: 0; } -.region-chat { - grid-area: chat; -} -.region-ai { - grid-area: ai; -} -.region-ui { - grid-area: ui; -} -.resizer-v { - grid-area: vres; -} -.resizer-h { - grid-area: hres; +.region { + min-width: 0; + min-height: 0; } .resizer { @@ -75,17 +145,151 @@ code { user-select: none; touch-action: none; } - -.resizer-h { +.resizer:hover, +.resizer.dragging { + background: rgba(100, 150, 255, 0.6); +} +.resizer.axis-row { cursor: row-resize; } -.resizer-v { +.resizer.axis-col { cursor: col-resize; } -.resizer:hover, -.resizer.dragging { - background: rgba(100, 150, 255, 0.6); +/* ─── Preset: default (CHAT + AI on top row, UI fills bottom) ─── */ +.grid[data-layout="default"] { + grid-template-columns: minmax(160px, var(--col-chat, 1fr)) 6px minmax(160px, var(--col-ai, 1fr)); + grid-template-rows: minmax(80px, var(--row-top, 1fr)) 6px minmax(0, var(--row-ui, 3fr)); + grid-template-areas: + "chat chat-ai ai" + "top-bottom top-bottom top-bottom" + "ui ui ui"; +} +.grid[data-layout="default"] .region-chat { + grid-area: chat; +} +.grid[data-layout="default"] .region-ai { + grid-area: ai; +} +.grid[data-layout="default"] .region-ui { + grid-area: ui; +} +.grid[data-layout="default"] .resizer[data-pair="chat-ai"] { + grid-area: chat-ai; +} +.grid[data-layout="default"] .resizer[data-pair="top-bottom"] { + grid-area: top-bottom; +} +.grid[data-layout="default"] .resizer[data-pair="ai-ui"] { + display: none; +} + +.grid[data-layout="default"][data-hide-ai] { + grid-template-columns: 1fr; + grid-template-rows: minmax(80px, var(--row-top, 1fr)) 6px minmax(0, var(--row-ui, 3fr)); + grid-template-areas: + "chat" + "top-bottom" + "ui"; +} +.grid[data-layout="default"][data-hide-ai] .region-ai, +.grid[data-layout="default"][data-hide-ai] .resizer[data-pair="chat-ai"] { + display: none; +} + +/* ─── Preset: sideBySide (CHAT | AI | UI in one row) ─── */ +.grid[data-layout="sideBySide"] { + grid-template-rows: 1fr; + grid-template-columns: + minmax(160px, var(--col-chat, 1fr)) + 6px + minmax(160px, var(--col-ai, 1fr)) + 6px + minmax(160px, var(--col-ui, 1fr)); + grid-template-areas: "chat chat-ai ai ai-ui ui"; +} +.grid[data-layout="sideBySide"] .region-chat { + grid-area: chat; +} +.grid[data-layout="sideBySide"] .region-ai { + grid-area: ai; +} +.grid[data-layout="sideBySide"] .region-ui { + grid-area: ui; +} +.grid[data-layout="sideBySide"] .resizer[data-pair="chat-ai"] { + grid-area: chat-ai; +} +.grid[data-layout="sideBySide"] .resizer[data-pair="ai-ui"] { + grid-area: ai-ui; +} +.grid[data-layout="sideBySide"] .resizer[data-pair="top-bottom"] { + display: none; +} + +.grid[data-layout="sideBySide"][data-hide-ai] { + grid-template-columns: + minmax(160px, var(--col-chat, 1fr)) + 6px + minmax(160px, var(--col-ui, 1fr)); + grid-template-areas: "chat chat-ai ui"; +} +.grid[data-layout="sideBySide"][data-hide-ai] .region-ai, +.grid[data-layout="sideBySide"][data-hide-ai] .resizer[data-pair="ai-ui"] { + display: none; +} +.grid[data-layout="sideBySide"][data-hide-ai] .resizer[data-pair="chat-ai"] { + grid-area: chat-ai; +} + +/* ─── Preset: stacked (CHAT / AI / UI in one column) ─── */ +.grid[data-layout="stacked"] { + grid-template-columns: 1fr; + grid-template-rows: + minmax(80px, var(--row-chat, 1fr)) + 6px + minmax(80px, var(--row-ai, 1fr)) + 6px + minmax(80px, var(--row-ui, 1fr)); + grid-template-areas: + "chat" + "chat-ai" + "ai" + "ai-ui" + "ui"; +} +.grid[data-layout="stacked"] .region-chat { + grid-area: chat; +} +.grid[data-layout="stacked"] .region-ai { + grid-area: ai; +} +.grid[data-layout="stacked"] .region-ui { + grid-area: ui; +} +.grid[data-layout="stacked"] .resizer[data-pair="chat-ai"] { + grid-area: chat-ai; +} +.grid[data-layout="stacked"] .resizer[data-pair="ai-ui"] { + grid-area: ai-ui; +} +.grid[data-layout="stacked"] .resizer[data-pair="top-bottom"] { + display: none; +} + +.grid[data-layout="stacked"][data-hide-ai] { + grid-template-rows: + minmax(80px, var(--row-chat, 1fr)) + 6px + minmax(80px, var(--row-ui, 1fr)); + grid-template-areas: + "chat" + "chat-ai" + "ui"; +} +.grid[data-layout="stacked"][data-hide-ai] .region-ai, +.grid[data-layout="stacked"][data-hide-ai] .resizer[data-pair="ai-ui"] { + display: none; } body.resizing-row { From 81aad919fba67b75f72b0aed8eea278f88cc48b1 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:19:19 -0700 Subject: [PATCH 10/14] feat(playground): layout engine with per-preset resizers --- playground/main.ts | 201 +++++++++++++++++++++++---------------------- 1 file changed, 103 insertions(+), 98 deletions(-) diff --git a/playground/main.ts b/playground/main.ts index 5c769b8..e606c6b 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -10,6 +10,7 @@ import { render, VERSION, } from '../src/index' +import { type LayoutPreset, type ResizerPair, readSettings, writeSettings } from './settings' // ─── Message state and localStorage ───────────────────────────────────── @@ -126,124 +127,111 @@ renderSuggestions() console.log(`[stream-ui] playground v${VERSION} ready`) -// ─── panel resizing ───────────────────────────────────────────────────── -// Layout: CHAT and AI sit side-by-side on top; UI spans the full width -// below. The vertical handle between CHAT and AI tunes the top row's -// column split; the horizontal handle between the top row and UI tunes -// the overall row split. Both sizes persist in localStorage. +// ─── layout engine ─────────────────────────────────────────────────────── const grid = document.getElementById('grid') as HTMLDivElement | null -const PANEL_SIZES_KEY = 'sui:playground:panel-sizes-v2' -const MIN_PANEL_PX = 80 - -type PanelSizes = { - colChat: number - colAi: number - rowTop: number - rowUi: number -} -function loadPanelSizes(): PanelSizes | null { - try { - const raw = localStorage.getItem(PANEL_SIZES_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as Partial - if ( - typeof parsed.colChat === 'number' && - typeof parsed.colAi === 'number' && - typeof parsed.rowTop === 'number' && - typeof parsed.rowUi === 'number' && - parsed.colChat >= MIN_PANEL_PX && - parsed.colAi >= MIN_PANEL_PX && - parsed.rowTop >= MIN_PANEL_PX && - parsed.rowUi >= MIN_PANEL_PX - ) { - return parsed as PanelSizes - } - return null - } catch { - return null - } -} +const MIN_FRACTION = 0.08 -function applyPanelSizes(sizes: PanelSizes): void { - if (!grid) return - grid.style.gridTemplateColumns = `${sizes.colChat}px 6px ${sizes.colAi}px` - grid.style.gridTemplateRows = `${sizes.rowTop}px 6px ${sizes.rowUi}px` +type Axis = 'row' | 'col' + +// For each preset, declare which pair each resizer controls and on what axis. +const PAIR_AXIS: Record>> = { + default: { 'chat-ai': 'col', 'top-bottom': 'row' }, + sideBySide: { 'chat-ai': 'col', 'ai-ui': 'col' }, + stacked: { 'chat-ai': 'row', 'ai-ui': 'row' }, } -function savePanelSizes(sizes: PanelSizes): void { - try { - localStorage.setItem(PANEL_SIZES_KEY, JSON.stringify(sizes)) - } catch { - // ignore quota errors - } +// For each pair on each preset, list the two CSS custom-property track names +// (first + second region) that participate in the drag. +const PAIR_TRACKS: Record>> = { + default: { + 'chat-ai': ['--col-chat', '--col-ai'], + 'top-bottom': ['--row-top', '--row-ui'], + }, + sideBySide: { + 'chat-ai': ['--col-chat', '--col-ai'], + 'ai-ui': ['--col-ai', '--col-ui'], + }, + stacked: { + 'chat-ai': ['--row-chat', '--row-ai'], + 'ai-ui': ['--row-ai', '--row-ui'], + }, } -function measurePanelSizes(): PanelSizes | null { - if (!grid) return null - const chat = grid.querySelector('.region-chat') - const ai = grid.querySelector('.region-ai') - const ui = grid.querySelector('.region-ui') - if (!chat || !ai || !ui) return null - const chatRect = chat.getBoundingClientRect() - return { - colChat: chatRect.width, - colAi: ai.getBoundingClientRect().width, - rowTop: chatRect.height, - rowUi: ui.getBoundingClientRect().height, - } +// Which two regions (by CSS class fragment) each pair resizes. +const PAIR_REGIONS: Record = { + 'chat-ai': ['region-chat', 'region-ai'], + 'ai-ui': ['region-ai', 'region-ui'], + 'top-bottom': ['region-chat', 'region-ui'], // "top" is chat row, "bottom" is ui } -// Clamp two adjacent panel sizes so neither drops below `min`. When one hits -// the floor, the other absorbs the remainder so the combined size stays put. -function clampPair(a: number, b: number, min: number): [number, number] { - let x = a - let y = b - if (x < min) { - y -= min - x - x = min +function applyLayout(g: HTMLDivElement): void { + const s = readSettings() + g.dataset.layout = s.layout + if (s.hideAI) g.dataset.hideAi = '' + else delete g.dataset.hideAi + + // apply sizes as CSS custom properties (fractions as fr) + const saved = s.sizes[s.layout] + const axisMap = PAIR_AXIS[s.layout] + for (const [pair, tracks] of Object.entries(PAIR_TRACKS[s.layout]) as [ + ResizerPair, + [string, string], + ][]) { + const v = saved[pair] + if (typeof v === 'number' && v > 0 && v < 1) { + g.style.setProperty(tracks[0], `${v}fr`) + g.style.setProperty(tracks[1], `${1 - v}fr`) + } else { + g.style.removeProperty(tracks[0]) + g.style.removeProperty(tracks[1]) + } } - if (y < min) { - x -= min - y - y = min + + // axis classes on resizers (used only for cursor styling; CSS handles display) + for (const r of g.querySelectorAll('.resizer')) { + const pair = r.dataset.pair as ResizerPair | undefined + r.classList.remove('axis-row', 'axis-col') + if (!pair) continue + const axis = axisMap[pair] + if (axis) r.classList.add(`axis-${axis}`) } - return [Math.max(min, x), Math.max(min, y)] } -if (grid) { - const saved = loadPanelSizes() - if (saved) applyPanelSizes(saved) - - const resizers = grid.querySelectorAll('.resizer') - for (const resizer of resizers) { - const axis = resizer.dataset.resizeAxis as 'col' | 'row' +function wireResizers(g: HTMLDivElement): void { + for (const resizer of g.querySelectorAll('.resizer')) { resizer.addEventListener('pointerdown', (e) => { + const pair = resizer.dataset.pair as ResizerPair | undefined + if (!pair) return + const layout = (g.dataset.layout as LayoutPreset) ?? 'default' + const axis = PAIR_AXIS[layout][pair] + const tracks = PAIR_TRACKS[layout][pair] + if (!axis || !tracks) return + e.preventDefault() resizer.setPointerCapture(e.pointerId) - const start = measurePanelSizes() - if (!start) return - const startCoord = axis === 'col' ? e.clientX : e.clientY resizer.classList.add('dragging') document.body.classList.add(axis === 'col' ? 'resizing-col' : 'resizing-row') + const [firstClass, secondClass] = PAIR_REGIONS[pair] + const first = g.querySelector(`.${firstClass}`) + const second = g.querySelector(`.${secondClass}`) + if (!first || !second) return + + const firstRect = first.getBoundingClientRect() + const secondRect = second.getBoundingClientRect() + const totalPx = + axis === 'col' ? firstRect.width + secondRect.width : firstRect.height + secondRect.height + const startFirstPx = axis === 'col' ? firstRect.width : firstRect.height + const startCoord = axis === 'col' ? e.clientX : e.clientY + const onMove = (ev: PointerEvent) => { const coord = axis === 'col' ? ev.clientX : ev.clientY const delta = coord - startCoord - const next: PanelSizes = { ...start } - if (axis === 'col') { - const [colChat, colAi] = clampPair( - start.colChat + delta, - start.colAi - delta, - MIN_PANEL_PX, - ) - next.colChat = colChat - next.colAi = colAi - } else { - const [rowTop, rowUi] = clampPair(start.rowTop + delta, start.rowUi - delta, MIN_PANEL_PX) - next.rowTop = rowTop - next.rowUi = rowUi - } - applyPanelSizes(next) + let nextFirst = (startFirstPx + delta) / totalPx + nextFirst = Math.max(MIN_FRACTION, Math.min(1 - MIN_FRACTION, nextFirst)) + g.style.setProperty(tracks[0], `${nextFirst}fr`) + g.style.setProperty(tracks[1], `${1 - nextFirst}fr`) } const onEnd = (ev: PointerEvent) => { @@ -253,8 +241,20 @@ if (grid) { resizer.removeEventListener('pointermove', onMove) resizer.removeEventListener('pointerup', onEnd) resizer.removeEventListener('pointercancel', onEnd) - const final = measurePanelSizes() - if (final) savePanelSizes(final) + + // persist the final fraction + const firstNow = + axis === 'col' + ? first.getBoundingClientRect().width + : first.getBoundingClientRect().height + const secondNow = + axis === 'col' + ? second.getBoundingClientRect().width + : second.getBoundingClientRect().height + const total = firstNow + secondNow + if (total <= 0) return + const fraction = firstNow / total + writeSettings({ sizes: { [layout]: { [pair]: fraction } } }) } resizer.addEventListener('pointermove', onMove) @@ -264,6 +264,11 @@ if (grid) { } } +if (grid) { + applyLayout(grid) + wireResizers(grid) +} + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function pushChat(who: 'human' | 'agent' | 'system', text: string): void { From 360e5dd40a4746e2ed7b5edb4b2cc722ac711ad9 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:22:20 -0700 Subject: [PATCH 11/14] feat(playground): settings popover (model, layout, logout) --- playground/main.ts | 14 +++ playground/settings-ui.ts | 174 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 playground/settings-ui.ts diff --git a/playground/main.ts b/playground/main.ts index e606c6b..7a4fa2e 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -11,6 +11,7 @@ import { VERSION, } from '../src/index' import { type LayoutPreset, type ResizerPair, readSettings, writeSettings } from './settings' +import { mountSettingsPopover } from './settings-ui' // ─── Message state and localStorage ───────────────────────────────────── @@ -269,6 +270,19 @@ if (grid) { wireResizers(grid) } +function doLogout(): void { + // implemented in Task 10 +} + +const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement | null +const settingsPopover = document.getElementById('settings-popover') as HTMLDivElement | null +if (settingsBtn && settingsPopover && grid) { + mountSettingsPopover(settingsBtn, settingsPopover, { + onLayoutChange: () => applyLayout(grid), + onLogout: () => doLogout(), + }) +} + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function pushChat(who: 'human' | 'agent' | 'system', text: string): void { diff --git a/playground/settings-ui.ts b/playground/settings-ui.ts new file mode 100644 index 0000000..1f10dd7 --- /dev/null +++ b/playground/settings-ui.ts @@ -0,0 +1,174 @@ +import { + clearSizes, + LAYOUT_PRESETS, + type LayoutPreset, + MODEL_PRESETS, + readSettings, + writeSettings, +} from './settings' + +export type PopoverCallbacks = { + onLayoutChange: () => void + onLogout: () => void +} + +const PRESET_LABELS: Record = { + default: 'Default', + sideBySide: 'Side-by-side', + stacked: 'Stacked', +} + +export function mountSettingsPopover( + btn: HTMLButtonElement, + popover: HTMLDivElement, + cb: PopoverCallbacks, +): void { + render() + + btn.addEventListener('click', () => { + const open = popover.hasAttribute('hidden') + if (open) show() + else hide() + }) + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !popover.hasAttribute('hidden')) hide() + }) + + document.addEventListener('pointerdown', (e) => { + if (popover.hasAttribute('hidden')) return + const target = e.target as Node + if (popover.contains(target) || btn.contains(target)) return + hide() + }) + + function show() { + render() + popover.removeAttribute('hidden') + btn.setAttribute('aria-expanded', 'true') + } + + function hide() { + popover.setAttribute('hidden', '') + btn.setAttribute('aria-expanded', 'false') + } + + function render() { + const s = readSettings() + const isCustomModel = !MODEL_PRESETS.includes(s.model) + popover.replaceChildren( + section('Model', [ + modelSelect(s.model, isCustomModel), + customModelInput(s.model, isCustomModel), + ]), + section('Layout', [presetRow(s.layout), hideAIRow(s.hideAI), resetSizesRow(s.layout)]), + section('Account', [logoutRow()]), + ) + } + + function section(title: string, children: HTMLElement[]): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'settings-section' + const h = document.createElement('h3') + h.textContent = title + wrap.append(h, ...children) + return wrap + } + + function modelSelect(current: string, isCustom: boolean): HTMLElement { + const sel = document.createElement('select') + for (const m of MODEL_PRESETS) { + const opt = document.createElement('option') + opt.value = m + opt.textContent = m + if (!isCustom && m === current) opt.selected = true + sel.appendChild(opt) + } + const customOpt = document.createElement('option') + customOpt.value = '__custom__' + customOpt.textContent = 'Custom…' + if (isCustom) customOpt.selected = true + sel.appendChild(customOpt) + + sel.addEventListener('change', () => { + if (sel.value === '__custom__') { + writeSettings({ model: current || '' }) + } else { + writeSettings({ model: sel.value }) + } + render() + }) + return sel + } + + function customModelInput(current: string, isCustom: boolean): HTMLElement { + const input = document.createElement('input') + input.type = 'text' + input.placeholder = 'provider/model-slug' + input.value = isCustom ? current : '' + input.hidden = !isCustom + input.addEventListener('input', () => { + writeSettings({ model: input.value.trim() }) + }) + return input + } + + function presetRow(current: LayoutPreset): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'settings-layout-presets' + wrap.setAttribute('role', 'radiogroup') + for (const p of LAYOUT_PRESETS) { + const b = document.createElement('button') + b.type = 'button' + b.textContent = PRESET_LABELS[p] + b.setAttribute('role', 'radio') + b.setAttribute('aria-pressed', String(p === current)) + b.addEventListener('click', () => { + writeSettings({ layout: p }) + render() + cb.onLayoutChange() + }) + wrap.appendChild(b) + } + return wrap + } + + function hideAIRow(current: boolean): HTMLElement { + const label = document.createElement('label') + label.style.display = 'flex' + label.style.alignItems = 'center' + label.style.gap = '0.4rem' + const box = document.createElement('input') + box.type = 'checkbox' + box.checked = current + box.addEventListener('change', () => { + writeSettings({ hideAI: box.checked }) + cb.onLayoutChange() + }) + const text = document.createElement('span') + text.textContent = 'Hide AI panel' + label.append(box, text) + return label + } + + function resetSizesRow(active: LayoutPreset): HTMLElement { + const b = document.createElement('button') + b.type = 'button' + b.textContent = `Reset sizes (${PRESET_LABELS[active]})` + b.addEventListener('click', () => { + clearSizes(active) + cb.onLayoutChange() + }) + return b + } + + function logoutRow(): HTMLElement { + const b = document.createElement('button') + b.type = 'button' + b.className = 'settings-logout' + b.textContent = 'Log out' + b.title = 'You may need to close this tab in some browsers' + b.addEventListener('click', () => cb.onLogout()) + return b + } +} From 9bfb536f12226216c28828907d458c59de870e28 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:26:10 -0700 Subject: [PATCH 12/14] feat(playground): include chosen model in agent requests --- playground/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playground/main.ts b/playground/main.ts index 7a4fa2e..b6f7736 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -766,10 +766,11 @@ const keywordRoutes: Route[] = [ async function* realAgent(msgs: PlaygroundMessage[]): AsyncGenerator { let response: Response try { + const { model } = readSettings() response = await fetch('/api/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages: msgs }), + body: JSON.stringify({ messages: msgs, model }), }) } catch (err) { throw new Error(`network: ${err instanceof Error ? err.message : String(err)}`) From de3d7c7149955f10152e8f3f7f7948284a144e5b Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:26:16 -0700 Subject: [PATCH 13/14] feat(playground): log out via 401 + cache-poison + reload --- playground/main.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/playground/main.ts b/playground/main.ts index b6f7736..70a7307 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -270,8 +270,24 @@ if (grid) { wireResizers(grid) } -function doLogout(): void { - // implemented in Task 10 +async function doLogout(): Promise { + try { + await fetch('/api/logout', { method: 'POST', cache: 'no-store' }) + } catch { + // ignore — we still want to reload + } + try { + // Poison Chrome's cached Basic Auth creds: a request with bogus creds + // replaces the cache entry so the next navigation re-prompts. + await fetch('/', { + method: 'GET', + cache: 'no-store', + headers: { Authorization: `Basic ${btoa('logout:logout')}` }, + }) + } catch { + // ignore + } + window.location.reload() } const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement | null From f00d22b046b7f49cc4404d678bfdc24266159bd5 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:33:48 -0700 Subject: [PATCH 14/14] fix(playground/settings): guard localStorage.getItem against storage-blocked contexts readSettings() called localStorage.getItem directly for three top-level keys, which throws SecurityError in private browsing or when storage is disabled by site settings. Wrap in a small getItem helper so readSettings degrades to defaults instead of crashing applyLayout on load. --- playground/settings.test.ts | 16 ++++++++++++++++ playground/settings.ts | 14 +++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/playground/settings.test.ts b/playground/settings.test.ts index 8f1dcba..7e6e604 100644 --- a/playground/settings.test.ts +++ b/playground/settings.test.ts @@ -41,4 +41,20 @@ describe('readSettings', () => { localStorage.setItem('sui.layout.preset', 'wat') expect(readSettings().layout).toBe('default') }) + + test('returns defaults when localStorage.getItem throws (private mode)', () => { + const orig = Storage.prototype.getItem + Storage.prototype.getItem = () => { + throw new Error('SecurityError: storage disabled') + } + try { + const s = readSettings() + expect(s.model).toBe(DEFAULT_MODEL) + expect(s.layout).toBe('default') + expect(s.hideAI).toBe(false) + expect(s.sizes).toEqual({ default: {}, sideBySide: {}, stacked: {} }) + } finally { + Storage.prototype.getItem = orig + } + }) }) diff --git a/playground/settings.ts b/playground/settings.ts index 2419183..14796c1 100644 --- a/playground/settings.ts +++ b/playground/settings.ts @@ -38,6 +38,14 @@ function readJSON(key: string, fallback: T): T { } } +function getItem(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + function readSizeMap(preset: LayoutPreset): SizeMap { const raw = readJSON(sizesKey(preset), {}) if (!raw || typeof raw !== 'object') return {} @@ -51,15 +59,15 @@ function readSizeMap(preset: LayoutPreset): SizeMap { } export function readSettings(): SuiSettings { - const rawLayout = localStorage.getItem(KEY_LAYOUT) + const rawLayout = getItem(KEY_LAYOUT) const layout: LayoutPreset = rawLayout === 'default' || rawLayout === 'sideBySide' || rawLayout === 'stacked' ? rawLayout : 'default' return { - model: localStorage.getItem(KEY_MODEL) ?? DEFAULT_MODEL, + model: getItem(KEY_MODEL) ?? DEFAULT_MODEL, layout, - hideAI: localStorage.getItem(KEY_HIDE_AI) === 'true', + hideAI: getItem(KEY_HIDE_AI) === 'true', sizes: { default: readSizeMap('default'), sideBySide: readSizeMap('sideBySide'),