diff --git a/.claude/commands/frontend-design.md b/.claude/commands/frontend-design.md new file mode 100644 index 000000000..be12639d3 --- /dev/null +++ b/.claude/commands/frontend-design.md @@ -0,0 +1,196 @@ +# Biorouter frontend design language + +Read this before any UI change in `ui/desktop/src`. It is a short copy of the root `design.md` +(Parts 1 to 3, and Part 9 for the 2026-10 redesign), regenerated at the end of that redesign's +wave 2. When they disagree, `design.md` wins, and its Part 9 wins over every earlier part. + +## Design philosophy + +> A quiet instrument: a warm, low-chroma, flat research console. Colour is evidence, not decoration. + +- **Simplicity first.** Codex's structure and restraint, Biorouter's coral kept. When unsure, copy + Crew (`components/crew/`) and never edit Crew to match; promote a Crew recipe under a `br-` name. +- **One reading column, quiet chrome.** Every surface has one 44px band with a 14px title and 32px + icon actions. Content sits in the 760px measure. Home is the one exception. +- **Say it once, then hide the explanation.** A visible line is a label, a value or a state word; + explanations go in an `InfoTip`. Errors, refusals, privacy disclosures, the consequence of a + destructive action and empty-state one-liners always stay visible. +- **Rows, not cards.** Anything enumerable is a row list, one primary line and at most one + secondary line. A card is for something that asks the person to act. Never a box inside a box. +- **Fills come before lines.** In-page cards, panels, buttons, chips, tabs and fields rest on a + subtle fill with no resting outline. Lines stay on structural edges and floating surfaces. The + one box the rule does not reach is the composer card (see below). +- **One job, one control, one look.** A variant lives in the primitive, never in a `className` + override. Show names, not IDs: machine IDs and paths go behind a "Copy …" item or a tooltip. +- **Simplicity never costs accessibility.** Everything hidden stays reachable by keyboard and by + screen reader, and the contrast gates pass. + +## Colour tokens (`ui/desktop/src/styles/main.css`) + +Write the token, never a hex. The neutrals are shared by all three theme families (Parchment, Alma +Mater, Roche Limit); a family varies only its ink, accent and status hues. Values are Parchment's. + +| Utility | Light | Dark | Use | +|---|---|---|---| +| `bg-background-canvas` | `#ffffff` | `#131312` | Main panel | +| `bg-background-default` | `#ffffff` | `#1b1b19` | Cards, popovers, dialogs | +| `bg-background-muted` | `#f4f4f2` | `#232320` | Page ground, one real step off the canvas | +| `bg-background-well` | `#f5f5f3` | `#232320` | Inline code, and code wells in the preview paper | +| `bg-background-fill` | `#f4f4f2` | `#232320` | The resting fill of cards, buttons, chips, tabs and fields (not the composer card) | +| `bg-background-fill-strong` | `#eaeae6` | `#2c2c29` | Hover and press on a fill; the resting fill on the sidebar or a muted ground | +| `tint-interactive` | ink at 5% | ink at 5% | Hover (`--overlay-hover`); press is 10% plus scale 0.98 | +| `border-border-subtle` | `#e4e4e0` | `#302f2c` | Structural edges and floating surfaces | +| `border-border-control` | `#86847f` | `#817f7a` | Resting edge of checkboxes, radios and the switch knob | +| `text-text-default` | `#2a2520` | `#f4f0e6` | Primary text | +| `text-text-muted` | `#635c54` | `#b0a892` | Secondary text, metadata | +| `text-text-subtle` | `#6e6760` | `#9c937b` | Quietest text, the InfoTip glyph at rest | +| `bg-background-accent` | `#b85a32` | `#e8895f` | The one primary button; on switches and checkboxes | +| `--accent-bar` | `#cf6d47` | `#e8895f` | The 2px active-row rail and tab underline only | + +Status is a wash with its own ink: `bg-wash-danger` with `text-text-danger`, and the same for +success, warning and info. A colour a utility names needs a `--color-*` mirror in `@theme inline`. +Families are generated from `ui/desktop/themes/*.theme.mjs` by `npm run themes`. + +## Fills, lines and shadows + +Fills come before lines (design.md 9.11). Write `bg-background-fill border border-transparent`, and +`hover:bg-background-fill-strong` where it responds to the pointer: keep the 1px border transparent, +never `border-0`, so nothing moves and forced colours still draw the edge. On the sidebar or a muted +ground, rest on `bg-background-fill-strong`: put `.br-ground-recessed` on that ground and the shared +controls on it follow. A control inside a filled card rests on `bg-background-default`; `Card`, `Note` +and dialog panels do it for their children, and a filled box of your own adds `.br-fill-surface`. +Inside a dark popover the fill is a well under the lifted ground, its hover +step and the off switch track go deeper; the tokens re-point themselves, so write nothing special. Lines stay only on structural +edges (the band edge, pane dividers, resize handles, rules inside a filled table) and on floating +surfaces, which keep a hairline and a shadow. +Row lists drop the hairline between rows when spacing and the hover fill separate them +(`.br-rows-flat` on the list's shell). Hairlines are +straight: no `border-bottom` on a rounded row. Crew keeps its own recipes. + +**The composer card is not a fill** (owner message 17, R-32; a chat and Home, and the in-place message +edit card). It is the page ground, `bg-background-default border border-border-subtle/60`, with no +`.br-fill-surface`, so a control in it rests as on any page ground. Its coral focus edge and working +sweep turn that same 1px border. Its classes are `components/composer/composerCard.ts`. A white card with a +resting hairline there is correct; do not "fix" it under the fill rule. + +| Token | Use | +|---|---| +| **No shadow** | Cards, rows, page sections, bands, panels, tab strips | +| `--shadow-composer` | The chat composer | +| `--shadow-popover` | Menus, popovers, toasts, the floating message actions | +| `--shadow-modal` | Dialogs and sheets | +| `--shadow-raised` | The segmented control's thumb | + +## Type + +Arial first, byte-equal to the website's `--font` (`landing/site.css`); one mono stack, for machine +strings only. Five sizes and two visible weights, 400 and 600 (Arial draws 500 as 400). Use a role +utility: `styles/typeScale.test.ts` fails on `text-xs` and its siblings, `text-[Npx]`, `tracking-*`, +a bare `uppercase` and `font-bold`. + +| Utility | Size / line, weight | Use | +|---|---|---| +| `text-supporting` | 12/16, 400 | Metadata, times, counts, helper and tooltip text, table heads | +| `text-chip` | 12/16, 500 | Badges and chips, sentence case | +| `text-caps` | 12/16, 500, caps +0.04em | Page section labels only | +| `text-secondary` | 13/18, 400 | Sidebar and menu rows, tab labels, table cells, composer controls | +| `text-code` | 13/20 mono | Code and terminal | +| `text-body` / `text-label` | 14/20, 400 / 500 | UI prose and values / control text, row and band titles | +| `text-prose` | 14/21, 400 | Chat markdown, documents, Crew messages | +| `text-subheading` | 17/24, 600 | Dialog and empty-state titles, chat h1 and h2 | +| `text-title` | 24/32, 400 | Home greeting, document h1, stat values | + +The document default is `text-body`: unsized text renders at 14px, never 16. Still name a role on +anything you write. Counts, dates, costs and percentages use the sans face with `tabular-nums`. Chat code blocks and +tables use the shared `.br-md-code` and `.br-md-table` recipes. + +## Spacing, rows and radii + +| What | Value | +|---|---| +| Spacing | 2, 4, 6, 8, 12, 16, 20, 24, 32; 8 is the default gap and 16 the default inset | +| Bands (`h-chrome`) | 44px; the three shell bands move together | +| Dock strips (`h-dock`), tabs (`h-tab`) | 36px, 32px | +| Rows | `h-row-nav` 28 (app sidebar, 13px), `h-row-rail` 32 (menus, Crew), `min-h-row` 40 | +| Controls | `h-control-compact` 24, `h-control-sm` 28, `h-control-md` 32, `h-control-lg` 36 | +| Icons | 14 in a chip and the InfoTip, 16 in rows and controls, 20 in banners and empty states | +| Radii | `rounded-inner` 4, `rounded-element` 8, `rounded-container` 12, `rounded-full` | + +A nested radius is the outer radius minus the padding. `rounded-surface` (16px) is the preview +sheet only; `rounded-2xl` is banned outside `artifacts/`. + +## Controls: one job, one control + +Use the primitive in `components/ui/`, never a raw ``; + +const chatRow = (id: string, title: string, active = false, itemAttrs = '') => + `
  • ` + + `` + + `` + + `
  • `; + +function page(css: string, width: number): string { + return ( + '' + + `' + ); +} + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +const EPSILON = 0.5; +const near = (actual: number, expected: number, what: string) => + expect(Math.abs(actual - expected), `${what}: ${actual} vs ${expected}`).toBeLessThanOrEqual( + EPSILON + ); + +type Box = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +describe('the app sidebar in a real layout engine', () => { + let browser: Browser | null = null; + let css = ''; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn( + 'No Chromium found (Playwright’s or Google Chrome) — skipping the sidebar geometry ' + + 'tests. Run `npx playwright install chromium` to enable them.' + ); + } + const main = readFileSync(MAIN_CSS, 'utf8'); + css = [ + readTokens(main), + PREFLIGHT, + readChatKindIconRule(main), + readFileSync(SIDEBAR_CSS, 'utf8'), + ].join('\n'); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }, 120_000); + + async function open(width: number): Promise { + const tab = await browser!.newPage({ + viewport: { width: 1440, height: 900 }, + reducedMotion: 'reduce', + }); + await tab.setContent(page(css, width)); + return tab; + } + + const boxes = (tab: Page, ids: string[]) => + tab.evaluate((list) => { + const out: Record = {}; + for (const id of list) { + const r = document.getElementById(id)!.getBoundingClientRect(); + out[id] = { + top: r.top, + bottom: r.bottom, + left: r.left, + right: r.right, + width: r.width, + height: r.height, + }; + } + return out; + }, ids); + + for (const width of [216, 288, 360]) { + it(`draws 28px rows on a 30px pitch, icons at x=16 and labels at x=40, at ${width}px`, async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(width); + const rows = ['home', 'newchat', 'crew', 'components', 'chat1', 'chat2', 'chat3', 'settings']; + const b = await boxes(tab, [ + ...rows, + 'home-icon', + 'home-label', + 'newchat-icon', + 'newchat-label', + 'components-chevron', + 'components-label', + 'chat1-glyph', + 'chat1-label', + 'chat2-glyph', + 'chat2-label', + 'settings-icon', + 'settings-label', + 'chat1-trailing', + 'chat1-more', + 'band', + 'brand', + 'wordmark', + 'chats-header', + 'bucket', + ]); + + for (const id of rows) { + near(b[id].height, 28, `${id} height`); + near(b[id].left, 8, `${id} left edge`); + near(b[id].right, width - 8, `${id} right edge`); + } + // The pitch: 28px rows with a 2px gap. + near(b.newchat.top - b.home.top, 30, 'nav pitch'); + near(b.crew.top - b.newchat.top, 30, 'nav pitch'); + near(b.chat2.top - b.chat1.top, 30, 'chat pitch'); + near(b.chat3.top - b.chat2.top, 30, 'chat pitch'); + + // One icon column and one label column for nav rows and chat rows alike. + for (const icon of [ + 'home-icon', + 'newchat-icon', + 'components-chevron', + 'chat1-glyph', + 'chat2-glyph', + 'settings-icon', + ]) { + near(b[icon].left, 16, `${icon} x`); + near(b[icon].width, 16, `${icon} width`); + } + for (const label of [ + 'home-label', + 'newchat-label', + 'components-label', + 'chat1-label', + 'chat2-label', + 'settings-label', + ]) { + near(b[label].left, 40, `${label} x`); + } + // The title truncates before the 16px trailing slot, 8px from the row's end. + near(b['chat1-trailing'].right, width - 16, 'trailing slot right'); + expect(b['chat1-label'].right).toBeLessThanOrEqual(b['chat1-trailing'].left - 8 + EPSILON); + // The ⋯ (24px) is centred on the trailing slot, vertically centred in the row. + near( + b['chat1-more'].left + b['chat1-more'].width / 2, + b['chat1-trailing'].left + b['chat1-trailing'].width / 2, + '⋯ centre x' + ); + near(b['chat1-more'].top - b.chat1.top, 2, '⋯ top inset'); + + // The band's hairline at y=44, full width, and the 44px brand block under it. + near(b.band.bottom, 44, 'band bottom'); + near(b.band.width, width, 'band width'); + near(b.brand.top, 44, 'brand top'); + near(b.brand.height, 44, 'brand height'); + near(b.wordmark.left, 16, 'wordmark x'); + near(b.wordmark.height, 20, 'wordmark height'); + // The Chats header is a 28px row; the bucket label starts on the icon column. + near(b['chats-header'].height, 28, 'Chats header height'); + const bucketText = await tab.evaluate(() => { + const range = document.createRange(); + range.selectNodeContents(document.getElementById('bucket')!); + return range.getBoundingClientRect().left; + }); + near(bucketText, 16, 'bucket label text x'); + await tab.close(); + }); + } + + /** + * Rename edits the title in place with no layout shift (spec 3.4 acceptance): + * the editing row keeps the 28px box and the 30px pitch, the input is 24px + * tall and centred in it, and the typed text starts on the label column at + * x=40 (the input's -4px margin cancels its 1px border and 3px padding). + */ + it('renames in place with no layout shift', async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(288); + const b = await boxes(tab, ['chat3', 'editing', 'editing-glyph', 'editing-input']); + near(b.editing.height, 28, 'editing row height'); + near(b.editing.top - b.chat3.top, 30, 'pitch into the editing row'); + near(b['editing-glyph'].left, 16, 'editing glyph x'); + near(b['editing-input'].height, 24, 'input height'); + near(b['editing-input'].top - b.editing.top, 2, 'input centred in the row'); + const textX = await tab.evaluate(() => { + const input = document.getElementById('editing-input')!; + const style = getComputedStyle(input); + return ( + input.getBoundingClientRect().left + + parseFloat(style.borderLeftWidth) + + parseFloat(style.paddingLeft) + ); + }); + near(textX, 40, 'typed text x'); + const fontSize = await tab.evaluate( + () => getComputedStyle(document.getElementById('editing-input')!).fontSize + ); + expect(fontSize).toBe('13px'); + await tab.close(); + }); + + /** A folder group's header (Group by Folder) is a 28px row on the same columns. */ + it('draws a folder header as a 28px row: chevron at x=16, folder glyph at x=40', async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(288); + const b = await boxes(tab, [ + 'folder', + 'folder-chevron', + 'folder-icon', + 'folder-label', + 'chat4', + 'chat4-glyph', + ]); + near(b.folder.height, 28, 'folder header height'); + near(b.folder.left, 8, 'folder header left'); + near(b.folder.right, 280, 'folder header right'); + near(b['folder-chevron'].left, 16, 'chevron x'); + near(b['folder-icon'].left, 40, 'folder glyph x'); + near(b['folder-label'].left, 64, 'folder name x'); + // Its first chat sits 2px under it, on the shared columns. + near(b.chat4.top - b.folder.bottom, 2, 'header to first row'); + near(b['chat4-glyph'].left, 16, 'folder chat glyph x'); + await tab.close(); + }); + + /** + * "Show more" belongs to the chats above it: its label starts on the label + * column (x=40), not under the glyphs. And a row whose menu is open keeps the + * hover ground, because the pointer is over the menu, not over the row. + */ + /** The header's chevron follows the word, not the buttons at the far end of the header. */ + it('draws the Chats chevron right after the label, 4px away', async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(288); + const textRight = await tab.evaluate(() => { + const range = document.createRange(); + range.selectNodeContents(document.getElementById('chats-label')!); + return range.getBoundingClientRect().right; + }); + const b = await boxes(tab, ['chats-label', 'chats-chevron', 'chats-toggle']); + near(b['chats-chevron'].left - textRight, 4, 'gap from "Chats" to its chevron'); + // The button still spans the header, so a click anywhere on it folds the list. + expect(b['chats-toggle'].width).toBeGreaterThan(150); + await tab.close(); + }); + + it('puts Show more on the label column and keeps a menu-open row lit', async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(288); + const b = await boxes(tab, ['more', 'chat4', 'chat5']); + near(b.more.height, 28, 'Show more row height'); + const labelX = await tab.evaluate(() => { + const range = document.createRange(); + range.selectNodeContents(document.getElementById('more-label')!); + return range.getBoundingClientRect().left; + }); + near(labelX, 40, 'Show more label x'); + const grounds = await tab.evaluate(() => { + const ground = (id: string) => getComputedStyle(document.getElementById(id)!).backgroundColor; + return { open: ground('chat5'), rest: ground('chat4') }; + }); + // --sidebar-hover in the page's own stand-in tokens is #eee; a row at rest is transparent. + expect(grounds.open).toBe('rgb(238, 238, 238)'); + expect(grounds.rest).toBe('rgba(0, 0, 0, 0)'); + await tab.close(); + }); + + it('marks the current row with the 2px accent rail inset 7px', async (ctx) => { + if (!browser) return ctx.skip(); + const tab = await open(288); + const rail = await tab.evaluate(() => { + const style = getComputedStyle(document.getElementById('chat1')!, '::before'); + const other = getComputedStyle(document.getElementById('chat2')!, '::before'); + return { + width: style.width, + top: style.top, + bottom: style.bottom, + background: style.backgroundColor, + otherContent: other.content, + fontSize: getComputedStyle(document.getElementById('chat1')!).fontSize, + }; + }); + expect(rail).toMatchObject({ width: '2px', top: '7px', bottom: '7px' }); + expect(rail.background).toBe('rgb(232, 96, 60)'); + expect(rail.otherContent).toBe('none'); + expect(rail.fontSize).toBe('13px'); + await tab.close(); + }); +}); diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarChatView.ts b/ui/desktop/src/components/BioRouterSidebar/useSidebarChatView.ts new file mode 100644 index 000000000..c50f3539c --- /dev/null +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarChatView.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + parseCollapsedFolders, + parseSidebarChatView, + readCollapsedFolders, + readSidebarChatView, + SIDEBAR_CHAT_VIEW_STORAGE_KEY, + SIDEBAR_COLLAPSED_FOLDERS_STORAGE_KEY, + writeCollapsedFolders, + writeSidebarChatView, + type SidebarChatView, +} from './sidebarChatView'; + +/** + * The person's sidebar view (Group by, Sort by), remembered per viewer and kept + * in step across windows: a change in one window arrives in the others through + * the `storage` event, which fires only in the windows that did not write. + */ +export function useSidebarChatView(): [SidebarChatView, (view: SidebarChatView) => void] { + const [view, setViewState] = useState(() => readSidebarChatView()); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== SIDEBAR_CHAT_VIEW_STORAGE_KEY && event.key !== null) return; + setViewState( + event.key === null ? readSidebarChatView() : parseSidebarChatView(event.newValue) + ); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const setView = useCallback((next: SidebarChatView) => { + setViewState(next); + writeSidebarChatView(next); + }, []); + + return [view, setView]; +} + +/** + * The folder groups the person collapsed, remembered the same way. The third + * member sets a folder's stored state outright: the list decides what a click + * means from what is DRAWN (a folder that holds the current chat is drawn open + * whatever is stored), so a toggle of the stored value alone would be a click + * that changes nothing on screen. + */ +export function useCollapsedFolders(): [ + ReadonlySet, + (folder: string) => void, + (folder: string, collapsed: boolean) => void, +] { + const [folders, setFolders] = useState(() => readCollapsedFolders()); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== SIDEBAR_COLLAPSED_FOLDERS_STORAGE_KEY && event.key !== null) return; + setFolders( + event.key === null ? readCollapsedFolders() : parseCollapsedFolders(event.newValue) + ); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const toggle = useCallback((folder: string) => { + setFolders((current) => { + const next = current.includes(folder) + ? current.filter((entry) => entry !== folder) + : [...current, folder]; + writeCollapsedFolders(next); + return next; + }); + }, []); + + const setCollapsed = useCallback((folder: string, collapsed: boolean) => { + setFolders((current) => { + if (current.includes(folder) === collapsed) return current; + const next = collapsed ? [...current, folder] : current.filter((entry) => entry !== folder); + writeCollapsedFolders(next); + return next; + }); + }, []); + + const collapsed = useMemo(() => new Set(folders), [folders]); + return [collapsed, toggle, setCollapsed]; +} diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.renameActivity.test.tsx b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.renameActivity.test.tsx new file mode 100644 index 000000000..f656d54b8 --- /dev/null +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.renameActivity.test.tsx @@ -0,0 +1,281 @@ +/** + * R-30, coordinator ruling 16: under "Last activity" a chat moves when + * something happens IN it. Renaming it is that; opening it is not. + * + * The daemon raises a renamed chat's `updated_at` when it saves the name, but + * the rename route answers with no body and nothing re-read the list, so the + * sidebar kept the chat where it was until some unrelated refresh. Usually that + * was the person opening ANOTHER chat (the open re-reads the head), which read + * as "opening a chat moved it". These drive the sidebar's own list hook and the + * real row list against a fake daemon that stamps a rename and never an open. + */ +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionSummary } from '../../api'; +import { renameSessionOptimistically } from '../../utils/sessionNameSync'; +import { SidebarProvider } from '../ui/sidebar'; +import RecentChats from './RecentChats'; +import { arrangeSidebarChats, DEFAULT_SIDEBAR_CHAT_VIEW } from './sidebarChatView'; +import useSidebarSessions from './useSidebarSessions'; + +const daemon = vi.hoisted(() => ({ + rows: [] as SessionSummary[], + listSidebarSessions: vi.fn(), + updateSessionName: vi.fn(), +})); + +vi.mock('../../api', () => ({ + listSidebarSessions: daemon.listSidebarSessions, + updateSessionName: daemon.updateSessionName, +})); + +vi.mock('../../utils/userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + +vi.mock('../../toasts', () => ({ toastSuccess: vi.fn(), toastError: vi.fn() })); + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +/** The daemon's order: `updated_at`, newest first. */ +function headOf(limit = 10) { + const sorted = [...daemon.rows].sort( + (left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at) + ); + return { + data: { + sessions: sorted.slice(0, limit).map((row) => ({ ...row })), + has_more: sorted.length > limit, + next_cursor: sorted.length > limit ? 'next' : null, + }, + }; +} + +function chat(letter: string, minutesAgo: number): SessionSummary { + const at = new Date(Date.now() - minutesAgo * 60_000).toISOString(); + return { + id: `session-${letter}`, + name: `Chat ${letter}`, + working_dir: '/workspace', + created_at: at, + updated_at: at, + message_count: 2, + user_set_name: false, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + daemon.rows = [chat('a', 10), chat('b', 20), chat('c', 30)]; + daemon.listSidebarSessions.mockImplementation(async ({ query }: { query: { limit: number } }) => + headOf(query.limit) + ); + // The daemon saves the name and stamps `updated_at` (a rename is activity), + // and answers with no body, as `PUT /sessions/{id}/name` does. + daemon.updateSessionName.mockImplementation( + async ({ path, body }: { path: { session_id: string }; body: { name: string } }) => { + const row = daemon.rows.find((candidate) => candidate.id === path.session_id); + if (row) { + row.name = body.name; + row.user_set_name = true; + row.updated_at = new Date().toISOString(); + } + return { data: undefined }; + } + ); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function SidebarList({ openSessionId }: { openSessionId: string | null }) { + const { sessions, hasMore, isLoading, isLoadingAll, loadMore } = useSidebarSessions({ + openSessionId, + }); + return ( + + + + ); +} + +const order = () => + Array.from(document.querySelectorAll('[data-row^="chat:"]')).map((row) => + row.getAttribute('data-row') + ); + +describe('a rename is activity, and the sidebar shows it at once', () => { + it('moves a renamed chat to the top under Last activity without opening another chat', async () => { + const view = render(); + await waitFor(() => + expect(order()).toEqual(['chat:session-a', 'chat:session-b', 'chat:session-c']) + ); + const readsBefore = daemon.listSidebarSessions.mock.calls.length; + + // The oldest chat is renamed from its chat title, while no other chat is + // opened and the window keeps focus. + await act(async () => { + await renameSessionOptimistically('session-c', 'Renamed oldest', { + name: 'Chat c', + userSetName: false, + }); + }); + + expect(daemon.updateSessionName).toHaveBeenCalledTimes(1); + expect(order()).toEqual(['chat:session-c', 'chat:session-a', 'chat:session-b']); + expect(view.getByTestId('recent-chat-session-c')).toHaveTextContent('Renamed oldest'); + // Moved from the saved rename itself, not from a re-read of the list. + expect(daemon.listSidebarSessions).toHaveBeenCalledTimes(readsBefore); + }); + + it('moves no row when a chat is opened, before a rename or after one', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const view = render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + const asLoaded = ['chat:session-a', 'chat:session-b', 'chat:session-c']; + expect(order()).toEqual(asLoaded); + + // Opening the middle chat: the head is read again (250 ms, then 2 s), and + // the daemon, which stamps nothing on an open, lists the same order. + view.rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2_500); + }); + expect(daemon.listSidebarSessions.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(order()).toEqual(asLoaded); + + await act(async () => { + await renameSessionOptimistically('session-c', 'Renamed oldest', { + name: 'Chat c', + userSetName: false, + }); + }); + const renamedFirst = ['chat:session-c', 'chat:session-a', 'chat:session-b']; + expect(order()).toEqual(renamedFirst); + + // Opening another chat after the rename leaves every row where it is. + view.rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2_500); + }); + expect(order()).toEqual(renamedFirst); + }); +}); + +describe('the list hook keeps a saved rename where it moved it', () => { + const firstUnderLastActivity = (sessions: SessionSummary[]) => + arrangeSidebarChats(sessions, DEFAULT_SIDEBAR_CHAT_VIEW)[0]?.sessions[0]?.id; + + it('brings in a renamed chat past the pages read so far', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + daemon.rows = Array.from({ length: 12 }, (_, index) => + chat(String.fromCharCode(97 + index), (index + 1) * 10) + ); + const { result } = renderHook(() => useSidebarSessions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.sessions.map((session) => session.id)).not.toContain('session-l'); + + await act(async () => { + await renameSessionOptimistically('session-l', 'Renamed far down', { + name: 'Chat l', + userSetName: false, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(firstUnderLastActivity(result.current.sessions)).toBe('session-l'); + expect(result.current.sessions.find((session) => session.id === 'session-l')?.name).toBe( + 'Renamed far down' + ); + }); + + it('does not let a page read before the rename put the chat back', async () => { + const { result } = renderHook(() => useSidebarSessions()); + await waitFor(() => expect(result.current.sessions).toHaveLength(3)); + + // A head refresh is in flight with the order from before the rename. + let answer: (value: unknown) => void = () => {}; + const stale = headOf(); + daemon.listSidebarSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + await waitFor(() => expect(daemon.listSidebarSessions).toHaveBeenCalledTimes(2), { + timeout: 1_000, + }); + + await act(async () => { + await renameSessionOptimistically('session-c', 'Renamed oldest', { + name: 'Chat c', + userSetName: false, + }); + }); + expect(firstUnderLastActivity(result.current.sessions)).toBe('session-c'); + + await act(async () => { + answer(stale); + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(firstUnderLastActivity(result.current.sessions)).toBe('session-c'); + expect(result.current.sessions.find((session) => session.id === 'session-c')?.name).toBe( + 'Renamed oldest' + ); + }); + + it('moves nothing for a rename the daemon refused', async () => { + daemon.updateSessionName.mockRejectedValueOnce('This chat is private.'); + const { result } = renderHook(() => useSidebarSessions()); + await waitFor(() => expect(result.current.sessions).toHaveLength(3)); + + await act(async () => { + await renameSessionOptimistically('session-c', 'Refused', { + name: 'Chat c', + userSetName: false, + }); + }); + + expect(firstUnderLastActivity(result.current.sessions)).toBe('session-a'); + expect(result.current.sessions.find((session) => session.id === 'session-c')?.name).toBe( + 'Chat c' + ); + }); +}); diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts index 77232f7e4..5efaef2a1 100644 --- a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts @@ -3,9 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SessionSummary } from '../../api'; import useSidebarSessions, { appendSessionPage, + SIDEBAR_SESSION_LOAD_ALL_CAP, SIDEBAR_SESSION_PAGE_SIZE, } from './useSidebarSessions'; import { notifySessionListChanged } from '../../utils/sessionListCache'; +import { announceSessionName } from '../../utils/sessionNameSync'; +import { SESSION_META_CHANGED_EVENT } from '../../utils/sessionMetaSubscription'; const mocks = vi.hoisted(() => ({ listSidebarSessions: vi.fn(), @@ -145,9 +148,102 @@ describe('useSidebarSessions', () => { }); }); +/** + * Round 1, V1-M8. Opening a chat used to raise its `updated_at` on the daemon + * and announce nothing, so the sidebar kept the chat under September while + * History (a fresh fetch) listed it under Today. Since R-30 opening is not + * activity and the daemon stamps nothing on it; these reads stay for a daemon + * from before that and for rows moved elsewhere. + */ +describe('the head is read again when something else raised a chat', () => { + const headPage = (name: string) => ({ + data: { + sessions: [ + { ...makeSummary(0), name }, + ...Array.from({ length: 9 }, (_, i) => makeSummary(i + 1)), + ], + has_more: false, + next_cursor: null, + }, + }); + + it('re-reads on a change of the open chat, now and once more after the resume', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + mocks.listSidebarSessions.mockResolvedValue(headPage('First')); + const { rerender } = renderHook(({ id }) => useSidebarSessions({ openSessionId: id }), { + initialProps: { id: null as string | null }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(1); + + // Opening a chat: the head is read 250 ms later and again at 2 s. + rerender({ id: 'session-3' }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2); + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3); + + // The same chat again reads nothing, and neither does leaving for a page. + rerender({ id: 'session-3' }); + rerender({ id: null }); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('does not read twice for the chat the sidebar mounts with', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + mocks.listSidebarSessions.mockResolvedValue(headPage('First')); + renderHook(() => useSidebarSessions({ openSessionId: 'session-3' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('re-reads when the window comes back to the front and when a chat row is rewritten', async () => { + mocks.listSidebarSessions + .mockResolvedValueOnce(headPage('First')) + .mockResolvedValueOnce(headPage('After focus')) + .mockResolvedValueOnce(headPage('After meta')); + const { result } = renderHook(() => useSidebarSessions()); + await waitFor(() => expect(result.current.sessions[0]?.name).toBe('First')); + + act(() => window.dispatchEvent(new Event('focus'))); + await waitFor(() => expect(result.current.sessions[0].name).toBe('After focus'), { + timeout: 1_000, + }); + + act(() => + window.dispatchEvent( + new CustomEvent(SESSION_META_CHANGED_EVENT, { detail: { sessionIds: ['session-0'] } }) + ) + ); + await waitFor(() => expect(result.current.sessions[0].name).toBe('After meta'), { + timeout: 1_000, + }); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3); + }); +}); + /** * M11. A deleted chat left History, the tab strip and the database immediately - * and stayed in the sidebar Recents until a renderer reload. + * and stayed in the sidebar until a renderer reload. * * Two independent breaks produced it, and BOTH have to be closed: * (a) the delete handler never announced membership at all, and @@ -160,7 +256,7 @@ describe('useSidebarSessions', () => { * because other chats were touched, not because it was deleted. So the removal * is announced explicitly, by id. */ -describe('a deleted chat leaves Recents without a reload', () => { +describe('a deleted chat leaves the sidebar without a reload', () => { it('drops the announced id, in this window and from a sibling window', async () => { const firstPage = Array.from({ length: 10 }, (_, index) => makeSummary(index)); mocks.listSidebarSessions.mockResolvedValue({ @@ -207,7 +303,7 @@ describe('a deleted chat leaves Recents without a reload', () => { * refresh re-reads only the HEAD of the keyset and never reaches a months-old * row, so the row is re-marked by id from ANOTHER window's announcement. */ -describe('a declassified chat is re-marked in Recents without a reload', () => { +describe('a declassified chat is re-marked in the sidebar without a reload', () => { it('patches a scrolled-in row where it sits, from a sibling window', async () => { const firstPage = Array.from({ length: 10 }, (_, index) => ({ ...makeSummary(index), @@ -315,3 +411,345 @@ describe('a declassified chat is re-marked in Recents without a reload', () => { expect(result.current.sessions[0].privacy_tier).toBe('private'); }); }); + +/** + * Spec 3.4 "Paging". Any view but the default sorts or groups the WHOLE list, + * so the hook reads it all: the first page, then the rest in one request of + * everything the cap leaves room for (R-28), and every page carries the user's + * proof: without it the daemon silently drops private rows, which would read as + * chats vanishing on a view change. + */ +describe('loadAll pages through every chat', () => { + it('asks for the rest of the list in one request after the first page (R-28)', async () => { + const page = (from: number, count: number) => + Array.from({ length: count }, (_, index) => makeSummary(from + index)); + mocks.listSidebarSessions + .mockResolvedValueOnce({ + data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' }, + }) + .mockResolvedValueOnce({ + data: { sessions: page(50, 1_200), has_more: false, next_cursor: null }, + }); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1_250)); + await waitFor(() => expect(result.current.isLoadingAll).toBe(false)); + expect(mocks.listSidebarSessions.mock.calls.map(([request]) => request)).toEqual([ + { query: { limit: 50 }, headers: { 'X-User-Action': 'test-proof' }, throwOnError: true }, + { + query: { limit: SIDEBAR_SESSION_LOAD_ALL_CAP - 50, cursor: 'c2' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }, + ]); + }); + + it('follows an older daemon that answers 50 rows a page, with the proof, then stops', async () => { + const page = (from: number, count: number) => + Array.from({ length: count }, (_, index) => makeSummary(from + index)); + mocks.listSidebarSessions + .mockResolvedValueOnce({ + data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' }, + }) + .mockResolvedValueOnce({ + data: { sessions: page(50, 50), has_more: true, next_cursor: 'c3' }, + }) + .mockResolvedValueOnce({ + data: { sessions: page(100, 7), has_more: false, next_cursor: null }, + }); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + + await waitFor(() => expect(result.current.sessions).toHaveLength(107)); + await waitFor(() => expect(result.current.isLoadingAll).toBe(false)); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3); + // Each request asks for all the room the cap leaves; the short answer is + // what makes the loop ask again, from the cursor that answer carried. + expect(mocks.listSidebarSessions.mock.calls.map(([request]) => request)).toEqual([ + { query: { limit: 50 }, headers: { 'X-User-Action': 'test-proof' }, throwOnError: true }, + { + query: { limit: SIDEBAR_SESSION_LOAD_ALL_CAP - 50, cursor: 'c2' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }, + { + query: { limit: SIDEBAR_SESSION_LOAD_ALL_CAP - 100, cursor: 'c3' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }, + ]); + }); + + it('keeps private rows a later page brings', async () => { + mocks.listSidebarSessions + .mockResolvedValueOnce({ + data: { sessions: [makeSummary(0)], has_more: true, next_cursor: 'c2' }, + }) + .mockResolvedValueOnce({ + data: { + sessions: [{ ...makeSummary(1), privacy_tier: 'private' as const }], + has_more: false, + next_cursor: null, + }, + }); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(result.current.sessions).toHaveLength(2)); + expect(result.current.sessions[1].privacy_tier).toBe('private'); + }); + + it('turns on when the view changes, continuing from the pages already held', async () => { + mocks.listSidebarSessions + .mockResolvedValueOnce({ + data: { + sessions: Array.from({ length: 10 }, (_, index) => makeSummary(index)), + has_more: true, + next_cursor: 'c2', + }, + }) + .mockResolvedValueOnce({ + data: { sessions: [makeSummary(10)], has_more: false, next_cursor: null }, + }); + + const { result, rerender } = renderHook(({ loadAll }) => useSidebarSessions({ loadAll }), { + initialProps: { loadAll: false }, + }); + await waitFor(() => expect(result.current.sessions).toHaveLength(10)); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(1); + + rerender({ loadAll: true }); + await waitFor(() => expect(result.current.sessions).toHaveLength(11)); + expect(mocks.listSidebarSessions).toHaveBeenNthCalledWith(2, { + query: { limit: SIDEBAR_SESSION_LOAD_ALL_CAP - 10, cursor: 'c2' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }); + }); +}); + +/** + * Vision V1-H2. Published page by page, a name-sorted view reshuffled its top + * rows with every arrival, under the pointer, for the minute and a half 2,000 + * chats took. The pages are read into a buffer now and the list changes once, + * when the last one lands, carrying whatever happened to those rows meanwhile. + */ +describe('loadAll shows the whole list at once', () => { + const page = (from: number, count: number) => + Array.from({ length: count }, (_, index) => makeSummary(from + index)); + + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; + } + + it('keeps the first page on screen until the last page has landed', async () => { + const second = deferred(); + const third = deferred(); + mocks.listSidebarSessions + .mockResolvedValueOnce({ data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' } }) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(third.promise); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2)); + expect(result.current.sessions).toHaveLength(50); + expect(result.current.isLoadingAll).toBe(true); + + await act(async () => { + second.resolve({ data: { sessions: page(50, 50), has_more: true, next_cursor: 'c3' } }); + }); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3)); + // The second page is read, not shown: the rows on screen have not moved. + expect(result.current.sessions).toHaveLength(50); + expect(result.current.isLoadingAll).toBe(true); + + await act(async () => { + third.resolve({ data: { sessions: page(100, 7), has_more: false, next_cursor: null } }); + }); + await waitFor(() => expect(result.current.sessions).toHaveLength(107)); + expect(result.current.isLoadingAll).toBe(false); + expect(result.current.sessions.map((session) => session.id)).toEqual( + page(0, 107).map((session) => session.id) + ); + }); + + it('carries a rename and a removal that land while the pages are being read', async () => { + const second = deferred(); + mocks.listSidebarSessions + .mockResolvedValueOnce({ data: { sessions: page(0, 2), has_more: true, next_cursor: 'c2' } }) + .mockReturnValueOnce(second.promise) + .mockResolvedValueOnce({ + data: { sessions: page(4, 2), has_more: false, next_cursor: null }, + }); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2)); + await act(async () => { + second.resolve({ data: { sessions: page(2, 2), has_more: true, next_cursor: 'c3' } }); + }); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3)); + + // Both rows of the second page are read and not yet shown when these land. + act(() => { + announceSessionName({ + sessionId: 'session-2', + name: 'Renamed mid-read', + userSetName: true, + origin: 'user', + }); + notifySessionListChanged({ removed: 'session-3' }); + }); + + await waitFor(() => expect(result.current.isLoadingAll).toBe(false)); + expect(result.current.sessions.map((session) => session.id)).toEqual([ + 'session-0', + 'session-1', + 'session-2', + 'session-4', + 'session-5', + ]); + expect(result.current.sessions[2].name).toBe('Renamed mid-read'); + }); + + it('runs a head refresh asked for mid-read once the read is done', async () => { + const second = deferred(); + mocks.listSidebarSessions + .mockResolvedValueOnce({ data: { sessions: page(0, 2), has_more: true, next_cursor: 'c2' } }) + .mockReturnValueOnce(second.promise) + .mockResolvedValueOnce({ + data: { sessions: [makeSummary(99), ...page(0, 2)], has_more: false, next_cursor: null }, + }); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2)); + + // A new chat elsewhere: the refresh is debounced 250ms and finds the read running. + act(() => notifySessionListChanged()); + await act(() => new Promise((resolve) => setTimeout(resolve, 300))); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2); + + await act(async () => { + second.resolve({ data: { sessions: page(2, 2), has_more: false, next_cursor: null } }); + }); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(3)); + expect(mocks.listSidebarSessions).toHaveBeenLastCalledWith({ + query: { limit: 50 }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }); + await waitFor(() => + expect(result.current.sessions.map((session) => session.id)).toContain('session-99') + ); + }); + + it('stops at the cap exactly, asking the last page for only the rows left (V1-L9)', async () => { + let served = 0; + mocks.listSidebarSessions.mockImplementation( + async ({ query }: { query: { limit: number; cursor?: string } }) => { + // An older daemon: at most 50 a page, whatever was asked. The head is a + // short page, so pages of 50 would land 20 rows past the cap. + const count = query.cursor ? Math.min(query.limit, 50) : 30; + const sessions = page(served, count); + served += count; + return { data: { sessions, has_more: true, next_cursor: `c${served}` } }; + } + ); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(result.current.isLoadingAll).toBe(false), { timeout: 5_000 }); + expect(result.current.sessions).toHaveLength(SIDEBAR_SESSION_LOAD_ALL_CAP); + const limits = mocks.listSidebarSessions.mock.calls.map( + ([request]) => (request as { query: { limit: number } }).query.limit + ); + expect(limits[limits.length - 1]).toBe(20); + // Every request after the head asks for exactly the room left under the cap. + expect(limits.slice(1)).toEqual( + Array.from( + { length: limits.length - 1 }, + (_, index) => SIDEBAR_SESSION_LOAD_ALL_CAP - 30 - 50 * index + ) + ); + }); + + it('reads a full list from a current daemon in two requests, the cap exactly', async () => { + mocks.listSidebarSessions.mockImplementation( + async ({ query }: { query: { limit: number; cursor?: string } }) => { + const from = query.cursor ? 50 : 0; + return { + data: { + sessions: page(from, query.limit), + has_more: true, + next_cursor: `c${from + query.limit}`, + }, + }; + } + ); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(result.current.isLoadingAll).toBe(false)); + expect(result.current.sessions).toHaveLength(SIDEBAR_SESSION_LOAD_ALL_CAP); + expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2); + }); +}); + +/** + * Vision V1-R3-3. The list does not draw a non-default view while `isLoadingAll`, so the flag + * must mean "this list is still only part of the list", not "a request is running". Once read to + * its end the list stays whole: a head refresh that finds more rows than its page re-reads the + * tail, and reporting that read would hide a view the person already had, every time the window + * came back to the front. + */ +describe('loadAll reports only the first full read', () => { + const page = (from: number, count: number) => + Array.from({ length: count }, (_, index) => makeSummary(from + index)); + + it('does not report the re-read a head refresh starts after the list was read to its end', async () => { + let tail: (value: unknown) => void = () => {}; + mocks.listSidebarSessions + .mockResolvedValueOnce({ data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' } }) + .mockResolvedValueOnce({ + data: { sessions: page(50, 20), has_more: false, next_cursor: null }, + }) + // The head refresh: more rows than its page, so the tail is read again. + .mockResolvedValueOnce({ data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' } }) + .mockReturnValueOnce( + new Promise((resolve) => { + tail = resolve; + }) + ); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(result.current.sessions).toHaveLength(70)); + expect(result.current.isLoadingAll).toBe(false); + + act(() => notifySessionListChanged()); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(4)); + expect(result.current.sessions).toHaveLength(70); + expect(result.current.isLoadingAll).toBe(false); + + await act(async () => { + tail({ data: { sessions: page(50, 20), has_more: false, next_cursor: null } }); + }); + expect(result.current.isLoadingAll).toBe(false); + }); + + it('reports the read after a failed one, which did not reach the end', async () => { + mocks.listSidebarSessions + .mockResolvedValueOnce({ data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' } }) + .mockRejectedValueOnce(new Error('daemon gone')) + .mockResolvedValueOnce({ data: { sessions: page(0, 50), has_more: true, next_cursor: 'c2' } }) + .mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook(() => useSidebarSessions({ loadAll: true })); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.hasMore).toBe(false)); + + act(() => notifySessionListChanged()); + await waitFor(() => expect(mocks.listSidebarSessions).toHaveBeenCalledTimes(4)); + expect(result.current.isLoadingAll).toBe(true); + }); +}); diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts index fb796640a..23aa4aaf9 100644 --- a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { listSidebarSessions, type SessionSummary } from '../../api'; import { userActionHeaders } from '../../utils/userAction'; -import { subscribeSessionNameChanges } from '../../utils/sessionNameSync'; +import { laterActivity, subscribeSessionNameChanges } from '../../utils/sessionNameSync'; import { subscribeSessionListChanges, subscribeSessionRemoved } from '../../utils/sessionListCache'; +import { SESSION_META_CHANGED_EVENT } from '../../utils/sessionMetaSubscription'; import { settleRowsReadDuringFetch, subscribeSessionRowChanges, @@ -10,11 +11,73 @@ import { } from '../../utils/sessionRowSync'; export const SIDEBAR_SESSION_PAGE_SIZE = 10; +/** A non-default view's first page and its head refreshes. */ +export const SIDEBAR_SESSION_FULL_PAGE_SIZE = 50; +/** + * How many chats a non-default view reads at most. It is also the server's page + * maximum (`MAX_SIDEBAR_SESSION_LIMIT` in `routes/session.rs`), so the rest of + * the list arrives in ONE request (R-28). + */ +export const SIDEBAR_SESSION_LOAD_ALL_CAP = 2_000; + +/** + * How long after a chat is opened the list reads its head a second time. The + * first read follows the open by 250 ms. Opening a chat used to raise its + * `updated_at` when the resume bound its model, and that could land after the + * first read. Since R-30 opening is not activity and the daemon stamps nothing + * on an open, so the second read normally finds the same order. It stays + * because it costs one head page and because a daemon from before R-30 (an + * older `biorouter serve` host or dev binary) still stamps the open. + */ +const OPEN_REFRESH_FOLLOW_UP_MS = 2_000; + +export interface SidebarSessionsOptions { + /** + * Read every chat, not just the pages scrolled into view. The server pages in + * `updated_at` order only, so any view that sorts by name or creation, or + * groups by folder, must hold the whole list before it arranges it: over the + * first 10 rows it would be wrong in a way nobody could see. After the first + * page, the rest is asked for in ONE request of up to + * {@link SIDEBAR_SESSION_LOAD_ALL_CAP} rows (R-28). It was 40 requests of 50, + * one after another: 3.0 s for 2,000 chats against an idle debug daemon, where + * one request takes 0.2 s. A daemon from before that change still clamps the + * page to 50, and the same loop then reads it page by page as before. + * + * ⚠ The pages are read into a buffer and shown ONCE, when the last has landed + * (vision V1-H2). Published page by page, a name-sorted list reshuffled its top + * rows with every arrival, under the pointer, for as long as the read took, and + * each arrival re-rendered every row already held, which is most of why it took + * a minute and a half for 2,000 chats. + */ + loadAll?: boolean; + /** + * The chat the person is in. Opening or resuming a chat used to raise its + * `updated_at` on the daemon without announcing a list change, so a list that + * only re-read on a finished turn or a new chat put it under September while + * History, fetched fresh, put it under Today (round 1, V1-M8). Since R-30 + * opening moves nothing, so a change of this finds the order the list already + * had, and the list itself moves no row for it. It still re-reads the head, + * which keeps the list right against a daemon from before R-30 and picks up a + * turn, or a rename from the terminal, made elsewhere. A rename made in this + * app moves its row at once and needs no open (the name channel's + * `activityAt`). Leaving for a page that is not a chat reads nothing. + */ + openSessionId?: string | null; +} export interface SidebarSessionsState { sessions: SessionSummary[]; hasMore: boolean; isLoading: boolean; + /** + * True while a `loadAll` view's list is read for the FIRST time: until then it + * holds only the pages the default view scrolled through, and any arrangement + * of them but the default one is wrong (vision V1-R3-3). A list read to its end + * (or to the cap) once stays whole: a later re-read of the tail, which a head + * refresh asks for when the daemon has more rows than the head page, only + * brings in a row that newly appeared with an old date, so it is not reported. + */ + isLoadingAll: boolean; loadMore: () => void; } @@ -38,41 +101,117 @@ export function appendSessionPage( return merged; } -export default function useSidebarSessions(): SidebarSessionsState { +/** A rename announced while a page request was in flight. */ +export interface NameDuringLoad { + name: string; + userSetName: boolean; + /** Set when the daemon saved it: the rename's activity time (R-30). */ + activityAt?: string; +} + +/** + * Re-apply the renames that outran a page: the name, and for a saved rename the + * later of the page's `updated_at` and the rename's, so a page issued before + * the rename neither restores the old name nor moves the chat back down. + */ +export function applyNamesDuringLoad( + rows: SessionSummary[], + names: ReadonlyMap +): SessionSummary[] { + if (names.size === 0) return rows; + return rows.map((row) => { + const later = names.get(row.id); + return later + ? { + ...row, + name: later.name, + user_set_name: later.userSetName, + updated_at: laterActivity(row.updated_at, later.activityAt), + } + : row; + }); +} + +export default function useSidebarSessions( + options: SidebarSessionsOptions = {} +): SidebarSessionsState { + const loadAll = options.loadAll ?? false; + const openSessionId = options.openSessionId ?? null; const [sessions, setSessions] = useState([]); const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(true); + // Set once a read has reached the end of the list or the cap (see + // `isLoadingAll`), and never cleared: rows are only added or removed by id + // after that, so the list stays whole. A failed read does not set it. + const [readToEnd, setReadToEnd] = useState(false); const sessionsRef = useRef([]); const nextCursorRef = useRef(null); const hasMoreRef = useRef(true); const hasLoadedRef = useRef(false); const loadingRef = useRef(false); + const mountedRef = useRef(true); + // `loadAll`'s reader. `pendingAllRef` holds the rows it has read and not yet + // shown (null when no read is running), and the subscriptions below patch it + // exactly as they patch the shown list, so a rename, a removal or a privacy + // change that lands mid-read is not undone when the buffer is shown. A head + // refresh asked for mid-read cannot run beside it (one request at a time) and + // must not be lost, so it waits in `refreshQueuedRef` and runs after. + const loadAllRef = useRef(loadAll); + loadAllRef.current = loadAll; + const drainingRef = useRef(false); + const pendingAllRef = useRef(null); + const refreshQueuedRef = useRef(false); // Row reads delivered while a page request was in flight, settled against // that page's answer. See `settleRowsReadDuringFetch`: without it, a head // refresh issued before a turn raised a chat and answered after the raise was // patched in would draw the chat public again. const rowsReadDuringLoadRef = useRef(new Map()); + // Renames delivered while a page request was in flight. A page issued before + // the rename carries the chat's old name and, for a saved rename, its old + // `updated_at`; merging it as it is would put both back (R-30). + const namesDuringLoadRef = useRef(new Map()); + // Read by `loadPage` at request time, so a view change takes effect on the + // next page without re-creating the loader (and its subscriptions). + const pageSizeRef = useRef(loadAll ? SIDEBAR_SESSION_FULL_PAGE_SIZE : SIDEBAR_SESSION_PAGE_SIZE); + pageSizeRef.current = loadAll ? SIDEBAR_SESSION_FULL_PAGE_SIZE : SIDEBAR_SESSION_PAGE_SIZE; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const loadPage = useCallback(async (reset: boolean) => { - if (loadingRef.current || (!reset && !hasMoreRef.current)) return; + if (loadingRef.current) { + if (reset && drainingRef.current) refreshQueuedRef.current = true; + return; + } + if (!reset && !hasMoreRef.current) return; const cursor = reset ? null : nextCursorRef.current; loadingRef.current = true; rowsReadDuringLoadRef.current.clear(); + namesDuringLoadRef.current.clear(); setIsLoading(true); try { // With the user's proof: without it the daemon pages a view with every - // private chat omitted (issue #56, QA 2026-09-10 M1). + // private chat omitted (issue #56, QA 2026-09-10 M1). Every page carries + // it, the `loadAll` pages included: a page without it would read as the + // person's private chats vanishing when they changed the view. const response = await listSidebarSessions({ - query: { limit: SIDEBAR_SESSION_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + query: { limit: pageSizeRef.current, ...(cursor ? { cursor } : {}) }, headers: await userActionHeaders(), throwOnError: true, }); const page = response.data; const mergedSessions = appendSessionPage( sessionsRef.current, - settleRowsReadDuringFetch(page.sessions, rowsReadDuringLoadRef.current, false) + applyNamesDuringLoad( + settleRowsReadDuringFetch(page.sessions, rowsReadDuringLoadRef.current, false), + namesDuringLoadRef.current + ) ); const pageHasMore = reset && hasLoadedRef.current ? hasMoreRef.current || page.has_more : page.has_more; @@ -92,6 +231,7 @@ export default function useSidebarSessions(): SidebarSessionsState { hasMoreRef.current = pageHasMore; hasLoadedRef.current = true; setHasMore(pageHasMore); + if (!pageHasMore) setReadToEnd(true); } catch { hasMoreRef.current = false; setHasMore(false); @@ -101,6 +241,10 @@ export default function useSidebarSessions(): SidebarSessionsState { } }, []); + // The debounced head refresh, for the effects that are not the subscriptions + // below (a change of the open chat). + const scheduleRefreshRef = useRef<() => void>(() => {}); + useEffect(() => { void loadPage(true); @@ -112,19 +256,51 @@ export default function useSidebarSessions(): SidebarSessionsState { void loadPage(true); }, 250); }; + scheduleRefreshRef.current = scheduleRefresh; - const unsubscribeNames = subscribeSessionNameChanges(({ sessionId, name, userSetName }) => { - const renamedSessions = sessionsRef.current.map((session) => - session.id === sessionId ? { ...session, name, user_set_name: userSetName } : session - ); - sessionsRef.current = renamedSessions; - setSessions(renamedSessions); - }); + // A rename patches the row's name in place. A rename the daemon has SAVED + // (`activityAt`) is activity in the chat (R-30), so it also raises the + // row's `updated_at` and the chat sorts to the top under "Last activity" at + // once, with no request and without waiting for an unrelated refresh (the + // list is arranged from `updated_at`, `sidebarChatView.ts`). A chat this + // list does not hold yet is past the pages read so far; the head re-read + // brings it in, now that the daemon lists it first. + const unsubscribeNames = subscribeSessionNameChanges( + ({ sessionId, name, userSetName, activityAt }) => { + const isRenamed = (session: SessionSummary) => session.id === sessionId; + const listed = + sessionsRef.current.some(isRenamed) || (pendingAllRef.current?.some(isRenamed) ?? false); + const rename = (rows: SessionSummary[]) => + rows.map((session) => + isRenamed(session) + ? { + ...session, + name, + user_set_name: userSetName, + updated_at: laterActivity(session.updated_at, activityAt), + } + : session + ); + if (loadingRef.current) { + const earlier = namesDuringLoadRef.current.get(sessionId); + namesDuringLoadRef.current.set(sessionId, { + name, + userSetName, + activityAt: activityAt ?? earlier?.activityAt, + }); + } + if (pendingAllRef.current) pendingAllRef.current = rename(pendingAllRef.current); + const renamedSessions = rename(sessionsRef.current); + sessionsRef.current = renamedSessions; + setSessions(renamedSessions); + if (activityAt && !listed) scheduleRefresh(); + } + ); // Membership changes — a session created, DIVERGED, deleted or imported, in // THIS window or any sibling. Diverge creates a session purely over HTTP and // dispatches no `session-created` window event (and those are per-renderer - // anyway), so before this the branch never entered the Recents list until an + // anyway), so before this the branch never entered the sidebar list until an // unrelated turn happened to finish. Now every list change re-reads. const unsubscribeList = subscribeSessionListChanges(scheduleRefresh); @@ -143,6 +319,9 @@ export default function useSidebarSessions(): SidebarSessionsState { // does not have to exist for the comparison to put the next page in the // right place, including when the deleted row is that very one. const unsubscribeRemoved = subscribeSessionRemoved((sessionId) => { + if (pendingAllRef.current) { + pendingAllRef.current = pendingAllRef.current.filter((session) => session.id !== sessionId); + } const remaining = sessionsRef.current.filter((session) => session.id !== sessionId); if (remaining.length === sessionsRef.current.length) return; sessionsRef.current = remaining; @@ -160,6 +339,13 @@ export default function useSidebarSessions(): SidebarSessionsState { const unsubscribeRows = subscribeSessionRowChanges((facts) => { const { sessionId, privacy_tier } = facts; if (loadingRef.current) rowsReadDuringLoadRef.current.set(sessionId, facts); + if (pendingAllRef.current) { + pendingAllRef.current = pendingAllRef.current.map((session) => + session.id === sessionId && session.privacy_tier !== privacy_tier + ? { ...session, privacy_tier } + : session + ); + } let changed = false; const next = sessionsRef.current.map((session) => { if (session.id !== sessionId || session.privacy_tier === privacy_tier) return session; @@ -171,8 +357,22 @@ export default function useSidebarSessions(): SidebarSessionsState { setSessions(next); }); + // Other writers raise `updated_at` without announcing a list change: a turn + // in another window, a rename from the terminal, a row rewritten by the CLI + // or a schedule (the session-meta channel reports those for the chats this + // window has open), and this window coming back to the front. A rename made + // in any window of this app is announced (the name channel above), and + // opening a chat is not activity at all since R-30. A head page is cheap (a + // 10 to 50 row read), and `scheduleRefresh` coalesces a burst into one. + const onVisible = () => { + if (!document.hidden) scheduleRefresh(); + }; + window.addEventListener('session-created', scheduleRefresh); window.addEventListener('message-stream-finished', scheduleRefresh); + window.addEventListener(SESSION_META_CHANGED_EVENT, scheduleRefresh); + window.addEventListener('focus', scheduleRefresh); + document.addEventListener('visibilitychange', onVisible); return () => { unsubscribeNames(); @@ -182,17 +382,117 @@ export default function useSidebarSessions(): SidebarSessionsState { if (refreshTimer !== undefined) window.clearTimeout(refreshTimer); window.removeEventListener('session-created', scheduleRefresh); window.removeEventListener('message-stream-finished', scheduleRefresh); + window.removeEventListener(SESSION_META_CHANGED_EVENT, scheduleRefresh); + window.removeEventListener('focus', scheduleRefresh); + document.removeEventListener('visibilitychange', onVisible); }; }, [loadPage]); + // A chat opened (or switched to) used to raise its `updated_at` (the resume's + // model bind stamped it); since R-30 it does not, so these reads normally find + // the same order, and nothing here moves a row for the open itself. They are + // kept for a daemon from before R-30 and for a turn, or a rename from the + // terminal, made elsewhere while this window was away. Read the head now, and + // once more after the resume has had time to finish. + const openedRef = useRef(openSessionId); + useEffect(() => { + if (openedRef.current === openSessionId) return; + openedRef.current = openSessionId; + if (!openSessionId) return; + scheduleRefreshRef.current(); + const followUp = window.setTimeout( + () => scheduleRefreshRef.current(), + OPEN_REFRESH_FOLLOW_UP_MS + ); + return () => window.clearTimeout(followUp); + }, [openSessionId]); + const loadMore = useCallback(() => { void loadPage(false); }, [loadPage]); + // `loadAll`: the rest of the list, read into a buffer and shown once (see + // `loadAll`). Each request asks for everything the cap leaves room for, so a + // current daemon answers in one; a page that comes back short of that with + // more to come (an older daemon's 50-row maximum) is followed by the next, + // from its cursor, until the server has no more or the cap is reached. The + // read never asks past the cap (V1-L9). + const drainAllPages = useCallback(async () => { + if (loadingRef.current || !hasMoreRef.current) return; + loadingRef.current = true; + drainingRef.current = true; + rowsReadDuringLoadRef.current.clear(); + namesDuringLoadRef.current.clear(); + pendingAllRef.current = []; + setIsLoading(true); + + let cursor = nextCursorRef.current; + let more: boolean = hasMoreRef.current; + let failed = false; + try { + while (more && loadAllRef.current && mountedRef.current) { + const read = sessionsRef.current.length + (pendingAllRef.current?.length ?? 0); + const room = SIDEBAR_SESSION_LOAD_ALL_CAP - read; + if (room <= 0) break; + // With the user's proof on every page, as `loadPage` sends it. + const response = await listSidebarSessions({ + query: { + limit: room, + ...(cursor ? { cursor } : {}), + }, + headers: await userActionHeaders(), + throwOnError: true, + }); + const page = response.data; + pendingAllRef.current = appendSessionPage( + pendingAllRef.current ?? [], + applyNamesDuringLoad( + settleRowsReadDuringFetch(page.sessions, rowsReadDuringLoadRef.current, false), + namesDuringLoadRef.current + ) + ); + cursor = page.next_cursor ?? null; + more = page.has_more; + } + } catch { + // As `loadPage` does: a failed page ends the list where it is. + more = false; + failed = true; + } finally { + const fetched = pendingAllRef.current ?? []; + pendingAllRef.current = null; + loadingRef.current = false; + drainingRef.current = false; + if (mountedRef.current) { + const merged = appendSessionPage(sessionsRef.current, fetched); + sessionsRef.current = merged; + nextCursorRef.current = cursor; + hasMoreRef.current = more; + setSessions(merged); + setHasMore(more); + if (!failed && (!more || merged.length >= SIDEBAR_SESSION_LOAD_ALL_CAP)) { + setReadToEnd(true); + } + setIsLoading(false); + if (refreshQueuedRef.current) { + refreshQueuedRef.current = false; + void loadPage(true); + } + } + } + }, [loadPage]); + + const wantsMoreForAll = loadAll && hasMore && sessions.length < SIDEBAR_SESSION_LOAD_ALL_CAP; + useEffect(() => { + if (!wantsMoreForAll || isLoading) return; + void drainAllPages(); + }, [wantsMoreForAll, isLoading, drainAllPages]); + return { sessions, hasMore, isLoading, + isLoadingAll: wantsMoreForAll && !readToEnd, loadMore, }; } diff --git a/ui/desktop/src/components/BrxtInstallModal.test.tsx b/ui/desktop/src/components/BrxtInstallModal.test.tsx index d6c95020a..5ba15b347 100644 --- a/ui/desktop/src/components/BrxtInstallModal.test.tsx +++ b/ui/desktop/src/components/BrxtInstallModal.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BrxtManifest } from '../types/brxt'; @@ -16,6 +16,7 @@ vi.mock('./ConfigContext', () => ({ })); import { BrxtInstallModal } from './BrxtInstallModal'; +import { BRXT_INSTALL_COPY as COPY } from './brxtInstallCopy'; const MANIFEST: BrxtManifest = { name: 'playwrightagent', @@ -127,10 +128,14 @@ describe('BrxtInstallModal — the local-file route (issue #116)', () => { it('keeps drag/drop and the file picker when no origin is given', () => { render( {}} onInstalled={() => {}} />); - expect(screen.getByText('Add extension')).toBeInTheDocument(); - expect(screen.getByText('Drop your .brxt file here')).toBeInTheDocument(); - expect(screen.getByText('or click to browse')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Browse file…' })).toBeInTheDocument(); + expect(screen.getByText(COPY.addTitle)).toBeInTheDocument(); + // One line, and the zone IS the control: no second "or click to browse" line, no extra + // "Browse file…" button beside it (Add skill's zone, Knowledge's recipe). + expect(screen.getByText(COPY.dropZone)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: COPY.dropZoneName })).toBeInTheDocument(); + expect(COPY.dropZoneName).toContain(COPY.dropZone); + expect(screen.queryByText('or click to browse')).toBeNull(); + expect(screen.queryByRole('button', { name: /Browse file/ })).toBeNull(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Back to marketplace' })).toBeNull(); }); @@ -155,10 +160,12 @@ describe('BrxtInstallModal — the local-file route (issue #116)', () => { /> ); - expect(await screen.findByText('Detected from bundle')).toBeInTheDocument(); - expect(screen.getByText('Add extension')).toBeInTheDocument(); - expect(screen.getByText('Drop a different .brxt file here')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Browse file…' })).toBeInTheDocument(); + expect(await screen.findByText(COPY.detectedFromBundle)).toBeInTheDocument(); + expect(screen.getByText(COPY.addTitle)).toBeInTheDocument(); + expect(screen.getByText(COPY.dropZoneAnother)).toBeInTheDocument(); + // The button's name contains the line on screen, whichever line that is (label in name). + const zone = screen.getByRole('button', { name: COPY.dropZoneAnotherName }); + expect(zone).toHaveAccessibleName(expect.stringContaining(COPY.dropZoneAnother)); }); it('labels the local primary action by what it will actually do', async () => { @@ -186,3 +193,326 @@ describe('BrxtInstallModal — the local-file route (issue #116)', () => { expect(install).toHaveBeenCalledWith('/tmp/playwright.brxt', 'playwrightagent', undefined); }); }); + +/** + * The dialog's chrome and its flat sections (spec 3.11): the dialog is the only box, the drop + * zone is one line and a button, the type roles are the app's, and the tier line (section 13.5) + * stays on screen as text. + */ +describe('BrxtInstallModal — the dialog', () => { + const ENV = (overrides: Record) => ({ + key: 'TOKEN', + required: true, + auto_propagate: false, + description: 'Your API token', + secret: true, + ...overrides, + }); + + const withManifest = (env: unknown[]) => { + (window as unknown as { electron: unknown }).electron = { + validateBrxtBundle: vi.fn(async () => ({ + manifest: { ...MANIFEST, env_vars: env }, + skillsPreview: [ + { slug: 'pw-skill', name: 'pw-skill', description: 'Drives the browser' }, + { slug: 'pw-two', name: 'pw-two', description: 'Another' }, + ], + })), + }; + }; + + const open = (props: Partial> = {}) => + render( + {}} + onInstalled={() => {}} + preloadedFilePath="/tmp/playwright.brxt" + {...props} + /> + ); + + it('is a 480px dialog anchored at the top, with Cancel quiet and the tier line in the page', async () => { + withManifest([]); + open(); + const dialog = await screen.findByRole('dialog', { name: COPY.addTitle }); + + expect(dialog).toHaveAttribute('data-size', 'md'); + expect(dialog).toHaveAttribute('data-anchor', 'top'); + expect(screen.getByRole('button', { name: 'Cancel' }).className).toMatch(/\bbr-fill-rest\b/); + // §13.5, as text above the Install button, not in a panel and not in help. + expect(screen.getByText(COPY.tierPublicFile)).toBeVisible(); + expect(dialog.querySelector('.biorouter-modal-panel')).toBeNull(); + }); + + it('summarises the bundle flat: facts, the description, and the skills it carries', async () => { + withManifest([ENV({})]); + open(); + + expect(await screen.findByText(COPY.detectedFromBundle)).toBeVisible(); + const facts = screen.getByText(/^v0\.1\.0/); + expect(facts).toHaveTextContent('v0.1.0 · 23 tools · 2 skills · 1 required env var'); + expect(screen.getByText('Browser automation via Playwright.')).toBeVisible(); + expect(screen.getByText(COPY.skillsIncluded)).toBeVisible(); + expect(screen.getByText('pw-skill')).toBeVisible(); + }); + + it('opens the native picker from the drop zone and reads the file it returns', async () => { + const user = userEvent.setup({ delay: null }); + const validate = vi.fn(async () => ({ manifest: MANIFEST, skillsPreview: [] })); + const picker = vi.fn(async () => '/tmp/picked.brxt'); + (window as unknown as { electron: unknown }).electron = { + validateBrxtBundle: validate, + openBrxtFilePicker: picker, + }; + render( {}} onInstalled={() => {}} />); + + await user.click(screen.getByRole('button', { name: COPY.dropZoneName })); + + expect(picker).toHaveBeenCalledTimes(1); + await waitFor(() => expect(validate).toHaveBeenCalledWith('/tmp/picked.brxt')); + expect(await screen.findByText(COPY.detectedFromBundle)).toBeInTheDocument(); + }); + + /** + * R-27 (owner message 13): the drop zone is one filled field with a plate, not an outlined box + * with a bordered tile. jsdom runs no stylesheet, so what it can pin is the contract with the + * sheet: the one authored class (every colour, edge and state lives in `brxt-install.css`, and + * `brxtInstallGeometry.browser.test.tsx` measures them), and the `data-state` that sheet keys + * on. A Tailwind outline utility on the zone is the old recipe coming back. + */ + it('draws the drop zone as the plated fill, keyed on its state, with no outline utility', async () => { + (window as unknown as { electron: unknown }).electron = { + validateBrxtBundle: vi.fn(async () => { + throw new Error('The bundle could not be read'); + }), + getPathForFile: () => '/tmp/bad.brxt', + }; + render( {}} onInstalled={() => {}} />); + + const zone = screen.getByRole('button', { name: COPY.dropZoneName }); + expect(zone.className).toBe('br-brxt-dropzone'); + expect(zone.querySelector('.br-brxt-drop-plate')).not.toBeNull(); + expect(zone.querySelector('.br-brxt-drop-plate')).toHaveAttribute('aria-hidden', 'true'); + expect(zone).not.toHaveAttribute('data-state'); + + // A file held over it, then taken away again. + fireEvent.dragOver(zone); + expect(zone).toHaveAttribute('data-state', 'dragging'); + fireEvent.dragLeave(zone); + expect(zone).not.toHaveAttribute('data-state'); + + // A bundle that cannot be read leaves the zone in its error state, beside the banner. + fireEvent.drop(zone, { + dataTransfer: { files: [new File(['x'], 'bad.brxt')], items: [], types: ['Files'] }, + }); + await waitFor(() => expect(zone).toHaveAttribute('data-state', 'error')); + }); + + /** + * The shared dialog (`ui/dialog.tsx`, `useReturnFocusToOpener`) decides where first focus goes + * from the last input the window saw. After a key press it keeps Radix's first stop; after a + * pointer press, and on a page nobody has touched (a dialog the app raises by itself, such as + * the one Finder's hand-over opens), it rests on the dialog's own box, which draws no ring. The + * tracker (`ui/inputTrail`) is module state that every earlier case in this file moved, so a case + * that depends on the modality sets it itself. + */ + const keyOpen = () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + const pointerOpen = () => window.dispatchEvent(new Event('pointerdown')); + + function readingFile() { + const validate = vi.fn(() => new Promise(() => {})); + const picker = vi.fn(async () => '/tmp/other.brxt'); + (window as unknown as { electron: unknown }).electron = { + validateBrxtBundle: validate, + openBrxtFilePicker: picker, + }; + render( + {}} + onInstalled={() => {}} + preloadedFilePath="/tmp/playwright.brxt" + /> + ); + return { validate, picker }; + } + + it('keeps the drop zone focusable while it reads a file, so a key open starts on the zone', async () => { + // Finder hands over a file and the dialog opens already reading it. A disabled drop zone + // cannot hold focus, which left a key open's first stop on the dialog's own box. The zone + // answers `aria-disabled` instead, so Radix's first stop is still the zone. + const user = userEvent.setup({ delay: null }); + keyOpen(); + const { validate, picker } = readingFile(); + + const zone = await screen.findByRole('button', { name: COPY.reading }); + expect(zone).not.toBeDisabled(); + expect(zone).toHaveAttribute('aria-disabled', 'true'); + await waitFor(() => expect(zone).toHaveFocus()); + expect(screen.getByRole('dialog')).not.toHaveFocus(); + + // It stays on screen without taking a second file while the first is being read. + await user.click(zone); + expect(picker).not.toHaveBeenCalled(); + expect(validate).toHaveBeenCalledTimes(1); + }); + + it('rests a pointer open on the dialog box, and one Tab still reaches the reading drop zone', async () => { + // A pointer open (or an app-raised one) rests on the box, which draws no ring (V4-R5-1). The + // zone must stay in the tab order while it reads: `aria-disabled`, never `disabled`, which + // would drop it and leave the first Tab on the Cancel button. + const user = userEvent.setup({ delay: null }); + pointerOpen(); + readingFile(); + + const zone = await screen.findByRole('button', { name: COPY.reading }); + const dialog = screen.getByRole('dialog'); + await waitFor(() => expect(dialog).toHaveFocus()); + expect(zone).not.toHaveFocus(); + + await user.tab(); + expect(zone).toHaveFocus(); + }); + + it('labels a required variable with a mark that is not spoken, and masks a secret', async () => { + const user = userEvent.setup({ delay: null }); + withManifest([ENV({ key: 'TEST_KEY' })]); + open(); + + await user.click(await screen.findByRole('button', { name: 'Next: configure' })); + + const field = await screen.findByLabelText(/TEST_KEY/); + expect(field).toHaveAttribute('type', 'password'); + expect(field).toHaveAttribute('aria-required', 'true'); + // The hint is the manifest's own words, in the empty field. + expect(field).toHaveAttribute('placeholder', 'Your API token'); + const label = document.querySelector('label[for="brxt-env-TEST_KEY"]') as HTMLElement; + expect(label).toHaveTextContent('TEST_KEY *'); + expect(label.querySelector('[aria-hidden="true"]')).toHaveTextContent('*'); + }); + + it('does not spellcheck a configuration value, secret or not (V4-R7-5)', async () => { + // An entry is a URL, a path or an id: the red dotted line Chromium draws under one tells a + // person the correct value is wrong. jsdom has no spellchecker, so the markup is what is read. + const user = userEvent.setup({ delay: null }); + withManifest([ENV({ key: 'TEST_KEY' }), ENV({ key: 'BASE_URL', secret: false })]); + open(); + await user.click(await screen.findByRole('button', { name: 'Next: configure' })); + + const entries = [await screen.findByLabelText(/TEST_KEY/), screen.getByLabelText(/BASE_URL/)]; + expect(entries[0]).toHaveAttribute('type', 'password'); + expect(entries[1]).toHaveAttribute('type', 'text'); + for (const entry of entries) { + expect(entry).toHaveAttribute('spellcheck', 'false'); + expect(entry).toHaveAttribute('autocorrect', 'off'); + expect(entry).toHaveAttribute('autocapitalize', 'off'); + } + }); + + it('says which value is missing when Install is pressed, instead of sitting grey', async () => { + const user = userEvent.setup({ delay: null }); + const install = vi.fn(async () => ({ installDir: '/tmp/x' })); + withManifest([ENV({ key: 'TEST_KEY' })]); + (window.electron as unknown as { installBrxtBundle: unknown }).installBrxtBundle = install; + open(); + await user.click(await screen.findByRole('button', { name: 'Next: configure' })); + + const field = await screen.findByLabelText(/TEST_KEY/); + const installButton = screen.getByRole('button', { name: COPY.installExtension }); + expect(installButton).toBeEnabled(); + expect(screen.queryByText(COPY.valueRequired)).toBeNull(); + + await user.click(installButton); + + expect(await screen.findByText(COPY.valueRequired)).toBeVisible(); + expect(field).toHaveAttribute('aria-invalid', 'true'); + expect(field).toHaveAccessibleDescription(COPY.valueRequired); + await waitFor(() => expect(field).toHaveFocus()); + expect(install).not.toHaveBeenCalled(); + + // The message follows what is typed. + await user.type(field, 'abc'); + expect(screen.queryByText(COPY.valueRequired)).toBeNull(); + }); + + it('keeps optional variables behind a disclosure when some are required', async () => { + const user = userEvent.setup({ delay: null }); + withManifest([ENV({}), ENV({ key: 'OPTIONAL_KEY', required: false, secret: false })]); + open(); + await user.click(await screen.findByRole('button', { name: 'Next: configure' })); + + expect(screen.queryByLabelText('OPTIONAL_KEY')).toBeNull(); + const toggle = screen.getByRole('button', { name: COPY.optionalVariables(1) }); + expect(toggle).toHaveAccessibleDescription(COPY.prefilled); + await user.click(toggle); + expect(await screen.findByLabelText('OPTIONAL_KEY')).toBeInTheDocument(); + }); + + it('shows optional-only settings open, with the reassurance as help on their title', async () => { + const user = userEvent.setup({ delay: null }); + withManifest([ENV({ key: 'HEADLESS', required: false, secret: false, default: 'true' })]); + open(); + await user.click(await screen.findByRole('button', { name: 'Next: optional settings' })); + + expect(await screen.findByLabelText('HEADLESS')).toBeVisible(); + expect(screen.getByRole('heading', { name: COPY.optionalSettings })).toBeVisible(); + const help = screen.getByRole('button', { name: `About ${COPY.optionalSettings}` }); + expect(help).toHaveAccessibleDescription(COPY.optionalHelp('Playwright Agent', 1)); + // Nothing is required, so Install is reachable at once, and no paragraph carries the help. + expect(screen.getByRole('button', { name: COPY.installExtension })).toBeEnabled(); + expect(screen.queryByText(/pre-filled with defaults — install/)).toBeNull(); + }); + + it('names the publisher and version under the title, and says "from the marketplace" once', async () => { + withManifest([]); + open({ + preloadedFilePath: '/tmp/cdw.brxt', + origin: { + kind: 'marketplace', + registrySource: { registryId: 'cdwagent' }, + entry: { name: 'CDWAgent', organization: 'BaranziniLab · UCSF', privacyTier: 'private' }, + downloading: false, + onRetry: () => {}, + }, + }); + + const dialog = await screen.findByRole('dialog', { + name: COPY.installTitle('Playwright Agent'), + }); + expect(dialog).toHaveAccessibleDescription('BaranziniLab · UCSF · v0.1.0'); + // The summary's first line is the one statement of where it came from. + expect(await screen.findAllByText(COPY.fromMarketplace)).toHaveLength(1); + }); + + it('says what it is waiting for in one status line with the shared spinner', () => { + render( + {}} + onInstalled={() => {}} + origin={{ + kind: 'marketplace', + registrySource: { registryId: 'playwright-agent' }, + entry: { name: 'Playwright Agent' }, + downloading: true, + onRetry: () => {}, + }} + /> + ); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent(COPY.downloading('Playwright Agent')); + expect(status.querySelector('.br-spinner')).not.toBeNull(); + expect(document.querySelector('.animate-pulse')).toBeNull(); + }); + + it('has no dash in a sentence it says', () => { + const sentences = [ + COPY.tierPrivate, + COPY.tierPublicMarketplace, + COPY.tierPublicFile, + COPY.noLocalPath, + COPY.registerFailed, + COPY.optionalHelp('X', 3), + ]; + for (const text of sentences) expect(text).not.toMatch(/[\u2013\u2014]/); + }); +}); diff --git a/ui/desktop/src/components/BrxtInstallModal.tsx b/ui/desktop/src/components/BrxtInstallModal.tsx index 55fbc6ff4..95e6e124f 100644 --- a/ui/desktop/src/components/BrxtInstallModal.tsx +++ b/ui/desktop/src/components/BrxtInstallModal.tsx @@ -3,7 +3,12 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { Package } from './icons/app-icons'; import { ModalShell } from './ModalShell'; import { Button } from './ui/button'; +import { Disclosure } from './ui/disclosure'; +import { Field } from './ui/field'; +import { InfoTip } from './ui/info-tip'; +import { Input } from './ui/input'; import { PrivacyBadge } from './ui/PrivacyBadge'; +import { Spinner } from './ui/spinner'; import { BrxtEnvVar, BrxtManifest } from '../types/brxt'; import { useConfig } from './ConfigContext'; import { activateExtensionDefault } from './settings/extensions'; @@ -18,6 +23,8 @@ import { resultingPrivacyTier, type BrxtInstallOrigin, } from './brxtInstallFlow'; +import { BRXT_INSTALL_COPY as COPY } from './brxtInstallCopy'; +import './brxt-install.css'; interface EnvEntry { key: string; @@ -58,8 +65,7 @@ interface Props { origin?: BrxtInstallOrigin; } -const NO_LOCAL_PATH_MESSAGE = - 'Biorouter is running on another machine, so it cannot read a file you drop here. Copy the file onto that machine and install it with `biorouter extension install `.'; +const NO_LOCAL_PATH_MESSAGE = COPY.noLocalPath; export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, origin }: Props) { const installOrigin: BrxtInstallOrigin = origin ?? { kind: 'local-file' }; @@ -79,6 +85,9 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig Array<{ slug: string; name: string; description: string }> >([]); const [showOptional, setShowOptional] = useState(false); + // Set by pressing Install with a required value still empty: the empty ones then say so. + const [installAttempted, setInstallAttempted] = useState(false); + const fieldsRef = useRef(null); const fileInputRef = useRef(null); const { addExtension } = useConfig(); @@ -117,7 +126,7 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig ); } } catch (error) { - setError(error instanceof Error ? error.message : 'Could not read the extension bundle'); + setError(error instanceof Error ? error.message : COPY.readFailed); } finally { setIsValidating(false); } @@ -133,15 +142,18 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); + // A bundle is already being read: the zone stays on screen (and keeps focus) but is not + // taking another one. + if (isValidating) return; const file = e.dataTransfer.files[0]; if (!file) return; if (!file.name.endsWith('.brxt')) { - setError('Drop a .brxt file'); + setError(COPY.dropBrxt); return; } processFile(window.electron.getPathForFile(file)); }, - [processFile] + [processFile, isValidating] ); const handleFileInput = useCallback( @@ -230,19 +242,17 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig try { await activateExtensionDefault({ addToConfig: addExtension, extensionConfig }); } catch { - setError( - 'Extension installed but failed to register. Try adding it manually from the Extensions tab.' - ); + setError(COPY.registerFailed); return; } toastService.success({ title: manifest.display_name, - msg: 'Extension installed and enabled', + msg: COPY.installedToast, }); onInstalled(); onClose(); } catch (error) { - setError(error instanceof Error ? error.message : 'Could not install the extension bundle'); + setError(error instanceof Error ? error.message : COPY.installFailed); } finally { setIsInstalling(false); } @@ -290,13 +300,8 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig manifest ? classifyExtension(manifest.name) : null, installOrigin.kind === 'marketplace' ? installOrigin.entry.privacyTier : undefined ); - const publicNotice = isMarketplace - ? 'The Biorouter marketplace publishes this name as public. Any model, including commercial models hosted outside your institution, will be able to call this extension.' - : 'Extensions installed from a file are always Public. Any model, including commercial models hosted outside your institution, will be able to call this extension.'; - const badgeNotice = - resultingTier === 'private' - ? 'The Biorouter marketplace publishes this name as private, so this extension will be Private: only private models will be able to call it.' - : publicNotice; + const publicNotice = isMarketplace ? COPY.tierPublicMarketplace : COPY.tierPublicFile; + const badgeNotice = resultingTier === 'private' ? COPY.tierPrivate : publicNotice; const requiredVars = envEntries.filter((e) => e.required); const optionalVars = envEntries.filter((e) => !e.required); @@ -333,44 +338,40 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig .join(' · ') : ''; - const privacyPanel = ( -
    + // §13.5: the badge this install will produce and the one sentence that says what it means, + // as text and not as help. No panel around them. + const privacyLine = ( +
    -

    {badgeNotice}

    +

    {badgeNotice}

    ); const manifestSummary = manifest && !error && ( -
    -

    - {isMarketplace ? 'From the Biorouter marketplace' : 'Detected from bundle'} +

    +

    + {isMarketplace ? COPY.fromMarketplace : COPY.detectedFromBundle}

    -

    {manifest.display_name}

    -

    +

    {manifest.display_name}

    +

    v{manifest.version} - {manifest.tools_count ? ` · ${manifest.tools_count} tools` : ''} - {skillsPreview.length > 0 - ? ` · ${skillsPreview.length} skill${skillsPreview.length !== 1 ? 's' : ''}` - : ''} + {manifest.tools_count ? ` · ${COPY.tools(manifest.tools_count)}` : ''} + {skillsPreview.length > 0 ? ` · ${COPY.skills(skillsPreview.length)}` : ''} {' · '} - {requiredVars.length} required env var - {requiredVars.length !== 1 ? 's' : ''} - {optionalVars.length > 0 ? `, ${optionalVars.length} optional` : ''} + {COPY.requiredVars(requiredVars.length, optionalVars.length)}

    -

    {manifest.description}

    +

    {manifest.description}

    {skillsPreview.length > 0 && ( -
    -

    - Skills included -

    +
    +

    {COPY.skillsIncluded}

    {skillsPreview.map((skill) => ( -

    - · {skill.name}: {skill.description} +

    + · {skill.name}: {skill.description}

    ))}
    )} -
    +
    ); const dependencyBanner = error && ( @@ -385,19 +386,20 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig /> ); + // The titles say what the dialog is doing; the second line, where there is one, is a fact (where + // it came from), never an instruction. "Install a Biorouter extension bundle (.brxt file)" and + // "Fill in required credentials…" repeated what the drop zone and the asterisks already say. let title: React.ReactNode; let subtitle: React.ReactNode; if (step === 'configure') { - title = `Configure ${manifest?.display_name ?? ''}`; - subtitle = 'Fill in required credentials. Optional fields are pre-filled with defaults.'; + title = COPY.configureTitle(manifest?.display_name ?? ''); } else if (isMarketplace) { - title = `Install ${marketplaceName}`; - subtitle = marketplaceMeta - ? `From the Biorouter marketplace · ${marketplaceMeta}` - : 'From the Biorouter marketplace.'; + title = COPY.installTitle(marketplaceName); + // Who published it and which version: facts. Where it came from is said once, by the + // summary's own first line (and by the tier sentence above it), not again under the title. + subtitle = marketplaceMeta || undefined; } else { - title = 'Add extension'; - subtitle = 'Install a Biorouter extension bundle (.brxt file).'; + title = COPY.addTitle; } /** @@ -407,8 +409,8 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig * differ. */ const backToMarketplace = ( - ); @@ -429,11 +431,11 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig } }} > - Retry + {COPY.retry} ) : ( )} @@ -441,11 +443,11 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig } else if (step === 'select') { footer = ( <> - ); @@ -453,29 +455,79 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig footer = ( <> {isMarketplace ? ( backToMarketplace ) : ( - )} - ); } + /** One credential field: a real label, a masked input for a secret, the manifest's own words as the hint. */ + const envField = (entry: EnvEntry) => ( + + {entry.key}{' '} + + + ) : ( + entry.key + ) + } + required={entry.required} + error={ + installAttempted && entry.required && !entry.value.trim() ? COPY.valueRequired : undefined + } + > + setEnvValue(entry.key, e.target.value)} + autoComplete="off" + /> + + ); + return ( {step === 'select' && isMarketplace && ( /* Issue #116. The marketplace route renders NO local-file controls: no - drop zone, no file input, no "Browse file…". The user chose this + drop zone, no file input, no way to pick a file. The user chose this extension on the previous screen and Biorouter is fetching it — there is nothing for them to supply. */ -
    - {privacyPanel} + <> + {privacyLine} {phase === 'downloading' && ( -
    - -

    - Downloading {marketplaceName}… -

    -
    +

    + + {COPY.downloading(marketplaceName)} +

    )} {phase === 'validating' && ( -
    - -

    Reading bundle…

    -
    +

    + + {COPY.reading} +

    )} {phase === 'error' && @@ -531,185 +585,129 @@ export function BrxtInstallModal({ onClose, onInstalled, preloadedFilePath, orig ))} {phase === 'ready' && manifestSummary} -
    + )} {step === 'select' && !isMarketplace && ( -
    + <> {/* §13.5: the tier this install will actually produce, stated before a bundle has even been chosen — someone who has not yet picked one should already know what dropping it in here means. */} - {privacyPanel} - - {/* Drop zone */} -
    { e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} + onClick={async () => { + if (isValidating) return; + // The native picker returns a real path (and the Playwright hook can answer it + // without a dialog); a surface without it falls back to the file input. + if (typeof window.electron?.openBrxtFilePicker !== 'function') { + fileInputRef.current?.click(); + return; + } + const fp = await window.electron.openBrxtFilePicker(); + if (fp) processFile(fp); + }} + className="br-brxt-dropzone" > - {isValidating ? ( -

    Reading bundle…

    + + + {COPY.reading} + ) : ( <> - -

    - {manifest ? 'Drop a different .brxt file here' : 'Drop your .brxt file here'} -

    -

    or click to browse

    - + + + {manifest ? COPY.dropZoneAnother : COPY.dropZone} + )} -
    + + {/* Error banner. A `.brxt` install fails most often on `uv sync` — a Python dependency that will not build on this machine — which is precisely the kind of thing a session with a shell can work out. */} {dependencyBanner} - {/* Manifest preview card */} + {/* What the bundle says it is */} {manifestSummary} -
    + )} {step === 'configure' && manifest && ( -
    - {requiredVars.length > 0 && ( -
    -

    - Required -

    - {requiredVars.map((entry) => ( -
    - - setEnvValue(entry.key, e.target.value)} - autoComplete="off" - /> -
    - ))} -
    - )} - - {/* Issue #116. When an extension declares only optional variables the - first step's button says so, and this step must not then hide them - behind a disclosure the user has to find. They are open. */} - {optionalVars.length > 0 && requiredVars.length === 0 && ( -
    -

    - Optional -

    -

    - {manifest.display_name} needs no credentials to run. These {optionalVars.length}{' '} - optional setting{optionalVars.length !== 1 ? 's are' : ' is'} pre-filled with - defaults — install without changing anything if you are not sure. -

    - {optionalVars.map((entry) => ( -
    - - setEnvValue(entry.key, e.target.value)} - autoComplete="off" + <> +
    + {requiredVars.map(envField)} + + {/* Issue #116. When an extension declares only optional variables the + first step's button says so, and this step must not then hide them + behind a disclosure the user has to find. They are open, under a + title whose help says they can be left as they are. */} + {optionalVars.length > 0 && requiredVars.length === 0 && ( + <> +
    +

    {COPY.optionalSettings}

    +
    - ))} -
    - )} + {optionalVars.map(envField)} + + )} - {optionalVars.length > 0 && requiredVars.length > 0 && ( -
    - - {showOptional && ( -
    -

    - Optional -

    - {optionalVars.map((entry) => ( -
    - - setEnvValue(entry.key, e.target.value)} - autoComplete="off" - /> -
    - ))} -
    - )} -
    - )} +
    {optionalVars.map(envField)}
    + + )} +
    {dependencyBanner} {/* §13.5: the resulting badge, above the Install button — the footer sits directly below this body, so it is the last thing read before the button that commits the install. */} - {privacyPanel} -
    + {privacyLine} + )} ); diff --git a/ui/desktop/src/components/ChatInput.autoFocus.test.tsx b/ui/desktop/src/components/ChatInput.autoFocus.test.tsx index 87079b47b..c9f5aa242 100644 --- a/ui/desktop/src/components/ChatInput.autoFocus.test.tsx +++ b/ui/desktop/src/components/ChatInput.autoFocus.test.tsx @@ -36,18 +36,11 @@ vi.mock('./ModelAndProviderContext', () => ({ }), })); vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); @@ -92,7 +85,6 @@ function Pane({ label, autoFocus }: { label: string; autoFocus?: boolean }) { accumulatedInputTokens={0} accumulatedOutputTokens={0} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} autoFocus={autoFocus} diff --git a/ui/desktop/src/components/ChatInput.bugCommand.test.tsx b/ui/desktop/src/components/ChatInput.bugCommand.test.tsx index 0894ed2ae..97015c597 100644 --- a/ui/desktop/src/components/ChatInput.bugCommand.test.tsx +++ b/ui/desktop/src/components/ChatInput.bugCommand.test.tsx @@ -31,18 +31,10 @@ vi.mock('./ModelAndProviderContext', () => ({ }), })); vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are mocked by path; their insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: ({ queuedMessages = [], @@ -145,7 +137,6 @@ const renderComposer = ( onFilesProcessed={vi.fn()} messagesLength={options.messagesLength ?? 0} commandHistory={options.commandHistory} - disableAnimation toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.contextGaugeRace.test.tsx b/ui/desktop/src/components/ChatInput.contextGaugeRace.test.tsx index 70760c200..10b78276f 100644 --- a/ui/desktop/src/components/ChatInput.contextGaugeRace.test.tsx +++ b/ui/desktop/src/components/ChatInput.contextGaugeRace.test.tsx @@ -10,32 +10,19 @@ * old model's window. */ import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act, render, waitFor } from '@testing-library/react'; -import type * as Alerts from './alerts'; +import { afterAll, beforeAll, describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +// The gauge is the footer's context ring (V2-R4-1). This file drives it through +// the real ComposerFooter and the real ring, and reads the window from the +// ring's name ("100% context left · 0 of 131.1k"). const gauge = vi.hoisted(() => ({ liveContextWindow: vi.fn(), - addAlert: vi.fn(), - clearAlerts: vi.fn(), - // One array for every render: a new `[]` each time is a new dependency for - // the composer's effects that read it, which re-render without end. - alerts: [] as unknown[], })); vi.mock('../utils/liveContextWindow', () => ({ liveContextWindow: (...args: unknown[]) => gauge.liveContextWindow(...args), })); -// The real alert types; only the hook is replaced, so every alert the composer -// raises is recorded with the window it was given. -vi.mock('./alerts', async (importOriginal) => ({ - ...(await importOriginal()), - useAlerts: () => ({ - alerts: gauge.alerts, - addAlert: gauge.addAlert, - clearAlerts: gauge.clearAlerts, - }), -})); // The scaffold the sibling ChatInput suites use. vi.mock('./ConfigContext', () => ({ @@ -57,22 +44,9 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ - BottomMenuSkillSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ - CostTracker: () => null, -})); +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null, })); @@ -89,6 +63,21 @@ vi.mock('../api', () => ({ import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + beforeEach(() => { vi.clearAllMocks(); Object.assign(window, { @@ -121,26 +110,22 @@ const composer = ( droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} effectiveModel={effectiveModel} /> ); -/** Every window the gauge has been shown, in order. */ -const gaugeWindows = () => - gauge.addAlert.mock.calls - .map(([alert]) => alert as { message?: string; progress?: { total: number } }) - .filter((alert) => alert.message === 'Context window') - .map((alert) => alert.progress?.total); +const ring = () => screen.queryByTestId('context-window-indicator'); +const WINDOW_131K = /of 131\.1k$/; +const WINDOW_65K = /of 65\.5k$/; describe('the context gauge across a model switch', () => { it('refreshes the local window after a turn lowers Ollama’s automatic context', async () => { gauge.liveContextWindow.mockResolvedValue(131_072); const model = { provider: 'ollama', model: 'qwen3.8:27b' }; const view = render(composer(model)); - await waitFor(() => expect(gaugeWindows()).toContain(131_072)); + await waitFor(() => expect(ring()).toHaveAccessibleName(WINDOW_131K)); view.rerender(composer(model, ChatState.Thinking)); view.rerender(composer(model, ChatState.Streaming)); @@ -148,7 +133,7 @@ describe('the context gauge across a model switch', () => { gauge.liveContextWindow.mockResolvedValue(65_536); view.rerender(composer(model, ChatState.Idle)); - await waitFor(() => expect(gaugeWindows().slice(-1)).toEqual([65_536])); + await waitFor(() => expect(ring()).toHaveAccessibleName(WINDOW_65K)); expect(gauge.liveContextWindow).toHaveBeenCalledTimes(2); }); @@ -156,7 +141,7 @@ describe('the context gauge across a model switch', () => { gauge.liveContextWindow.mockResolvedValue(131_072); const model = { provider: 'anthropic', model: 'claude-opus-5' }; const view = render(composer(model)); - await waitFor(() => expect(gaugeWindows()).toContain(131_072)); + await waitFor(() => expect(ring()).toHaveAccessibleName(WINDOW_131K)); view.rerender(composer(model, ChatState.Streaming)); view.rerender(composer(model, ChatState.Idle)); @@ -178,15 +163,45 @@ describe('the context gauge across a model switch', () => { // The switch, while the Ollama probe is still out. view.rerender(composer({ provider: 'llamacpp', model: 'qwen3.8-27b' })); - await waitFor(() => expect(gaugeWindows()).toContain(131_072)); + await waitFor(() => expect(ring()).toHaveAccessibleName(WINDOW_131K)); // Now the stale Ollama answer lands. await act(async () => { answerOllama(65_536); }); - const windows = gaugeWindows(); - expect(windows).not.toContain(65_536); - expect(windows[windows.length - 1]).toBe(131_072); + expect(ring()).toHaveAccessibleName(WINDOW_131K); + expect(ring()).not.toHaveAccessibleName(WINDOW_65K); + }); + + // The same model read twice: the read the chat started when it opened is + // still out when a turn ends and the re-read after it answers. Both answers + // are for this chat's model, so only the order decides, and the older one + // must not put the window the turn moved away from back on the ring. + it('keeps the re-read window when an older read of the same model answers last', async () => { + const answers: Array<(window: number | null) => void> = []; + gauge.liveContextWindow.mockImplementation( + () => + new Promise((resolve) => { + answers.push(resolve); + }) + ); + const model = { provider: 'ollama', model: 'qwen3.8:27b' }; + + const view = render(composer(model, ChatState.Streaming)); + await waitFor(() => expect(gauge.liveContextWindow).toHaveBeenCalledTimes(1)); + + view.rerender(composer(model, ChatState.Idle)); + await waitFor(() => expect(gauge.liveContextWindow).toHaveBeenCalledTimes(2)); + + await act(async () => { + answers[1](65_536); + }); + await waitFor(() => expect(ring()).toHaveAccessibleName(WINDOW_65K)); + + await act(async () => { + answers[0](131_072); + }); + expect(ring()).toHaveAccessibleName(WINDOW_65K); }); }); diff --git a/ui/desktop/src/components/ChatInput.contextWindow.test.tsx b/ui/desktop/src/components/ChatInput.contextWindow.test.tsx new file mode 100644 index 000000000..6cffc8728 --- /dev/null +++ b/ui/desktop/src/components/ChatInput.contextWindow.test.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; + +// The composer decides which context window the footer ring reads, and the +// ring draws only against a window marked loaded (V2-R4-1). So "loaded" has to +// mean "loaded for the model this chat is bound to NOW": two lookups can be in +// flight at once (the composer mounts before the chat's row names its binding, +// or the person changes model twice quickly), and whichever finished LAST used +// to win, however old its question was. This file drives that race through the +// real ComposerFooter and the real ring, with the lookups held open by hand. + +const mocks = vi.hoisted(() => ({ + getProviders: vi.fn(), + read: vi.fn(), + getCurrentModelAndProvider: vi.fn(), +})); + +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ getProviders: mocks.getProviders, read: mocks.read }), +})); +vi.mock('./ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + getCurrentModelAndProvider: mocks.getCurrentModelAndProvider, + currentModel: 'app-model', + currentProvider: 'app-provider', + currentModelSupportsVision: false, + currentModelSupportedInputMimeTypes: null, + }), +})); +vi.mock('../hooks/useDiverge', () => ({ + useDiverge: () => ({ diverge: vi.fn() }), +})); +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +vi.mock('./MessageQueue', () => ({ default: () => null })); +vi.mock('./MentionPopover', () => { + const MentionPopoverMock = React.forwardRef(() => null); + MentionPopoverMock.displayName = 'MentionPopoverMock'; + return { default: MentionPopoverMock }; +}); +vi.mock('../api', () => ({ + getSession: vi.fn(async () => ({ data: null })), + llamacppStatus: vi.fn(async () => ({ data: {} })), + updateWorkingDir: vi.fn(async () => ({ data: {} })), +})); + +import ChatInput from './ChatInput'; +import { ChatState } from '../types/chatState'; +import type { PinnedModelView } from '../hooks/chatStreamStore'; + +type Providers = Array<{ + name: string; + metadata: { known_models: Array<{ name: string; context_limit: number }> }; +}>; + +/** One lookup the test holds open until it decides how it ends. */ +function deferred() { + let resolve!: (providers: Providers) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// Two models with windows far enough apart to read at a glance in the ring's +// name: "of 1M" against "of 200k". +const PROVIDERS: Providers = [ + { + name: 'app-provider', + metadata: { known_models: [{ name: 'app-model', context_limit: 1_000_000 }] }, + }, + { + name: 'pinned-provider', + metadata: { known_models: [{ name: 'pinned-model', context_limit: 200_000 }] }, + }, +]; +const PINNED: PinnedModelView = { provider: 'pinned-provider', model: 'pinned-model' }; + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.read.mockResolvedValue(null); + // The chat has no pin yet, so the first lookup measures the app's selection. + mocks.getCurrentModelAndProvider.mockResolvedValue({ + model: 'app-model', + provider: 'app-provider', + }); + Object.assign(window, { + appConfig: { get: () => undefined }, + electron: { + directoryChooser: vi.fn(), + addRecentDir: vi.fn(), + logInfo: vi.fn(), + getPathForFile: vi.fn(() => ''), + on: vi.fn(), + off: vi.fn(), + }, + }); +}); + +function chat(effectiveModel?: PinnedModelView) { + return ( + + ); +} + +/** Mount on the app's model, then pin the chat to another while the first lookup is still open. */ +async function mountThenPin() { + const first = deferred(); + const second = deferred(); + mocks.getProviders.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + const view = render(chat()); + // The first run has reached its provider lookup before the binding arrives. + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledTimes(1)); + view.rerender(chat(PINNED)); + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledTimes(2)); + return { first, second }; +} + +const ring = () => screen.queryByTestId('context-window-indicator'); + +describe('ChatInput context window across overlapping lookups', () => { + it('draws no ring while the lookups are open', async () => { + await mountThenPin(); + expect(ring()).toBeNull(); + }); + + it('keeps the newest lookup when an older one finishes after it', async () => { + const { first, second } = await mountThenPin(); + + await act(async () => second.resolve(PROVIDERS)); + await waitFor(() => expect(ring()).toHaveAccessibleName(/of 200k$/)); + + // The first run was about the app's model, which this chat is no longer bound to. + await act(async () => first.resolve(PROVIDERS)); + expect(ring()).toHaveAccessibleName(/of 200k$/); + expect(ring()).not.toHaveAccessibleName(/of 1M/); + }); + + it('does not let an older lookup that fails put the default window back', async () => { + const { first, second } = await mountThenPin(); + + await act(async () => second.resolve(PROVIDERS)); + await waitFor(() => expect(ring()).toHaveAccessibleName(/of 200k$/)); + + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + await act(async () => first.reject(new Error('offline'))); + logged.mockRestore(); + + expect(ring()).toHaveAccessibleName(/of 200k$/); + }); + + it('still takes the newest lookup when the lookups finish in order', async () => { + const { first, second } = await mountThenPin(); + + await act(async () => first.resolve(PROVIDERS)); + // The older answer must not stand in for the chat's model while the newest is open. + expect(ring()).toBeNull(); + + await act(async () => second.resolve(PROVIDERS)); + await waitFor(() => expect(ring()).toHaveAccessibleName(/of 200k$/)); + }); +}); + +/** The composer of a chat that is opening: loading, then loaded with or without a pin. */ +function opening(chatState: ChatState, effectiveModel?: PinnedModelView) { + return ( + + ); +} + +/** Lets a lookup whose promises are already resolved run to its settle. */ +async function flushLookups() { + await act(async () => { + await new Promise((done) => setTimeout(done, 0)); + }); +} + +// V2-R4-1, round 5. Until a chat's row lands (a tab restored after a reload, a +// hash or History open), the composer has no binding and looks up the window of +// the model for NEW chats; that lookup is loaded, for the wrong model. Measured: +// a 200k chat read "0 of 1.1M", swept to "64.6k of 1.1M", then "of 200k". +describe('ChatInput context window while a chat opens (V2-R4-1, round 5)', () => { + it('draws no ring while the chat loads, nor until its own model’s window lands', async () => { + mocks.getProviders.mockResolvedValueOnce(PROVIDERS); + const view = render(opening(ChatState.LoadingConversation)); + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledTimes(1)); + await flushLookups(); + // The app's model's window has loaded, but the chat's binding is not known. + expect(ring()).toBeNull(); + + // The row lands, pinned to another model; its window is still being read. + const pinned = deferred(); + mocks.getProviders.mockReturnValueOnce(pinned.promise); + view.rerender(opening(ChatState.Idle, PINNED)); + expect(ring()).toBeNull(); + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledTimes(2)); + expect(ring()).toBeNull(); + + await act(async () => pinned.resolve(PROVIDERS)); + await waitFor(() => expect(ring()).toHaveAccessibleName(/of 200k$/)); + }); + + it('draws the selection’s window as soon as a chat with no pin has loaded', async () => { + mocks.getProviders.mockResolvedValueOnce(PROVIDERS); + const view = render(opening(ChatState.LoadingConversation)); + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledTimes(1)); + await flushLookups(); + expect(ring()).toBeNull(); + + view.rerender(opening(ChatState.Idle)); + expect(ring()).toHaveAccessibleName(/of 1M$/); + // The lookup made while the chat loaded was for this same model: no second one. + expect(mocks.getProviders).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/desktop/src/components/ChatInput.crewCommand.test.tsx b/ui/desktop/src/components/ChatInput.crewCommand.test.tsx index bdb3fd8f2..5307e39b5 100644 --- a/ui/desktop/src/components/ChatInput.crewCommand.test.tsx +++ b/ui/desktop/src/components/ChatInput.crewCommand.test.tsx @@ -35,18 +35,11 @@ vi.mock('./ModelAndProviderContext', () => ({ }), })); vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: ({ queuedMessages = [], @@ -90,6 +83,7 @@ vi.mock('../api', () => ({ })); import ChatInput from './ChatInput'; +import { COMPOSER_COPY } from './composer/copy'; import { ChatState } from '../types/chatState'; import type { DroppedFile } from '../hooks/useFileDrop'; import type { View, ViewOptions } from '../utils/navigationUtils'; @@ -176,7 +170,6 @@ const renderComposer = (options: RenderOptions = {}) => { droppedFiles={options.droppedFiles ?? []} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation toolCount={0} onWorkingDirChange={vi.fn()} /> @@ -237,8 +230,8 @@ describe('the /crew composer navigation command', () => { */ describe('/crew in a chat that has no session yet', () => { const START_FIRST = expect.objectContaining({ - title: 'Start the chat first', - msg: 'Send this chat a message, then type /crew to connect it to a Crew channel. To just open Crew, use the sidebar.', + title: COMPOSER_COPY.toasts.crewStartFirst.title, + msg: COMPOSER_COPY.toasts.crewStartFirst.message, }); it('warns from Enter, keeps the draft and never navigates or submits', () => { @@ -300,7 +293,9 @@ describe('/crew in a chat that has no session yet', () => { fireEvent.click(sendButton()); expect(toastWarning).toHaveBeenCalledTimes(1); - expect(toastWarning).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft kept' })); + expect(toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ title: COMPOSER_COPY.toasts.crewDraftKept.title }) + ); expect(setView).not.toHaveBeenCalled(); expect(composer().value).toBe('/crew'); }); @@ -315,7 +310,9 @@ describe('the /crew command refuses local attachment extras', () => { await waitFor(() => expect(screen.getByTestId('resource-ref-chip')).toBeInTheDocument()); fireEvent.click(sendButton()); - expect(toastWarning).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft kept' })); + expect(toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ title: COMPOSER_COPY.toasts.crewDraftKept.title }) + ); expect(setView).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled(); expect(composer().value).toBe('/crew'); @@ -329,14 +326,16 @@ describe('the /crew command refuses local attachment extras', () => { chatState: ChatState.Streaming, }); - expect(screen.getByTitle('report.txt')).toBeInTheDocument(); + expect(screen.getByTestId('composer-file-chip')).toHaveTextContent('report.txt'); fireEvent.click(sendButton()); - expect(toastWarning).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft kept' })); + expect(toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ title: COMPOSER_COPY.toasts.crewDraftKept.title }) + ); expect(setView).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled(); expect(composer().value).toBe('/crew'); - expect(screen.getByTitle('report.txt')).toBeInTheDocument(); + expect(screen.getByTestId('composer-file-chip')).toHaveTextContent('report.txt'); }); it('keeps a pasted image and draft, with no dispatch', async () => { @@ -349,7 +348,9 @@ describe('the /crew command refuses local attachment extras', () => { await waitFor(() => expect(screen.getByAltText(/^Pasted image/)).toBeInTheDocument()); fireEvent.click(sendButton()); - expect(toastWarning).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft kept' })); + expect(toastWarning).toHaveBeenCalledWith( + expect.objectContaining({ title: COMPOSER_COPY.toasts.crewDraftKept.title }) + ); expect(setView).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled(); expect(composer().value).toBe('/crew'); @@ -435,7 +436,6 @@ describe('Enter in a chat held by lapsed Crew access', () => { droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.droppedFileError.test.tsx b/ui/desktop/src/components/ChatInput.droppedFileError.test.tsx index d1d1252d4..d42bb4233 100644 --- a/ui/desktop/src/components/ChatInput.droppedFileError.test.tsx +++ b/ui/desktop/src/components/ChatInput.droppedFileError.test.tsx @@ -28,20 +28,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ - BottomMenuSkillSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); @@ -110,7 +101,6 @@ const renderWithFiles = (droppedFiles: DroppedFile[]) => { droppedFiles={droppedFiles} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> @@ -141,8 +131,19 @@ describe('an attachment that could not be located', () => { it('leaves a file that does have a path alone', async () => { renderWithFiles([okFile]); await waitFor(() => { - expect(screen.getByText('text/csv')).toBeTruthy(); + expect(screen.getByTestId('composer-file-chip')).toHaveTextContent('results.csv'); }); expect(screen.queryByText(/cannot be read/i)).toBeNull(); + expect(screen.queryByTestId('composer-failed-chip')).toBeNull(); + }); + + it('marks the failed file in danger ink and explains it in its help', async () => { + renderWithFiles([erroredFile]); + const chip = await screen.findByTestId('composer-failed-chip'); + expect(chip).toHaveTextContent('results.csv'); + expect(chip.className).toContain('text-text-danger'); + expect(screen.getByRole('button', { name: 'About results.csv' })).toHaveAccessibleDescription( + /cannot be read from a browser tab/i + ); }); }); diff --git a/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx b/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx new file mode 100644 index 000000000..7c5878484 --- /dev/null +++ b/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +/** + * V2-R8-3. A new chat's first send must not withdraw what the composer states + * and then state it again. + * + * Measured in the running app at 1440: 81 ms after Enter in a new chat the + * composer read "Tools" (no number) and "gpt-5.5" (no padlock); at 128 ms it + * read "Tools 5" and the padlock again. The cause is a REMOUNT, not the null to + * id step the chips already carried: `BaseChat` draws the composer centred + * under the greeting while the chat is empty and in the bar under the + * transcript once it is not (`renderChatInput()` in both branches), and the + * send leaves the empty state in the commit that starts creating the chat. So + * a second `ChatInput` mounts, and every state of the first is gone. + * + * `ChatPane` below has `BaseChat`'s shape at that seam (a ternary holding the + * composer in its empty branch, and a bar after it holding the composer + * otherwise) and nothing else. The REAL composer, Tools chip and model chip + * run; the three lists are stand-ins that answer per mount, a moment after + * they are asked, as the real ones do. + */ + +const lists = vi.hoisted(() => ({ extensions: 2, skills: 2, knowledge: 1, answerAfterMs: 15 })); + +/** True once this mount's list has answered for `sessionId`. */ +function useAnsweredFor(sessionId: string | null): boolean { + const [answeredFor, setAnsweredFor] = React.useState<{ chat: string | null } | null>(null); + React.useEffect(() => { + const timer = setTimeout(() => setAnsweredFor({ chat: sessionId }), lists.answerAfterMs); + return () => clearTimeout(timer); + }, [sessionId]); + return answeredFor !== null && answeredFor.chat === sessionId; +} + +vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ + useExtensionPicker: (sessionId: string | null) => ({ + activeCount: lists.extensions, + answered: useAnsweredFor(sessionId), + setQuery: () => {}, + }), + ExtensionsPanel: () => null, +})); +vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ + useSkillPicker: (sessionId: string | null) => ({ + activeCount: lists.skills, + answered: useAnsweredFor(sessionId), + setQuery: () => {}, + }), + SkillsPanel: () => null, +})); +vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ + useKnowledgePicker: () => ({ + visibleCount: lists.knowledge, + answered: useAnsweredFor(null), + setQuery: () => {}, + }), + KnowledgePanel: () => null, +})); + +// The app's catalog as `ConfigContext` holds it once anything has read it: +// `getProviders(false)` answers from this very list. One object, as the +// context's value is: a new function per render would re-run every effect that +// depends on it. +const config = vi.hoisted(() => { + const VERSA = { + name: 'versa_azure', + is_configured: true, + provider_type: 'Builtin', + metadata: { + name: 'versa_azure', + display_name: 'Versa API Azure', + tier: 'private', + runs_locally: false, + }, + affiliation: null, + resolved_tier: 'private', + }; + const providersList = [VERSA]; + return { + getProviders: async () => providersList, + read: async () => '', + providersList, + }; +}); +vi.mock('./ConfigContext', () => ({ + useConfig: () => config, + usePrivacyTiersEnabled: () => true, +})); +vi.mock('./ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + getCurrentModelAndProvider: async () => ({ model: 'gpt-5.5', provider: 'versa_azure' }), + currentModel: 'gpt-5.5', + currentProvider: 'versa_azure', + currentModelSupportsVision: true, + currentModelSupportedInputMimeTypes: null, + getCurrentModelAndProviderForDisplay: async () => ({ + model: 'gpt-5.5', + provider: 'Versa API Azure', + }), + getCurrentModelDisplayName: async () => 'gpt-5.5', + getCurrentProviderDisplayName: async () => 'Versa API Azure', + }), +})); +vi.mock('./settings/models/subcomponents/SwitchModelModal', () => ({ + SwitchModelModal: () => null, +})); +vi.mock('./settings/models/subcomponents/LeadWorkerSettings', () => ({ + LeadWorkerSettings: () => null, +})); +vi.mock('../hooks/useDiverge', () => ({ + useDiverge: () => ({ diverge: vi.fn() }), +})); +// The footer as the folder it names, and a way to pick one. +vi.mock('./bottom_menu/ComposerFooter', () => ({ + ComposerFooter: ({ + workingDir, + onWorkingDirChange, + }: { + workingDir: string | null; + onWorkingDirChange: (dir: string) => void; + }) => ( +
    + {workingDir ?? ''} + +
    + ), +})); +vi.mock('./MessageQueue', () => ({ default: () => null })); +vi.mock('./MentionPopover', () => { + const MentionPopoverMock = React.forwardRef(() => null); + MentionPopoverMock.displayName = 'MentionPopoverMock'; + return { default: MentionPopoverMock }; +}); +vi.mock('../api', async (importOriginal) => ({ + ...(await importOriginal>()), + // The new chat's row: created in the folder picked before the send. + getSession: vi.fn(async ({ path }: { path: { session_id: string } }) => ({ + data: { + id: path.session_id, + working_dir: path.session_id === OTHER_CHAT ? OTHER_DIR : CHOSEN_DIR, + privacy_tier: 'public', + }, + })), + llamacppStatus: vi.fn(async () => ({ data: {} })), + updateWorkingDir: vi.fn(async () => ({ data: {} })), + getPrivacyDisclosure: vi.fn(async () => ({ + data: { title_template: '{provider}', long: 'LONG', short: 'SHORT', acknowledged: true }, + })), + ackPrivacyDisclosure: vi.fn(async () => ({ data: {} })), +})); +vi.mock('../utils/userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-key' }), +})); +vi.mock('../toasts', () => ({ + toastWarning: vi.fn(), + toastError: vi.fn(), + toastInfo: vi.fn(), + toastSuccess: vi.fn(), + toastLoading: vi.fn(), +})); + +import ChatInput from './ChatInput'; +import { ChatState } from '../types/chatState'; +import { composerHandOffSizeForTests } from './composer/composerHandOff'; +import { __resetDisclosureStoreForTests } from './privacy/disclosureCopy'; + +const DEFAULT_DIR = '/default/workdir'; +const CHOSEN_DIR = '/Users/wgu/Desktop/data'; +const TAB = 'tab-1'; +const NEW_CHAT = 'chat-new'; +const OTHER_CHAT = 'chat-b'; +const OTHER_DIR = '/Users/wgu/Projects/b'; + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); +}); +afterAll(() => vi.unstubAllGlobals()); + +beforeEach(() => { + __resetDisclosureStoreForTests(); + Object.assign(window, { + appConfig: { + get: (key: string) => (key === 'BIOROUTER_WORKING_DIR' ? DEFAULT_DIR : undefined), + }, + electron: { + directoryChooser: vi.fn(async () => ({ canceled: true, filePaths: [] })), + addRecentDir: vi.fn(), + logInfo: vi.fn(), + getPathForFile: vi.fn(() => ''), + on: vi.fn(), + off: vi.fn(), + }, + }); +}); + +interface PaneProps { + /** `BaseChat`'s `isCleanConversation`: false from the commit that starts creating the chat. */ + clean: boolean; + sessionId: string | null; + /** `BaseChat`'s tab-scoped `reasoningDraftKey`, the same before and after the chat exists. */ + composerKey?: string; +} + +/** `BaseChat` at the seam that matters: one composer, drawn in one of two places. */ +function ChatPane({ clean, sessionId, composerKey = TAB }: PaneProps) { + const input = ( + undefined} + draftKey={sessionId ? `${composerKey}:${sessionId}` : composerKey} + reasoningDraftKey={composerKey} + initialValue="" + setView={vi.fn()} + totalTokens={0} + accumulatedInputTokens={0} + accumulatedOutputTokens={0} + droppedFiles={[]} + onFilesProcessed={vi.fn()} + messagesLength={0} + sessionCosts={undefined} + toolCount={0} + /> + ); + return ( +
    +
    + {clean ? ( +
    +
    +

    Greeting

    + {input} +
    +
    + ) : ( +
    + )} +
    + {!clean &&
    {input}
    } +
    + ); +} + +/** What the composer states in one frame: the Tools chip, its name, the padlock, the folder. */ +function frame(): string { + const tools = screen.queryByTestId('composer-tools-chip'); + const model = screen.queryByTestId('model-chip'); + const lock = model?.querySelector('[data-testid="privacy-badge"]') ? '+lock' : 'no lock'; + const folder = screen.queryByTestId('footer-folder')?.textContent ?? '-'; + return `${tools?.textContent ?? '-'} | ${tools?.getAttribute('aria-label') ?? '-'} | ${lock} | ${folder}`; +} + +/** Every distinct committed state from now on (the observer reports per commit). */ +function recordFrames() { + const frames = [frame()]; + const observer = new MutationObserver(() => { + const next = frame(); + if (frames[frames.length - 1] !== next) frames.push(next); + }); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + }); + return { frames, stop: () => observer.disconnect() }; +} + +const settle = (ms: number) => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + +const SETTLED = `Tools5 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${CHOSEN_DIR}`; + +/** A new tab, answered, with a folder picked before the first message. */ +async function openNewChatWithPickedFolder() { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Pick folder' })); + await waitFor(() => expect(frame()).toBe(SETTLED)); + return view; +} + +describe('a new chat’s first send keeps what the composer states (V2-R8-3)', () => { + it('keeps "Tools 5", its name, the padlock and the picked folder in every commit', async () => { + const view = await openNewChatWithPickedFolder(); + const fieldBefore = screen.getByTestId('chat-input'); + const record = recordFrames(); + + // Enter: the chat starts being created, and the composer leaves the centre. + view.rerender(); + // The instrument: this IS a second composer, as in `BaseChat`. + expect(screen.getByTestId('chat-input')).not.toBe(fieldBefore); + await settle(5); + // The chat exists. + view.rerender(); + // Its lists answer, and its row (the folder) lands. + await settle(lists.answerAfterMs * 3); + record.stop(); + + expect(record.frames).toEqual([SETTLED]); + }); + + it('hands over nothing to a composer that mounts later, or for another chat', async () => { + const view = await openNewChatWithPickedFolder(); + view.unmount(); + // Every entry left with its composer: nothing is kept for a later visit. + expect(composerHandOffSizeForTests()).toBe(0); + + // The same tab, opened again: it asks for itself. + const again = render(); + expect(screen.getByTestId('composer-tools-chip')).toHaveTextContent(/^Tools$/); + expect(screen.getByTestId('composer-tools-chip')).toHaveAccessibleName('Tools'); + expect(screen.getByTestId('footer-folder')).toHaveTextContent(DEFAULT_DIR); + again.unmount(); + + // A composer settled on one chat, replaced by one bound to ANOTHER chat: + // nothing it stated is carried over. + const chatA = render(); + await waitFor(() => expect(frame()).toBe(SETTLED)); + chatA.rerender(); + expect(screen.getByTestId('composer-tools-chip')).toHaveTextContent(/^Tools$/); + expect(screen.getByTestId('composer-tools-chip')).toHaveAccessibleName('Tools'); + expect(screen.getByTestId('footer-folder')).toHaveTextContent(/^$/); + // Then its own figures and folder, once they are read. + await waitFor(() => + expect(frame()).toBe( + `Tools5 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${OTHER_DIR}` + ) + ); + chatA.unmount(); + expect(composerHandOffSizeForTests()).toBe(0); + }); +}); diff --git a/ui/desktop/src/components/ChatInput.giveBack.test.tsx b/ui/desktop/src/components/ChatInput.giveBack.test.tsx index 8b2d3988c..5960aa01b 100644 --- a/ui/desktop/src/components/ChatInput.giveBack.test.tsx +++ b/ui/desktop/src/components/ChatInput.giveBack.test.tsx @@ -47,18 +47,11 @@ vi.mock('./ModelAndProviderContext', () => ({ }), })); vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); @@ -145,7 +138,6 @@ function NewChat({ accumulatedInputTokens={0} accumulatedOutputTokens={0} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> @@ -185,7 +177,6 @@ function PlainComposer({ accumulatedInputTokens={0} accumulatedOutputTokens={0} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> @@ -233,7 +224,10 @@ const dropFile = async (label: string, path: string) => { }; const pastedImages = (label: string) => within(pane(label)).queryAllByAltText(/^Pasted image/); -const fileChip = (label: string, name: string) => within(pane(label)).queryByTitle(name); +const fileChip = (label: string, name: string) => + within(pane(label)) + .queryAllByTestId('composer-file-chip') + .find((chip) => chip.textContent?.includes(name)) ?? null; describe('1 — a failed start gives the message back to ITS composer and no other', () => { it('leaves the other pane’s new tab exactly as it was', async () => { @@ -530,7 +524,6 @@ describe('D4 — a message still in flight when its composer goes is handed back accumulatedInputTokens={0} accumulatedOutputTokens={0} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.noProvider.test.tsx b/ui/desktop/src/components/ChatInput.noProvider.test.tsx index 7c8beaa4e..e4d152989 100644 --- a/ui/desktop/src/components/ChatInput.noProvider.test.tsx +++ b/ui/desktop/src/components/ChatInput.noProvider.test.tsx @@ -38,22 +38,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ - BottomMenuSkillSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ - CostTracker: () => null, -})); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null, })); @@ -74,6 +63,7 @@ vi.mock('../api', () => ({ import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; +import { NO_MODEL_COMPOSER_ACTION, NO_MODEL_COMPOSER_HINT } from './composerNoProvider'; beforeEach(() => { vi.clearAllMocks(); @@ -110,7 +100,6 @@ function renderComposer(effectiveModel?: { provider: string; model: string }) { droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} effectiveModel={effectiveModel} @@ -122,7 +111,8 @@ describe('the composer with no model configured', () => { it('says why, above the input, with the way out', async () => { renderComposer(); const hint = await screen.findByTestId('composer-no-model-hint'); - expect(hint).toHaveTextContent('No model yet'); + expect(hint).toHaveTextContent(NO_MODEL_COMPOSER_HINT); + expect(hint).toHaveTextContent(NO_MODEL_COMPOSER_ACTION); fireEvent.click(screen.getByTestId('composer-no-model-action')); expect(setView).toHaveBeenCalledWith('ConfigureProviders'); }); diff --git a/ui/desktop/src/components/ChatInput.privacyTier.test.tsx b/ui/desktop/src/components/ChatInput.privacyTier.test.tsx index 5d6cc1c92..e2ca8f1c6 100644 --- a/ui/desktop/src/components/ChatInput.privacyTier.test.tsx +++ b/ui/desktop/src/components/ChatInput.privacyTier.test.tsx @@ -48,26 +48,15 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ - default: ({ privacyTier }: { privacyTier?: string }) => ( +// The pickers and the footer are WS-PICKERS' modules, mocked by path. The +// model chip reports the tier it was handed, which is what this suite reads. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ + ModelEffortChip: ({ privacyTier }: { privacyTier?: string }) => (
    {privacyTier ?? 'unresolved'}
    ), })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ - BottomMenuSkillSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ - CostTracker: () => null, -})); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null, })); @@ -107,12 +96,14 @@ function chatInput( sessionId: string | null, chatState: ChatState = ChatState.Idle, messagesLength = 0, - sessionRowPrivacyTier?: 'public' | 'private' + sessionRowPrivacyTier?: 'public' | 'private', + followUp?: boolean ) { return ( @@ -135,9 +125,10 @@ function renderChatInput( sessionId: string | null, chatState: ChatState = ChatState.Idle, messagesLength = 0, - sessionRowPrivacyTier?: 'public' | 'private' + sessionRowPrivacyTier?: 'public' | 'private', + followUp?: boolean ) { - return render(chatInput(sessionId, chatState, messagesLength, sessionRowPrivacyTier)); + return render(chatInput(sessionId, chatState, messagesLength, sessionRowPrivacyTier, followUp)); } // A promise whose resolution this test controls, so the order responses LAND @@ -269,6 +260,30 @@ describe('ChatInput session privacy tier (#56)', () => { expect(getSessionMock.mock.calls.length).toBe(afterBind + 1); }); + it('is not armed or fired by followUp, which only words the placeholder', async () => { + // A new chat's first send draws its message before the store holds it, and + // BaseChat says so with `followUp` (the placeholder). Folding that row into + // `messagesLength` instead would move this probe's timing for a message the + // daemon has not streamed: the re-read must wait for the transcript to grow. + getSessionMock + .mockResolvedValueOnce({ data: { working_dir: '/w' } } as never) + .mockResolvedValue({ data: { privacy_tier: 'public' } } as never); + + const { rerender } = renderChatInput('chat-a', ChatState.Streaming, 0, undefined, false); + await waitFor(() => expect(screen.getByTestId('tier-probe')).toHaveTextContent('public')); + const afterBind = getSessionMock.mock.calls.length; + + rerender(chatInput('chat-a', ChatState.Streaming, 0, undefined, true)); + await act(async () => { + await Promise.resolve(); + }); + expect(getSessionMock.mock.calls.length).toBe(afterBind); + + // The transcript growing is what fires it, as before. + rerender(chatInput('chat-a', ChatState.Streaming, 1, undefined, true)); + await waitFor(() => expect(getSessionMock.mock.calls.length).toBe(afterBind + 1)); + }); + it('stops re-reading once the answer is private', async () => { // The ratchet only raises within a chat, so no further read during the turn // can change the answer. diff --git a/ui/desktop/src/components/ChatInput.queueDrain.test.tsx b/ui/desktop/src/components/ChatInput.queueDrain.test.tsx index 3a6f6c292..ef6364b88 100644 --- a/ui/desktop/src/components/ChatInput.queueDrain.test.tsx +++ b/ui/desktop/src/components/ChatInput.queueDrain.test.tsx @@ -41,18 +41,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -104,6 +97,7 @@ vi.mock('../toasts', () => ({ })); import ChatInput from './ChatInput'; +import { COMPOSER_COPY } from './composer/copy'; import { ChatState } from '../types/chatState'; import { toastWarning } from '../toasts'; import type { DroppedFile } from '../hooks/useFileDrop'; @@ -180,7 +174,6 @@ function renderComposer( droppedFiles={droppedFiles} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> @@ -451,7 +444,7 @@ describe('stopping the current turn and sending a queued message', () => { expect(handleSubmit).not.toHaveBeenCalled(); expect(queued()).toEqual([QUEUED_TEXT]); expect(toastWarning).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Message still queued' }) + expect.objectContaining({ title: COMPOSER_COPY.toasts.queued.title }) ); }); diff --git a/ui/desktop/src/components/ChatInput.queueSurvivesRemount.test.tsx b/ui/desktop/src/components/ChatInput.queueSurvivesRemount.test.tsx index a0a6d357c..ca5949f49 100644 --- a/ui/desktop/src/components/ChatInput.queueSurvivesRemount.test.tsx +++ b/ui/desktop/src/components/ChatInput.queueSurvivesRemount.test.tsx @@ -38,18 +38,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -168,7 +161,6 @@ function Composer({ droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> diff --git a/ui/desktop/src/components/ChatInput.references.test.tsx b/ui/desktop/src/components/ChatInput.references.test.tsx index 861fd8761..898c97c42 100644 --- a/ui/desktop/src/components/ChatInput.references.test.tsx +++ b/ui/desktop/src/components/ChatInput.references.test.tsx @@ -34,20 +34,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ - BottomMenuSkillSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); @@ -106,7 +97,6 @@ const renderComposer = ( droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> @@ -260,7 +250,6 @@ describe('the composer recognises a command with a reference attached', () => { droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.splitPaneSend.test.tsx b/ui/desktop/src/components/ChatInput.splitPaneSend.test.tsx index 322559f13..cc0580ebc 100644 --- a/ui/desktop/src/components/ChatInput.splitPaneSend.test.tsx +++ b/ui/desktop/src/components/ChatInput.splitPaneSend.test.tsx @@ -43,18 +43,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -126,7 +119,6 @@ function composerFor(spec: PaneSpec, handleSubmit: SubmitMock) { droppedFiles={spec.droppedFiles ?? []} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.startFailureRestore.test.tsx b/ui/desktop/src/components/ChatInput.startFailureRestore.test.tsx index 89b27e8a0..baca2308b 100644 --- a/ui/desktop/src/components/ChatInput.startFailureRestore.test.tsx +++ b/ui/desktop/src/components/ChatInput.startFailureRestore.test.tsx @@ -57,18 +57,11 @@ vi.mock('./ModelAndProviderContext', () => ({ }), })); vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MessageQueue', () => ({ default: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); @@ -171,7 +164,6 @@ function Surface({ accumulatedInputTokens={0} accumulatedOutputTokens={0} messagesLength={0} - disableAnimation={false} toolCount={0} onWorkingDirChange={vi.fn()} /> diff --git a/ui/desktop/src/components/ChatInput.steerBrowserSurface.test.tsx b/ui/desktop/src/components/ChatInput.steerBrowserSurface.test.tsx index 79fcccbba..734eab0bc 100644 --- a/ui/desktop/src/components/ChatInput.steerBrowserSurface.test.tsx +++ b/ui/desktop/src/components/ChatInput.steerBrowserSurface.test.tsx @@ -51,18 +51,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -149,7 +142,6 @@ function renderComposer(onSteer: (text: string) => Promise) { droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> @@ -258,7 +250,6 @@ describe('the queue explains the steer it cannot perform, on a browser surface', droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> diff --git a/ui/desktop/src/components/ChatInput.steerRefusedKeepsPlace.test.tsx b/ui/desktop/src/components/ChatInput.steerRefusedKeepsPlace.test.tsx index b5645f773..745e3eafa 100644 --- a/ui/desktop/src/components/ChatInput.steerRefusedKeepsPlace.test.tsx +++ b/ui/desktop/src/components/ChatInput.steerRefusedKeepsPlace.test.tsx @@ -46,18 +46,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -143,7 +136,6 @@ function renderComposer({ droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> diff --git a/ui/desktop/src/components/ChatInput.steerShortcut.test.tsx b/ui/desktop/src/components/ChatInput.steerShortcut.test.tsx index 4cff774fd..a07401efb 100644 --- a/ui/desktop/src/components/ChatInput.steerShortcut.test.tsx +++ b/ui/desktop/src/components/ChatInput.steerShortcut.test.tsx @@ -38,18 +38,11 @@ vi.mock('./ModelAndProviderContext', () => ({ vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }), })); -vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); -vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ - BottomMenuExtensionSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ BottomMenuSkillSelection: () => null })); -vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ - BottomMenuKnowledgeSelection: () => null, -})); -vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ - BottomMenuReasoningEffort: () => null, -})); -vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +// The pickers and the footer are WS-PICKERS' modules, mocked by path; their +// insides are tested where they live. +vi.mock('./bottom_menu/ToolsChip', () => ({ ToolsChip: () => null })); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ ModelEffortChip: () => null })); +vi.mock('./bottom_menu/ComposerFooter', () => ({ ComposerFooter: () => null })); vi.mock('./MentionPopover', () => { const MentionPopoverMock = React.forwardRef(() => null); MentionPopoverMock.displayName = 'MentionPopoverMock'; @@ -140,7 +133,6 @@ function renderComposer({ droppedFiles={[]} onFilesProcessed={vi.fn()} messagesLength={2} - disableAnimation sessionCosts={undefined} toolCount={0} /> diff --git a/ui/desktop/src/components/ChatInput.structure.test.tsx b/ui/desktop/src/components/ChatInput.structure.test.tsx new file mode 100644 index 000000000..3be30cda1 --- /dev/null +++ b/ui/desktop/src/components/ChatInput.structure.test.tsx @@ -0,0 +1,406 @@ +/** + * The composer's shape (spec 3.7, owner message 8): ONE card holding the text + * and its controls, and ONE muted footer line under it. + * + * card [queue] [notices] [chips] text + * [+] [Tools] [model · effort] (Send) + * footer folder ring cost + * + * The pickers inside the controls row and the footer's insides belong to + * `bottom_menu/` (WS-PICKERS); they are stubbed by path here, so these tests + * are about where the composer puts things, not what the pickers draw. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ + getProviders: vi.fn(async () => []), + read: vi.fn(async () => null), + }), +})); +const model = vi.hoisted(() => ({ supportsVision: true })); +vi.mock('./ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + getCurrentModelAndProvider: vi.fn(async () => ({ model: null, provider: null })), + currentModel: 'gpt-5.6-sol', + currentProvider: 'versa_azure', + modelConfigStatus: 'ready', + currentModelSupportsVision: model.supportsVision, + currentModelSupportedInputMimeTypes: null, + }), +})); +vi.mock('../hooks/useDiverge', () => ({ useDiverge: () => ({ diverge: vi.fn() }) })); +const modelMenuOpened = vi.hoisted(() => vi.fn()); +vi.mock('./bottom_menu/ToolsChip', () => ({ + ToolsChip: () => , +})); +vi.mock('./bottom_menu/ModelEffortChip', () => ({ + ModelEffortChip: () => ( + + ), +})); +vi.mock('./bottom_menu/ComposerFooter', () => ({ + ComposerFooter: ({ + sessionId, + workingDirLocked, + }: { + sessionId: string | null; + workingDirLocked?: boolean; + }) => ( +
    + ), +})); +vi.mock('./MessageQueue', () => ({ default: () => null })); +vi.mock('./MentionPopover', () => { + const MentionPopoverMock = React.forwardRef(() => null); + MentionPopoverMock.displayName = 'MentionPopoverMock'; + return { default: MentionPopoverMock }; +}); +vi.mock('../api', () => ({ + getSession: vi.fn(async () => ({ data: null })), + llamacppStatus: vi.fn(async () => ({ data: {} })), + updateWorkingDir: vi.fn(async () => ({ data: {} })), +})); + +import ChatInput from './ChatInput'; +import { ChatState } from '../types/chatState'; +import { COMPOSER_COPY } from './composer/copy'; +import { resetAnnotationChannelForTests, sendArtifactAnnotation } from '../utils/annotationChannel'; + +beforeEach(() => { + vi.clearAllMocks(); + resetAnnotationChannelForTests(); + model.supportsVision = true; + Object.assign(window, { + appConfig: { get: () => '/Users/wgu/Desktop' }, + electron: { + directoryChooser: vi.fn(), + addRecentDir: vi.fn(), + logInfo: vi.fn(), + getPathForFile: vi.fn((file: File) => `/data/${file.name}`), + on: vi.fn(), + off: vi.fn(), + saveDataUrlToTemp: vi.fn(async (_dataUrl: string, id: string) => ({ + id, + filePath: `/tmp/biorouter-test/${id}.png`, + })), + readTempImageAsBase64: vi.fn(async () => ({ data: 'AAAA', mimeType: 'image/png' })), + deleteTempFile: vi.fn(), + }, + }); +}); + +function composerElement(overrides: Partial> = {}) { + return ( + + ); +} + +function renderComposer( + overrides: Partial> = {} +): ReturnType { + return render(composerElement(overrides)); +} + +const card = () => document.querySelector('.biorouter-composer-card') as HTMLElement; +const sendButton = () => screen.getByRole('button', { name: COMPOSER_COPY.send.label }); + +describe('the composer is one card and one footer line', () => { + it('holds the text and every control inside the card, Send last', () => { + renderComposer(); + const controls = within(card()).getByTestId('chat-input-toolbar'); + expect(within(card()).getByTestId('chat-input')).toBeInTheDocument(); + + const order = [ + within(controls).getByTestId('composer-plus'), + within(controls).getByTestId('tools-chip-stub'), + within(controls).getByTestId('model-chip-stub'), + within(controls).getByRole('button', { name: COMPOSER_COPY.send.label }), + ]; + for (let i = 1; i < order.length; i++) { + expect( + order[i - 1].compareDocumentPosition(order[i]) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + } + }); + + it('puts the footer line under the card, outside it', () => { + renderComposer(); + const footer = screen.getByTestId('composer-footer'); + expect(card().contains(footer)).toBe(false); + expect(card().compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('has no rows above the card any more', () => { + renderComposer(); + expect(screen.queryByTestId('composer-context-banner')).toBeNull(); + expect(card().previousElementSibling).toBeNull(); + }); + + it('is the page ground with a resting hairline, not a filled box (R-32)', () => { + renderComposer(); + const classes = card().classList; + expect(classes.contains('bg-background-default')).toBe(true); + // One 1px hairline at rest: the focus and working edges turn it the accent. + expect(classes.contains('border')).toBe(true); + expect(classes.contains('border-border-subtle/60')).toBe(true); + expect(classes.contains('border-transparent')).toBe(false); + // No grey fill, and not a filled box: what sits in it rests as on any page. + expect(classes.contains('bg-background-fill')).toBe(false); + expect(classes.contains('br-fill-surface')).toBe(false); + }); + + it('uses no monospace anywhere in the composer', () => { + const { container } = renderComposer(); + expect(container.querySelector('.font-mono')).toBeNull(); + }); + + it('tells the footer whether there is a chat, so Home shows no ring or cost', () => { + renderComposer({ sessionId: null }); + expect(screen.getByTestId('composer-footer')).toHaveAttribute('data-session', 'none'); + }); +}); + +describe('the text field', () => { + it('has a name that does not change with the placeholder', () => { + renderComposer({ messagesLength: 3 }); + expect(screen.getByRole('textbox', { name: COMPOSER_COPY.inputLabel })).toBe( + screen.getByTestId('chat-input') + ); + }); +}); + +describe('placeholders', () => { + it('asks the open question in an empty chat', () => { + renderComposer({ messagesLength: 0 }); + expect(screen.getByPlaceholderText(COMPOSER_COPY.placeholderEmpty)).toBeInTheDocument(); + }); + + it('invites a follow-up in a chat with messages, never a shortcut hint', () => { + renderComposer({ messagesLength: 3 }); + expect(screen.getByPlaceholderText(COMPOSER_COPY.placeholderFollowUp)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/navigate messages/)).toBeNull(); + }); +}); + +describe('followUp: the placeholder for a message the caller already shows', () => { + // A new chat's first send draws the person's message before the store holds it + // (summary/useFirstSend.ts), so for about 0.7 s the transcript has a message + // and `messagesLength` is still 0. + it('invites a follow-up while the store is still empty', () => { + renderComposer({ messagesLength: 0, followUp: true }); + expect(screen.getByPlaceholderText(COMPOSER_COPY.placeholderFollowUp)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(COMPOSER_COPY.placeholderEmpty)).toBeNull(); + }); + + it('changes once, with the message on screen, and not again when the store catches up', () => { + const { rerender } = renderComposer({ messagesLength: 0, followUp: false }); + const field = screen.getByTestId('chat-input') as HTMLTextAreaElement; + const seen = [field.placeholder]; + // The send: the row is drawn, the store is not yet holding it. + rerender(composerElement({ messagesLength: 0, followUp: true })); + seen.push(field.placeholder); + // The store's message lands under the same stated word. + rerender(composerElement({ messagesLength: 1, followUp: true })); + seen.push(field.placeholder); + expect(seen).toEqual([ + COMPOSER_COPY.placeholderEmpty, + COMPOSER_COPY.placeholderFollowUp, + COMPOSER_COPY.placeholderFollowUp, + ]); + }); + + it('wins over the length when it is stated, in both directions', () => { + const { unmount } = renderComposer({ messagesLength: 3, followUp: false }); + expect(screen.getByPlaceholderText(COMPOSER_COPY.placeholderEmpty)).toBeInTheDocument(); + unmount(); + renderComposer({ messagesLength: 0, followUp: true }); + expect(screen.getByPlaceholderText(COMPOSER_COPY.placeholderFollowUp)).toBeInTheDocument(); + }); + + it('is the placeholder and nothing else: the working folder stays unlocked', () => { + // `workingDirIsLocked` falls back to `messagesLength > 0`; a drawn row that the + // store does not hold must not lock the folder of a chat that has no message. + renderComposer({ messagesLength: 0, followUp: true }); + expect(screen.getByTestId('composer-footer')).toHaveAttribute('data-locked', 'false'); + cleanup(); + renderComposer({ messagesLength: 2 }); + expect(screen.getByTestId('composer-footer')).toHaveAttribute('data-locked', 'true'); + }); +}); + +describe('Send and Stop', () => { + it('is quiet while empty and the accent once there is something to send', () => { + renderComposer(); + // The filled primitive's ground (R-27): on the card's page ground it rests + // on the fill, as on any page. + expect(sendButton().className).toContain('br-fill-rest'); + fireEvent.change(screen.getByTestId('chat-input'), { target: { value: 'hello' } }); + expect(sendButton().className).toContain('bg-background-accent'); + }); + + it('swaps to a Stop with the same geometry and no native title or ping', () => { + renderComposer({ chatState: ChatState.Streaming }); + const stop = screen.getByTestId('chat-stop-button'); + expect(stop).toHaveAccessibleName(COMPOSER_COPY.stop.label); + expect(stop).not.toHaveAttribute('title'); + expect(stop.className).toContain('h-control-md'); + expect(stop.querySelector('.animate-ping')).toBeNull(); + }); + + it('shows a spinner in Send while a chat is being started, not a Stop', () => { + renderComposer({ sessionId: null, chatState: ChatState.LoadingConversation }); + expect(screen.queryByTestId('chat-stop-button')).toBeNull(); + expect(sendButton()).toHaveAttribute('aria-busy', 'true'); + expect(sendButton().querySelector('.br-spinner')).not.toBeNull(); + }); +}); + +describe('the + menu', () => { + it('offers attach, mention and commands', async () => { + const user = userEvent.setup(); + renderComposer(); + await user.click(screen.getByRole('button', { name: COMPOSER_COPY.plus.label })); + expect( + await screen.findByRole('menuitem', { name: COMPOSER_COPY.plus.attachFile }) + ).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^Mention/ })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /^Commands/ })).toBeInTheDocument(); + }); + + it('starts a mention at the caret', async () => { + const user = userEvent.setup(); + renderComposer(); + const box = screen.getByTestId('chat-input') as HTMLTextAreaElement; + fireEvent.change(box, { target: { value: 'compare' } }); + await user.click(screen.getByRole('button', { name: COMPOSER_COPY.plus.label })); + await user.click(await screen.findByRole('menuitem', { name: /^Mention/ })); + await waitFor(() => expect(box.value).toBe('compare @')); + }); + + it('starts a command at the start of a word', async () => { + const user = userEvent.setup(); + renderComposer(); + const box = screen.getByTestId('chat-input') as HTMLTextAreaElement; + await user.click(screen.getByRole('button', { name: COMPOSER_COPY.plus.label })); + await user.click(await screen.findByRole('menuitem', { name: /^Commands/ })); + await waitFor(() => expect(box.value).toBe('/')); + }); + + it('attaches a picked file the way a drop does', async () => { + renderComposer(); + const input = screen.getByTestId('composer-file-input') as HTMLInputElement; + const file = new File(['a,b'], 'cohort.csv', { type: 'text/csv' }); + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + expect(await screen.findByTestId('composer-file-chip')).toHaveTextContent('cohort.csv'); + }); +}); + +describe('attachments', () => { + it('sit in one chips row above the text', async () => { + renderComposer({ + droppedFiles: [ + { + id: 'f1', + path: '/data/results.csv', + sourcePath: '/data/results.csv', + name: 'results.csv', + type: 'text/csv', + isImage: false, + canUploadAsImage: false, + isLoading: false, + }, + ], + }); + const chips = await screen.findByTestId('composer-chips'); + const text = screen.getByTestId('chat-input'); + expect(chips.compareDocumentPosition(text) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(chips).getByTestId('composer-file-chip')).toHaveTextContent('results.csv'); + }); + + it('draws a loading image under a translucent scrim, never solid black', async () => { + renderComposer({ + droppedFiles: [ + { + id: 'img1', + path: '/data/plot.png', + sourcePath: '/data/plot.png', + name: 'plot.png', + type: 'image/png', + isImage: true, + canUploadAsImage: true, + dataUrl: 'data:image/png;base64,AAAA', + isLoading: true, + }, + ], + }); + const thumb = await screen.findByTestId('composer-image-thumb'); + expect(thumb.querySelector('.br-composer-thumb__scrim')).not.toBeNull(); + expect(thumb.querySelector('.br-spinner')).not.toBeNull(); + expect(thumb.innerHTML).not.toMatch(/bg-black|bg-opacity/); + }); +}); + +describe('notices inside the card', () => { + it('says the model cannot read images in one line, with a way to switch model', async () => { + model.supportsVision = false; + renderComposer(); + // A region from the preview panel stages an image whatever the model can + // read, which is how an image meets a model that cannot read it. + await act(async () => { + sendArtifactAnnotation({ + sessionId: 'chat-1', + imagePath: '/tmp/biorouter-test/region.png', + sourceTitle: 'figure.html', + region: { x: 0, y: 0, width: 10, height: 10, surfaceWidth: 100, surfaceHeight: 100 }, + width: 10, + height: 10, + }); + }); + + const notice = await screen.findByTestId('composer-vision-notice'); + expect(card().contains(notice)).toBe(true); + expect(notice).toHaveTextContent(COMPOSER_COPY.vision.banner); + // The explanation lives in the help, not in a paragraph. + expect( + within(notice).getByRole('button', { name: `About ${COMPOSER_COPY.vision.banner}` }) + ).toHaveAccessibleDescription(COMPOSER_COPY.vision.help); + expect(sendButton()).toBeDisabled(); + + fireEvent.click(within(notice).getByRole('button', { name: COMPOSER_COPY.vision.action })); + expect(modelMenuOpened).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 32a8586f9..be346ca48 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1,26 +1,25 @@ import { onQuotedText, quoteReference } from '../utils/quotedText'; import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'; import { annotationContextText, onArtifactAnnotation } from '../utils/annotationChannel'; -import { ArrowUp, ChevronsDownUp, Plus, X } from './icons/app-icons'; -import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; -import { useComposerToolbarCollapsed } from './bottom_menu/useComposerToolbarCollapsed'; -import { ContextWindowIndicator } from './ContextWindowIndicator'; +import { ArrowUp, EyeOff } from './icons/app-icons'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Button } from './ui/button'; import type { View, ViewOptions } from '../utils/navigationUtils'; import Stop from './ui/Stop'; import { ChatState } from '../types/chatState'; -import debounce from 'lodash/debounce'; import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { DirSwitcher } from './bottom_menu/DirSwitcher'; -import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; -import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection'; -import { BottomMenuSkillSelection } from './bottom_menu/BottomMenuSkillSelection'; -import { BottomMenuKnowledgeSelection } from './bottom_menu/BottomMenuKnowledgeSelection'; +import { ToolsChip } from './bottom_menu/ToolsChip'; +import { useSessionWorkingDir } from './bottom_menu/useSessionWorkingDir'; +import { ModelEffortChip } from './bottom_menu/ModelEffortChip'; +import { ComposerFooter } from './bottom_menu/ComposerFooter'; +import { contextWindowModelKey } from './ContextWindowIndicator'; import { draftReasoningScope, sessionReasoningScope } from '../store/reasoningEffort'; -import { BottomMenuReasoningEffort } from './bottom_menu/BottomMenuReasoningEffort'; -import { AlertType, useAlerts } from './alerts'; -import { toolCountWarning } from './alerts/toolCountWarning'; +import { InfoTip } from './ui/info-tip'; +import { Spinner } from './ui/spinner'; +import { ComposerChips } from './composer/ComposerChips'; +import { composerCardClassName } from './composer/composerCard'; +import { mentionMenuAnchor } from './composer/mentionMenuPlacement'; +import { ComposerPlusMenu, type ComposerTrigger } from './composer/ComposerPlusMenu'; import { useConfig } from './ConfigContext'; import { useModelAndProvider } from './ModelAndProviderContext'; import { @@ -29,8 +28,6 @@ import { hasNoModelConfigured, } from './composerNoProvider'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; -import { COST_TRACKING_ENABLED } from '../updates'; -import { CostTracker } from './bottom_menu/CostTracker'; import type { ModelCostRow } from '../hooks/useCostTracking'; import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; import { useDiverge } from '../hooks/useDiverge'; @@ -42,9 +39,9 @@ import { getSession, Message } from '../api'; import { userActionHeaders } from '../utils/userAction'; import { liveContextWindow } from '../utils/liveContextWindow'; import type { SessionClassification } from '../api/types.gen'; -import { getInitialWorkingDir } from '../utils/workingDir'; import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils'; -import { getNavigationShortcutText, getSteerShortcutText } from '../utils/keyboardShortcuts'; +import { getSteerShortcutText } from '../utils/keyboardShortcuts'; +import { COMPOSER_COPY } from './composer/copy'; import type { UserAttachment } from '../types/message'; import { useStopAcknowledgement } from '../hooks/useStopAcknowledgement'; import { isRunningState, type PinnedModelView } from '../hooks/chatStreamStore'; @@ -90,7 +87,6 @@ import { subscribeQueuedOfferReturns, type QueuedMessage, } from '../utils/composerQueues'; -import { CommandChip, ResourceRefChip } from './ResourceRefChip'; /** * Queued messages as a draft, for a composer with no chat to keep a queue for. @@ -174,7 +170,7 @@ function draftFilesOf(files: readonly DroppedFile[]): DroppedFile[] { const MAX_IMAGES_PER_MESSAGE = 5; const MAX_IMAGE_SIZE_MB = 3; -// Constants for token and tool alerts +// The context window assumed until the model's own is known. const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about // Manual compact trigger message - must match backend constant @@ -200,62 +196,6 @@ const DIVERGE_TRIGGER = '/diverge'; * which is reported and left QUEUED for the next drain rather than spun on. */ const QUEUE_DRAIN_ATTEMPTS = 3; -/** - * The composer's controls are grouped by ROW, and the rows are not boxes. - * - * Three arrangements have been tried. A single card holding everything read as - * a settings panel you could also type into. A recessed banner tucked behind a - * card grouped the controls correctly but paid two outlines and an 8px overlap - * for it. One card of three rows fixed that, and then made the card so tall and - * so full that "crowded" was the note two rounds running. - * - * What ships now inverts it: the CARD IS THE INPUT, and nothing else. The - * context row floats above it and the control row below it, both directly on - * the canvas, so the only boxed thing on screen is the one thing the user acts - * on. The grouping that needed a rule, then a tuck, then a hairline is now done - * by the canvas gaps alone. - * - * Chips inside one cluster sit at 8px. They were at 2px when the strip was its - * own recessed surface and 4px when it was a card row; on bare canvas there is - * no fill or edge left to hold them together, so the spacing has to do it, and - * a chip cluster that is merely NOT touching reads as a cramped toolbar. - */ -const TOOLBAR_GROUP_CLASS = 'flex flex-shrink-0 items-center gap-2'; - -/* - * ═══════════════════════════════════════════════════════════════════════════ - * THE RAILS' TYPE IS `text-supporting` (12/16), AND THAT OVERRIDES A RULE. - * ═══════════════════════════════════════════════════════════════════════════ - * - * This governs the controls in row 1 and row 3 — the directory, the extension / - * skill / knowledge counts, the reasoning knob, the model, the context gauge's - * figure, the cost. They live in their own files (`bottom_menu/*`, - * `ContextWindowIndicator`), every one of which is composer-only, so the change - * is scoped even though it is spread. Each carries a pointer back here. - * - * ⚠ THE RULE THIS SUBORDINATES, so nobody "fixes" it back: `main.css`'s type - * scale states the sanctioned dense-control exception is `--text-secondary` - * (13px) and says in terms — "It does NOT drop to `text-supporting`; a control - * that renders at metadata size stops looking like something you can press." - * These controls were raised 12 -> 13 earlier for exactly that reason. They are - * now back at 12 at the USER'S EXPLICIT DIRECTION, with a stated goal: the rails - * should "read as annotations rather than competing UI" against the message - * text. That is a call about this surface's hierarchy, and it outranks a - * default. - * - * The ratio is the part to preserve if the body size ever moves. The spec asked - * for rails one clear step under a 15px body; our body is `--text-body` 14px - * (and the composer's input deliberately shares that role, so what you type and - * what it becomes are one size). One step under 14 on our scale is 12 — - * `text-supporting` — which also lands inside the spec's own 11.5-12.5 band. - * Do not import 15px, and do not invent a half-pixel role to hit it exactly. - * - * What answers the rule's actual objection — "stops looking like something you - * can press" — is that pressability here is not carried by type size at all. - * Every one of these keeps a 28px (`h-7`) hit area, a hover fill, and an icon or - * caret. The type shrank; the affordance did not. - */ - /** * The composer's input type: `--text-body`, 14 on a 20px line — THE SAME ROLE * the transcript sets message text in. @@ -280,39 +220,6 @@ const TOOLBAR_GROUP_CLASS = 'flex flex-shrink-0 items-center gap-2'; */ const COMPOSER_INPUT_TYPE_CLASS = 'text-body'; -/** - * The control rail's ONE separator, between the reasoning knob and the model. - * - * This is not the old toolbar vocabulary returning. That row carried several - * rules across eight controls, which is what made a strip of chips read as a - * control panel; this one separates the two settings that answer the same - * question — "how does the next message get answered" — from each other, and it - * is the only separator inside any row of the composer. - * - * A 3px DOT, not a 14px rule, and the change is not cosmetic. A vertical rule - * is a piece of RULING: it runs the height of its neighbours, so it reads as - * the beginning of a column and invites more of itself — which is exactly how - * the row got to eight controls and several rules the first time. A dot has no - * height to align to. It says "these are two groups" and nothing else, which is - * all this seam ever needed, and it cannot accrete into a grid. - * - * IT IS NOT A BORDER TOKEN, and that is the part that had to be measured rather - * than assumed. Contrast is area-dependent: the old rule laid 14 px of ink on - * the canvas, a 3px dot lays about 7, so a token that read as a hairline reads - * as dirt as a dot. Against `--background-canvas`, across all six scopes: - * - * --border-subtle 1.27 light / 1.39 dark invisible as a dot - * --border-strong 1.52 / 1.71 still washed out - * text-muted/55 2.37-2.48 / 3.01-3.62 present, clearly subordinate - * --text-muted (full) 6.11-6.89 / 7.37-9.41 darker than what it divides - * - * The rail's own labels measure 5.71-6.19 light / 8.08-8.55 dark, so `/55` puts - * the dot at roughly 40% of the ink it separates: enough to be seen on purpose, - * never enough to be read as content. A separator that outweighs its neighbours - * — which the full token does — is just punctuation shouting. - */ -const TOOLBAR_DIVIDER_CLASS = 'size-[3px] flex-shrink-0 rounded-full bg-text-muted/55'; - function canonicalMimeType(mimeType: string): string { const normalized = mimeType.toLowerCase().trim(); return normalized === 'image/jpg' ? 'image/jpeg' : normalized; @@ -416,6 +323,23 @@ interface ChatInputProps { * identity changes per event, the length almost never does). */ messagesLength?: number; + /** + * Whether the placeholder invites a follow-up ("Ask a follow-up") rather than + * the open question ("Ask Biorouter anything…"). Presentation only: it reads + * nothing else and is read by nothing else. + * + * Absent, it follows `messagesLength > 0`, which is what every caller that does + * not track a provisional row gets. A caller that DRAWS a message before the + * store holds it (a new chat's first send, `summary/useFirstSend.ts`) states it + * here, so the placeholder changes once with what the person sees and not 0.7 s + * later for no event they can see. + * + * ⚠ Never fold that provisional row into `messagesLength` instead: that prop + * also arms the turn tier probe (`turnTierProbeRef`, a privacy read's timing) + * and is `workingDirIsLocked`'s fallback, and neither may move for a row the + * store does not hold yet. + */ + followUp?: boolean; /** * #44 — authoritative working-dir lock, derived by the owner of the session * metadata (BaseChat, via `deriveWorkingDirLocked`). `messagesLength` alone @@ -434,7 +358,6 @@ interface ChatInputProps { }; /** Real per-model usage rows from the token ledger (Issue #1 breakdown). */ modelCostRows?: ModelCostRow[]; - disableAnimation?: boolean; workflow?: Workflow | null; workflowAccepted?: boolean; initialPrompt?: string; @@ -483,13 +406,12 @@ export default function ChatInput({ accumulatedInputTokens, accumulatedOutputTokens, messagesLength = 0, + followUp, workingDirLocked, - disableAnimation = false, sessionCosts, modelCostRows, workflowAccepted, initialPrompt, - toolCount, append: _append, onWorkingDirChange, supportsVisionOverride, @@ -568,10 +490,6 @@ export default function ChatInput({ const lastInterruptionRef = useRef(lastInterruption); lastInterruptionRef.current = lastInterruption; - const { alerts, addAlert, clearAlerts } = useAlerts(); - const dropdownRef: React.RefObject = useRef( - null - ) as React.RefObject; const { getProviders, read } = useConfig(); const { getCurrentModelAndProvider, @@ -592,40 +510,33 @@ export default function ChatInput({ : globalSupportedInputMimeTypes; const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); - // Which `loadProviderDetails` call may still write the gauge: only the - // newest. See the comment there. + // Counts window lookups so only the newest one may settle (see loadProviderDetails). const tokenLimitRequestRef = useRef(0); + // The chat's state one render ago, so the idle re-read below fires once per + // finished turn rather than on every render. const previousGaugeChatStateRef = useRef(chatState); - const [sessionWorkingDir, setSessionWorkingDir] = useState(null); + // The model the footer ring must measure for now: this chat's binding, else the + // app's selection. `tokenLimitModel` is the model the window in `tokenLimit` + // was looked up for. The ring draws a window only when the two agree, and + // nothing while the chat loads (V2-R4-1, round 5): until the row lands there is + // no binding, and the lookup meanwhile measures the model for new chats. + const contextModel = contextWindowModelKey(effectiveModel, currentProvider, currentModel); + const [tokenLimitModel, setTokenLimitModel] = useState(null); + // V2-R8-3. This composer's lasting identity: the same from an empty chat to + // the chat its first message starts. That message REMOUNTS the composer + // (`BaseChat` draws it centred in an empty chat and in the bar under a + // transcript), so what the Tools chip, the model chip and the folder state is + // handed to the new mount under this key (`composer/composerHandOff.ts`). + // `draftKey` comes second: `BaseChat` re-keys it to the chat once one exists. + const handOffKey = reasoningDraftKey ?? draftKey; + // The footer's folder: this chat's own, never the app default standing in + // for it while the row loads (V2-R7-3; see the hook). + const { workingDir: composerWorkingDir, setWorkingDir: setComposerWorkingDir } = + useSessionWorkingDir(sessionId, handOffKey); // Branch-the-conversation action shared with the message-level Diverge button. const { diverge } = useDiverge(); - useEffect(() => { - if (!sessionId) { - return; - } - - const fetchSessionWorkingDir = async () => { - try { - const response = await getSession({ - path: { session_id: sessionId }, - // The row, not the transcript: `working_dir` is all this reads. - query: { metadata_only: true }, - // Issue #56 Task 58: reading a private chat needs the proof-of-user. - headers: await userActionHeaders(), - }); - if (response.data?.working_dir) { - setSessionWorkingDir(response.data.working_dir); - } - } catch (error) { - console.error('[ChatInput] Failed to fetch session working dir:', error); - } - }; - - fetchSessionWorkingDir(); - }, [sessionId]); - /** * The chat's privacy tier, for the two composer surfaces that need it * (issue #56, §14.2 / §14.5): the model chip's dot and the extension @@ -678,8 +589,8 @@ export default function ChatInput({ const response = await getSession({ path: { session_id: sessionId }, // The row, not the transcript: `privacy_tier` is all this reads, and the - // same query as the working-directory read above, so a mount's two reads - // share one request (`utils/sessionReadCoalescing.ts`). + // same query as the folder's read (`useSessionWorkingDir`), so a mount's + // two reads share one request (`utils/sessionReadCoalescing.ts`). query: { metadata_only: true }, // Issue #56 Task 58: and this read is *about* the tier, so it is // exactly the read the gate refuses without the header. @@ -990,8 +901,8 @@ export default function ChatInput({ prev.some((queued) => queued.id === message.id) ? prev : [message, ...prev] ); toastWarning({ - title: 'Message still queued', - msg: 'Biorouter could not send it while the chat was busy. It is still in the queue, ready to send.', + title: COMPOSER_COPY.toasts.queued.title, + msg: COMPOSER_COPY.toasts.queued.busy, }); }); }; @@ -1108,11 +1019,6 @@ export default function ChatInput({ const [hasUserTyped, setHasUserTyped] = useState(() => Boolean(seedDraft?.text)); const textAreaRef = useRef(null); const timeoutRefsRef = useRef>>(new Set()); - // Rung 3b of the yield ladder: when the composer's control row is too narrow - // to lay every picker out, they collapse behind a single "+" at the lower-left - // instead of overlapping. Measured on the toolbar's own box (see the hook). - const toolbarRef = useRef(null); - const composerToolbarCollapsed = useComposerToolbarCollapsed(toolbarRef); // A region the user selected in the preview panel arrives here as an already // written PNG. It joins `pastedImages` rather than getting a channel of its @@ -1131,8 +1037,8 @@ export default function ChatInput({ if (pastedImagesRef.current.length >= MAX_IMAGES_PER_MESSAGE) { window.electron.deleteTempFile(annotation.imagePath); toastWarning({ - title: 'Region not attached', - msg: `A message can contain at most ${MAX_IMAGES_PER_MESSAGE} images.`, + title: COMPOSER_COPY.toasts.region.title, + msg: COMPOSER_COPY.toasts.region.tooMany(MAX_IMAGES_PER_MESSAGE), }); return; } @@ -1171,8 +1077,8 @@ export default function ChatInput({ window.electron.deleteTempFile(annotation.imagePath); setPastedImages((current) => current.filter((image) => image.id !== id)); toastWarning({ - title: 'Region not attached', - msg: 'The selected region could not be read.', + title: COMPOSER_COPY.toasts.region.title, + msg: COMPOSER_COPY.toasts.region.unreadable, }); }); textAreaRef.current?.focus(); @@ -1446,7 +1352,7 @@ export default function ChatInput({ setPastedImages((prev) => prev.map((img) => img.id === imageId - ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } + ? { ...img, error: COMPOSER_COPY.imageErrors.saveFailed, isLoading: false } : img ) ); @@ -1485,19 +1391,23 @@ export default function ChatInput({ // Load providers and get current model's token limit const loadProviderDetails = async () => { - // Every call is one model's answer, and a model switch starts another - // before this one settles: the Ollama branch below waits on the daemon's - // `/api/show` probe of `OLLAMA_HOST`, up to 15 s against a host that drops - // packets. The old answer then landed last and the gauge measured the new - // model's usage against the OLD model's window — the mismatch the Gate B - // note below exists to prevent. So each call writes only while it is the - // newest, checked after every await. + // Only the newest lookup may settle the window, checked after every await. + // The composer mounts before the chat's row names its binding (run 1 + // measures the app's selected model, run 2 the chat's pinned one), a person + // can change model twice quickly, and the Ollama branch of + // `liveContextWindow` waits on the daemon's `/api/show` probe of + // `OLLAMA_HOST`, up to 15 s against a host that drops packets. An older run + // that finishes later is about a model this chat is no longer bound to, and + // the footer ring draws whatever window is marked loaded. const request = ++tokenLimitRequestRef.current; const current = () => request === tokenLimitRequestRef.current; - const setLimit = (limit: number, loaded: boolean) => { + // The model this lookup answers for, from the render that started it. + const forModel = contextModel; + const settle = (limit: number, loaded: boolean) => { if (!current()) return; setTokenLimit(limit); setIsTokenLimitLoaded(loaded); + setTokenLimitModel(forModel); }; try { // Reset token limit loaded state @@ -1522,7 +1432,7 @@ export default function ChatInput({ // chip correctly saying no model was chosen — a precise figure for a // model that does not exist. Zero is what the gauge renders its empty // state from. - setLimit(0, false); + settle(0, false); return; } @@ -1533,7 +1443,7 @@ export default function ChatInput({ const live = await liveContextWindow(provider, model); if (!current()) return; if (live !== null) { - setLimit(live, true); + settle(live, true); return; } @@ -1541,7 +1451,7 @@ export default function ChatInput({ const predefinedModels = getPredefinedModelsFromEnv(); const predefinedModel = predefinedModels.find((m) => m.name === model); if (predefinedModel?.context_limit) { - setLimit(predefinedModel.context_limit, true); + settle(predefinedModel.context_limit, true); return; } @@ -1554,7 +1464,7 @@ export default function ChatInput({ // Find the model's token limit from the backend response const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model); if (modelConfig?.context_limit) { - setLimit(modelConfig.context_limit, true); + settle(modelConfig.context_limit, true); return; } } @@ -1563,16 +1473,16 @@ export default function ChatInput({ const modelLimit = await getModelLimits(); const fallbackLimit = findModelLimit(model as string, modelLimit); if (fallbackLimit !== null) { - setLimit(fallbackLimit, true); + settle(fallbackLimit, true); return; } // If no match found, use the default model limit - setLimit(TOKEN_LIMIT_DEFAULT, true); + settle(TOKEN_LIMIT_DEFAULT, true); } catch (err) { console.error('Error loading providers or token limit:', err); // Set default limit on error - setLimit(TOKEN_LIMIT_DEFAULT, true); + settle(TOKEN_LIMIT_DEFAULT, true); } }; @@ -1598,42 +1508,6 @@ export default function ChatInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatState, currentProvider, effectiveModel?.provider]); - // Handle tool count alerts and token usage - useEffect(() => { - clearAlerts(); - - // Show alert when either there is registered token usage, or we know the limit - if ((totalTokens && totalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { - addAlert({ - type: AlertType.Info, - message: 'Context window', - progress: { - current: totalTokens || 0, - total: tokenLimit, - }, - showCompactButton: true, - compactButtonDisabled: !totalTokens, - onCompact: () => { - window.dispatchEvent(new CustomEvent('hide-alert-popover')); - - const customEvent = new CustomEvent('submit', { - detail: { value: MANUAL_COMPACT_TRIGGER }, - }) as unknown as React.FormEvent; - - handleSubmit(customEvent); - }, - compactIcon: , - }); - } - - // Add tool count alert if we have the data - const countWarning = toolCountWarning(toolCount, sessionId); - if (countWarning) addAlert(countWarning); - // `handleSubmit` changes with composer state; alerts should refresh from - // their metrics, not every time the submit callback is rebuilt. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [totalTokens, toolCount, tokenLimit, isTokenLimitLoaded, sessionId, addAlert, clearAlerts]); - // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { return () => { @@ -1665,42 +1539,14 @@ export default function ChatInput({ window.clearTimeout(timeoutId); }); timeouts.clear(); - - // Clear alerts to prevent memory leaks - clearAlerts(); }; - }, [clearAlerts]); - - // Ten lines, counted in the line box the composer actually renders: - // `COMPOSER_INPUT_TYPE_CLASS` is `--text-body`, which is 14 on a 20px line. - // This tracked a 24px line while the input was briefly 16px; left at 24 it - // would now mean twelve lines, which is how this figure was wrong before. - // The textarea's own vertical padding is inside the measurement - // (`scrollHeight` includes padding), so the true ceiling is a shade under ten - // — the right way to be wrong for a scroll cap. - const maxHeight = 10 * 20; + }, []); // Immediate function to update actual value - no debounce for better responsiveness const updateValue = React.useCallback((value: string) => { setValue(value); }, []); - const debouncedAutosize = useMemo( - () => - debounce((element: HTMLTextAreaElement) => { - element.style.height = '0px'; // Reset height - const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 50), - [maxHeight] - ); - - useEffect(() => { - if (textAreaRef.current) { - debouncedAutosize(textAreaRef.current); - } - }, [debouncedAutosize, displayValue]); - // Issue #65 — the composer's two views of one string. // // `displayValue` stays the whole message, reference tags included, because @@ -1712,7 +1558,7 @@ export default function ChatInput({ // // What the *textarea* binds to is the body — the message with the tags taken // out — so the user never sees ~45 characters of XML where they typed a - // sentence. The tags come back as chips in the rail below. `composerRefs` is + // sentence. The tags come back as chips in the row above it. `composerRefs` is // the parse, so a chip on screen is always a reference the agent resolves. const { body: composerBody, refs: composerRefs } = useMemo( () => splitComposerText(displayValue), @@ -1782,14 +1628,6 @@ export default function ChatInput({ [displayValue, setComposerText] ); - // Reset textarea height when the prose is empty. Keyed off the prose, not the - // whole message: a message that is nothing but a chip shows an empty box. - useEffect(() => { - if (textAreaRef.current && composerProse === '') { - textAreaRef.current.style.height = 'auto'; - } - }, [composerProse]); - const handleChange = (evt: React.ChangeEvent) => { const val = evt.target.value; let cursorPosition = evt.target.selectionStart; @@ -1844,16 +1682,12 @@ export default function ChatInput({ return; } - // Calculate position for the popover - position it above the chat input - const textAreaRect = textArea.getBoundingClientRect(); - setMentionPopover((prev) => ({ ...prev, isOpen: true, - position: { - x: textAreaRect.left, - y: textAreaRect.top, // Position at the top of the textarea - }, + // The list opens above the whole card from the text's left edge, never + // from the field's own top, which sits inside the card (V2-R5-2). + position: mentionMenuAnchor(textArea), query, mentionStart: triggerIndex, selectedIndex: 0, // Reset selection when query changes @@ -1971,7 +1805,7 @@ export default function ChatInput({ ? { ...img, dataUrl: '', - error: 'Image preview could not be decoded.', + error: COMPOSER_COPY.imageErrors.undecodable, isLoading: false, } : img @@ -1998,7 +1832,7 @@ export default function ChatInput({ setPastedImages((prev) => prev.map((img) => img.id === imageId - ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } + ? { ...img, error: COMPOSER_COPY.imageErrors.saveFailed, isLoading: false } : img ) ); @@ -2010,7 +1844,7 @@ export default function ChatInput({ setPastedImages((prev) => prev.map((img) => img.id === imageId - ? { ...img, error: 'Failed to read image file.', isLoading: false } + ? { ...img, error: COMPOSER_COPY.imageErrors.unreadable, isLoading: false } : img ) ); @@ -2022,13 +1856,6 @@ export default function ChatInput({ setPastedImages((prev) => [...prev, ...newImages]); }; - // Cleanup debounced functions on unmount - useEffect(() => { - return () => { - debouncedAutosize.cancel?.(); - }; - }, [debouncedAutosize]); - // Handlers for composition events, which are crucial for proper IME behavior const handleCompositionStart = () => { setIsComposing(true); @@ -2389,8 +2216,8 @@ export default function ChatInput({ const openCrew = useCallback(() => { if (hasCrewCommandExtras) { toastWarning({ - title: 'Draft kept', - msg: 'Remove the attached files, images, and reference chips before using /crew, or open Crew from the sidebar. Nothing was sent.', + title: COMPOSER_COPY.toasts.crewDraftKept.title, + msg: COMPOSER_COPY.toasts.crewDraftKept.message, }); return; } @@ -2400,8 +2227,8 @@ export default function ChatInput({ // keep the draft, as /diverge does. if (!sessionId) { toastWarning({ - title: 'Start the chat first', - msg: 'Send this chat a message, then type /crew to connect it to a Crew channel. To just open Crew, use the sidebar.', + title: COMPOSER_COPY.toasts.crewStartFirst.title, + msg: COMPOSER_COPY.toasts.crewStartFirst.message, }); setMentionPopover((prev) => ({ ...prev, isOpen: false })); return; @@ -2462,8 +2289,8 @@ export default function ChatInput({ if (trimmedCandidate === DIVERGE_TRIGGER) { if (!sessionId) { toastWarning({ - title: 'Start a chat first', - msg: '/diverge continues an existing conversation in a new chat. Send a message before using it.', + title: COMPOSER_COPY.toasts.divergeStartFirst.title, + msg: COMPOSER_COPY.toasts.divergeStartFirst.message, }); return; } @@ -2901,8 +2728,8 @@ export default function ChatInput({ } queuePausedRef.current = wasPaused; toastWarning({ - title: 'Message still queued', - msg: 'Biorouter could not confirm that the current turn stopped. Your message is still in the queue.', + title: COMPOSER_COPY.toasts.queued.title, + msg: COMPOSER_COPY.toasts.queued.notStopped, }); return; } @@ -2936,155 +2763,162 @@ export default function ChatInput({ } }; - // The composer's controls, defined ONCE and arranged in three places: the - // card's context strip (row 1), its control bar (row 3), and the collapsed - // "+" popover. They used to be declared inside the toolbar's IIFE, which is - // why the context strip could not exist — a control cannot be lifted out of - // the row that defines it. Hoisting them here is the whole enabling change. - // // #44: the working dir is choosable only while the chat is completely empty // (pre-session #39 path included); the first message locks it for the // session's lifetime. Prefer the authoritative lock from BaseChat (hydration- // and failed-submit-aware); fall back to the transcript length for callers // that do not track session metadata. const workingDirIsLocked = workingDirLocked ?? messagesLength > 0; - const dirSwitcher = ( - { - setSessionWorkingDir(newDir); - if (onWorkingDirChange) { - onWorkingDirChange(newDir); - } - }} - onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} - onRestartEnd={() => setChatState?.(ChatState.Idle)} - /> - ); - const extensionsSkillsKnowledge = ( - <> - - - - - ); - const reasoning = ( - - ); - const model = ( -
    - -
    - ); - const contextGauge = ( - { - handleSubmit( - new CustomEvent('submit', { - detail: { value: MANUAL_COMPACT_TRIGGER }, - }) as unknown as React.FormEvent - ); - }} - /> + + const compactNow = () => { + handleSubmit( + new CustomEvent('submit', { + detail: { value: MANUAL_COMPACT_TRIGGER }, + }) as unknown as React.FormEvent + ); + }; + + /** A file picked from the `+` menu goes down exactly the path a drop does. */ + const attachPickedFiles = (files: FileList) => { + void handleLocalDrop({ + preventDefault: () => {}, + stopPropagation: () => {}, + dataTransfer: { files, getData: () => '' }, + } as unknown as React.DragEvent); + textAreaRef.current?.focus(); + }; + + /** + * `@` or `/` at the caret, with the menu it opens, as if typed. The caret is a + * prose offset, so the trigger goes into the prose after a `/bug` chip. + */ + const insertTrigger = (trigger: ComposerTrigger) => { + const textArea = textAreaRef.current; + const start = textArea?.selectionStart ?? composerProse.length; + const end = textArea?.selectionEnd ?? start; + const before = composerProse.slice(0, start); + // A command only opens at the start of a word. + const insert = `${before && !/\s$/.test(before) ? ' ' : ''}${trigger}`; + const nextProse = `${before}${insert}${composerProse.slice(end)}`; + const caret = before.length + insert.length; + setComposerProse(nextProse); + setHasUserTyped(true); + setTimeout(() => { + const current = textAreaRef.current; + if (!current) return; + current.focus(); + current.setSelectionRange(caret, caret); + checkForMentionOrSlash(nextProse, caret, current); + }, 0); + }; + + /** + * "Switch model" in the vision notice opens the model chip's own menu, so the + * fix is one choice away and nothing navigates. + */ + const modelSlotRef = useRef(null); + const openModelMenu = () => { + const slot = modelSlotRef.current; + const trigger = + slot?.querySelector('[data-testid="model-chip"]') ?? + slot?.querySelector('button[aria-haspopup]'); + if (!trigger) return; + trigger.focus(); + // A menu trigger opens on Enter; a popover trigger opens on click. Ask the + // first way, and fall back to the second if nothing opened. + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + requestAnimationFrame(() => { + if (trigger.isConnected && trigger.getAttribute('aria-expanded') !== 'true') trigger.click(); + }); + }; + + // The one place Send and Stop swap. Stop shows only for a running turn; a + // chat that is still being created or loaded shows Send with a spinner. + const isStarting = chatState === ChatState.LoadingConversation && !hasSubmittableContent; + const showStop = isLoading && !hasSubmittableContent && !isStarting; + const sendTooltip = isStarting + ? sessionId + ? COMPOSER_COPY.send.loading + : COMPOSER_COPY.send.starting + : isAnyImageLoading + ? COMPOSER_COPY.send.waitingForImages + : isAnyDroppedFileLoading + ? COMPOSER_COPY.send.processingFiles + : chatState === ChatState.RestartingAgent + ? COMPOSER_COPY.send.restarting + : COMPOSER_COPY.send.tooltip; + + const sendOrStop = showStop ? ( + + + + + + {stopAck.acknowledged ? COMPOSER_COPY.stop.tooltipAcknowledged : COMPOSER_COPY.stop.tooltip} + + + ) : ( + + + + + + + +

    {sendTooltip}

    + {/* The steer chord, named only while a turn is running and plain Enter + would queue this message instead. The tooltip's own ink: muted ink + on the inverse ground is too faint to read. */} + {canSteer && !isAnyImageLoading && !isAnyDroppedFileLoading && ( +

    {COMPOSER_COPY.send.steerHint(getSteerShortcutText())}

    + )} +
    +
    ); - const cost = COST_TRACKING_ENABLED ? ( - - ) : null; return ( - // THE CARD IS THE INPUT. Nothing else on this surface is boxed. - // - // Three arrangements came before (see `TOOLBAR_GROUP_CLASS`), and each one - // put more inside the card than the card was about. The last of them — one - // box, three rows, a hairline under the first — grouped correctly and still - // drew the note "crowded", because a card that contains the directory, the - // extension counts, the prose, the model, the gauge, the cost and Send is a - // panel no matter how its rows are spaced. - // - // So the box is now drawn around the ONE thing the user acts on: - // - // ROW 1 context — where am I, what can I reach. (on the canvas) - // ROW 2 the card — the prose, and Send. (the only surface) - // ROW 3 controls — how it answers, what it costs. (on the canvas) - // - // Nothing separates the three but canvas: no rule, no fill, no tuck. That - // is the whole point — the chrome reads as annotation ON the input rather - // than as more contents OF it, and the input gets to be the only object - // with an edge. - // - // `gap-1.5` IS 6px, AND THE SMALLNESS IS THE DESIGN. It was 10px, and - // before that 12px, on the theory that a wide gap made the card "read as - // one object and its two neighbours as satellites". That theory was wrong - // in a way worth recording, because it is the note this round came in on: - // ~10px is also roughly what the app puts between UNRELATED blocks, so the - // rails did not read as satellites of the input — they read as three - // separate rows that happened to be stacked, "three floating strips". - // - // Proximity is the only grouping signal available on a surface with no - // fills and no rules, and it is RELATIVE, not absolute: what binds the - // group is that its internal gaps are visibly smaller than the gap to - // everything around it. So the two numbers are one decision, and neither - // can be read alone — - // - // 6px here the three rows are one object - // 28px `pt-7` on the composer bar that object is not the transcript + // ONE CARD AND ONE LINE (spec 3.7, Crew's composer recipe plus Codex's + // footer). The card holds what is being written and every control that + // shapes it: the queue ahead of it, its notices, its chips, the text, and a + // controls row ending in Send. Under it, one muted footer line says where + // the agent works and what the chat has used. // - // (see `BaseChat.tsx`, which carries the same note from the other side). - // Widening one without tightening the other just moves the looseness. - // - // The drop target stays the WRAPPER rather than the card, so a file dropped - // on the context row or the control row still attaches. + // The drop target is the WRAPPER, so a file dropped on the footer still + // attaches.
    - {/* ROW 1 — CONTEXT, ON THE CANVAS. No fill, no outline, no rule under it. - It keeps its `data-testid` because it is still the same thing to - anything looking for it — "where the agent works · what it can reach" - — even though it is no longer inside anything. - - The directory goes LEFT and the counts go RIGHT (`justify-between`) - rather than all four hugging the left edge. Four chips in a row read - as a toolbar; one chip against three across a span reads as a label - and a tally, which is what they are. - - ⚠ `pl-3` IS NOT A FREE NUMBER, AND A FLAT INSET WILL BREAK IT. A spec - round asked for both rails at a flat 4px inset, with the stated goal - "folder icon, placeholder text and the effort bars share one left - edge". That is the same goal this padding already serves, by the other - mechanism, and the flat version was measured before it was declined: - - left ink from the shell's edge row 1 / card / row 3 spread - solved optically (`pl-3`/`pl-4`/`pl-3.5`) 16 / 17 / 16 1px - flat 4px on both rails (`pl-1`) 8 / 17 / 6 11px - - A flat inset only lands one column if every chip carries the same - internal padding, and ours do not — the folder chip has 4px, the - reasoning chip 2px, and the card contributes a 1px border the rails do - not have. So the literal 4px would have moved the rails 8-11px LEFT of - the placeholder and lost the alignment it was asked for. The result was - kept; the mechanism was not adopted. - - `pl-3` is therefore solved for, not a copy of the design's number. The - invariant is that the folder glyph here, the placeholder in the card - and the reasoning glyph below all start on ONE column; each of the - three sits behind a different amount of its own padding (this chip - 4px, the card's border 1px + inset, the reasoning chip 2px), so the - row paddings differ precisely so the ink does not. See the card. */} -
    -
    {dirSwitcher}
    -
    {extensionsSkillsKnowledge}
    -
    - - {/* ROW 2 — THE CARD. The prose and Send, and everything attached to the - message being written: the queue ahead of it, a vision warning about - it, the reference chips on it, the files under it. Nothing that is a - SETTING lives in here any more. - - `pl-4 pr-3 py-2.5` — 16px to the text, 12px to Send, 10px top and - bottom. The horizontal insets stayed where they were: the note two - rounds running was "the edge distance seems a bit too small", and - nothing about returning the type to 14/20 makes the SIDES tighter. The - vertical came in a step, because it is what "less spaced out" reaches - first — a 20px line inside 12px of card padding read as a small - sentence in a large box. - - THIS CARD AND THE USER'S BUBBLE ARE ALREADY THE SAME BOX on the two - axes that are comparable, and the numbers are worth having here so the - next "make it match the chat bubble" round does not re-derive them. - `UserMessage.tsx` is `rounded-container bg-background-medium px-3.5 - py-2.5`; measured against this card: radius 12 == 12, vertical padding - 10 == 10. Only the horizontal differs (16/12 here, 14/14 there), and - that difference is the "edge distance too small" note above — the - bubble has never drawn it, this card has, twice. - - What does NOT transfer is height: the bubble is 40px around a 20px line - and this card is 54px, because the card also has to hold a 32px Send. - Closing that gap means cutting the card's vertical padding BELOW the - bubble's — trading a padding the two boxes agree on for a height they - still would not. Measured and left alone. - - The asymmetry is Send's: a 32px button needs less inset than a text - baseline does to look equally clear of the corner. - - `items-end` on the input line is what keeps Send pinned to the last - line as the prose grows upward, instead of drifting to the middle of - a six-line message. */}
    1.152 (canvas == fill, #ffffff) - // dark 1.289 -> 1.141 1.389 -> 1.184 - // - // All three families share one neutral set, so those six scopes are - // really two. LIGHT IS THE BINDING ONE: `--background-canvas` and - // `--background-default` are both #ffffff there, so the border is the - // only thing separating the field from the page and it may not go much - // lower — 44% (the `.biorouter-modal-surface` idiom) lands at 1.111 and - // starts to disappear, and a modal can afford that because it sits on a - // blurred scrim that reinforces its edge. Dark is the safer scope, not - // the riskier one: the card fill is already a step off the canvas - // (1.078:1) so the field keeps its shape there even as the hairline - // fades. - // - // Note this is deliberately BELOW `check-contrast.mjs`'s 1.25 floor for - // `--border-subtle` vs the app ground. That floor governs the TOKEN, at - // full strength, where a hairline is the whole statement. This call - // site wants one step lighter than that on purpose. - 'bg-background-default border border-border-subtle/60', - 'transition-[box-shadow,background-color,border-color]', - // NO FOCUS RING. THE EDGE ITSELF CARRIES THE STATE. - // - // This is what "the rim is still too thick" was actually about. The - // ring was 2px and INSET, so stacked on the 1px border it painted a - // 3px band — and because the composer AUTOFOCUSES on mount, it was on - // from the first frame of every session. It was never a focus state; - // it was the composer's permanent resting appearance. No amount of - // lightening the 1px border underneath could fix that, because the - // border was never the thick part. - // - // THE ORANGE RING IS BACK, BY EXPLICIT REQUEST, AND IT IS THE FOCUS - // STATE. It was removed once — on the reasoning below — and the user - // asked for it back after seeing the composer without it. Their call - // stands: read the rest of this note as the constraint the ring has to - // survive, not as an argument against it. - // - // The reasoning that removed it, which is still TRUE and still worth - // knowing: the composer autofocuses, so `:focus` is on from the first - // frame of every session, which makes anything hung on it the resting - // appearance rather than a state. That is why the ring must stay - // CHEAP — it is on nearly always, so it has to be something the eye - // can live beside, not an alert. What made it read as "a very thick - // rim" the first time was the ring at 2px stacked on a border that was - // then at full strength: two edges, ~3px, in two different hues. The - // border has since dropped to 60%, and everything around the composer - // has tightened, so the same ring now reads as one accented edge - // rather than a band. - // - // ⚠ DO NOT hang this on `:focus-visible` to make it "keyboard only". - // It was tried and measured: `:focus-visible` ALWAYS matches a focused - // text control, however focus arrived, because the element accepts - // keyboard input. On a textarea it is not a narrower `:focus`, it is - // the same selector spelled longer — so it changes nothing except how - // long the next person takes to work that out. - // - // CSS, not a mirrored React flag. This used to ride an `isFocused` - // state kept in sync by the textarea's own `onFocus`/`onBlur`. Asking - // the DOM directly is one source of truth instead of two, and cannot - // desync from it. - // THE RING IS THE BORDER, IN FULL ACCENT — one crisp orange pixel, - // not a pale band. - // - // The previous spelling was `inset-shadow-accent`, which is - // `--inset-accent` = `inset 0 0 0 2px var(--accent-muted)`, and - // `--accent-muted` is the accent at **8%**. Two pixels of 8% coral on - // a white card does not read as orange at all — it reads as a thick, - // slightly warm grey border, which is exactly how it was reported - // ("not orange, a thick border"). The alpha was the bug, and it is - // also why the same ring earlier read as "a very thick rim": a 2px - // near-transparent band beside a 1px real one is two edges, and - // neither of them looks deliberate. - // - // `--border-accent` is the full accent (coral-600 light, coral-400 - // dark, teal in Alma Mater), so the composer's own 1px edge simply - // turns orange. One pixel, one edge, unmistakably the accent — - // and it stays a border, so nothing about the box changes. - 'biorouter-composer-card', - // Drag-over keeps the ring: THAT is a transient state worth shouting, - // it is nothing like a resting appearance, and being inset it still - // costs no layout. - isDraggingOver && 'inset-shadow-accent', - isDraggingOver && 'bg-background-medium/80' - )} - // THE WORKING EDGE. While a turn runs, the card's own border carries a - // travelling segment of the accent (`main.css`, next to the focus rule) - // — the replacement for the breathing dot that used to sit in a row - // above this card, duplicating the transcript's indicator 8px out of - // line with it. - // - // An ATTRIBUTE, not a class, and deliberately: the styling is authored - // CSS rather than a Tailwind utility (a newly written arbitrary class - // can silently fail to generate under `BIOROUTER_NO_HMR`), so the hook - // it keys off has to be something Tailwind never had to produce. - // - // `undefined` rather than `'false'` when idle, so the attribute is - // absent from the DOM and `[data-working]` is the whole test. + // The page ground with a resting hairline (R-32); the focus and working + // edges turn that 1px border the accent. `composer/composerCard.ts`. + className={composerCardClassName({ isDraggingOver })} + // THE WORKING EDGE: an attribute, not a class, so the hook is something + // Tailwind never had to generate. Absent when idle. data-working={isWorking ? 'true' : undefined} > - {/* Message Queue Display */} {queuedMessages.length > 0 && ( )} - {/* No-model hint. Same shape as the vision-mismatch banner below and for - the same reason: the composer says why Send is off, in the composer, instead - of letting the user press it and meet a daemon error written for a developer. - It carries the way out, because "choose a provider" is only actionable if the - catalog is one click away. */} + + {/* Why Send is off, said in the composer with the way out, instead of + letting the person press it and meet a daemon error. */} {noModelConfigured && ( -
    - {NO_MODEL_COMPOSER_HINT} - +
    )} - {/* Vision-mismatch banner: shown when the user has images attached but the - current model does not support vision. Blocks Send until resolved. */} + + {/* Images attached for a model that cannot read them. Blocks Send + until resolved. */} {visionMismatch && ( -
    - - - The active model can't read images. Switch to a vision-capable model, or remove - attached images to send. - -
    - )} - {/* Attached resources (issue #65). The canonical form of a reference is a - `` tag, which is the only form that survives a name with a - space or a quote in it — and is far too much markup to leave sitting in the - user's sentence. It lives in the message text; this rail is where the user - sees and manages it. Above the prose because a reference qualifies the whole - message rather than a point in it, and because the row below the textarea is - already the attachments area for images and files. */} - {(composerCommand || composerRefs.length > 0) && ( -
    - {/* The command first: it is the first thing in the message, and - what the rest of the message is for. */} - {composerCommand && ( - - )} - {composerRefs.map((ref, index) => ( - handleRemoveReference(index)} - /> - ))} -
    - )} - {/* THE INPUT LINE: the prose, and the one action on it. Send lives - HERE now, not in the control row — it acts on what is in this box, - so it belongs in this box, and putting it here is also what lets the - control row below be settings and readouts only. - - `items-end` rather than `items-center`: on one line the two children - are the same 32px so the two agree, but on a six-line message only - `end` keeps Send beside the last line the user typed. - - `gap-2` (8px), down from 10. The shell above claims its `gap-2.5` is - "deliberately wider than any gap inside the card" — that is the whole - reason the card reads as one object with two satellites rather than - as three bands — and while this was also 10px the claim was simply - not true, the two were equal. Tightening the one gap INSIDE the card - is what makes it true, and it is the "squeeze the elements in a - little" the note asked for, spent where it costs nothing: 8px between - the prose and Send, still inside 12px to the card's edge. */} -
    -
    - {/* THE TYPING REGION, and the third attempt at making the text sit - plainly in the middle of its box. The first two put the padding in - the wrong place; this one puts it symmetrically on the control - itself, which is the only arrangement that survives the textarea - growing. - - The physics, stated once so it stops being rediscovered: a - textarea's text begins at the top of its CONTENT box. So - bottom-only padding cannot centre anything — it grows the box - underneath the text and leaves the line where it was (that was the - original `p-0 pb-1.5` bug, measured at 3px high). SYMMETRIC - padding is different: it insets the content box equally top and - bottom, and a single line box then fills that content box exactly, - so the line is centred in the border box by construction. - - `py-1.5` is therefore load-bearing twice over. It centres the line - — and it makes the textarea's border box exactly 32px, the same as - Send, so `items-end` above lands the two on the same baseline - instead of dropping the text below the button. Remove it and the - text does not merely lose its padding, it goes visibly low. - - ⚠ THIS NUMBER IS TIED TO THE TYPE SIZE, and that coupling is what - made it 6px rather than 4px. The invariant is `line + 2×padding == - 32px`, so the input's box matches Send's: at a 24px line that was - `py-1`, and at `--text-body`'s 20px line it is `py-1.5`. When the - input was moved back onto the shared 14/20 role, leaving `py-1` - would have made the box 28px, and `items-end` would have hung the - text 4px low — reintroducing the exact bias this note exists to - prevent, while every padding number still looked correct. - - Air, measured rather than eyeballed: the 20px line box sits inside - 6px of the control's own padding inside 10px of the card's, so it - carries 16px above and 16px below — the same number, which is what - "not biased towards anywhere" has to mean. - - `block` is the last piece. A textarea is `inline-block` by default - and sits on its parent's BASELINE, so a wrapping line box reserves - descender room under it and the row the user sees is taller than - the control — invisible to any test that measures the textarea - rather than its wrapper. It is a flex item here, which blockifies - it anyway, but the class states the requirement rather than - relying on the parent keeping `display:flex` forever. */} - +
    + + + +
    + +
    +
    +
    `; + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +/** The field and controls row as `ChatInput` draws them, for the list placement cases. */ +const FIELD = + ''; +const CONTROLS = + `
    ` + + `
    `; +/** A chips row (one thumbnail) and a queue row, the things that push the field down the card. */ +const CARD_TOP_ROWS = + '
    • ' + + '↳' + + '
    ' + + '
    '; + +describe('the composer recipes in a real layout engine', () => { + let browser: Browser | null = null; + let page: Page | null = null; + /** A 1440x900 window, the size the review measured the overlap at. */ + let placementPage: Page | null = null; + let placementScript = ''; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn('No Chromium found; skipping the composer geometry tests.'); + return; + } + page = await browser.newPage({ viewport: { width: 800, height: 900 } }); + await page.setContent( + `` + + `${MARKUP}` + ); + const bundle = await build({ + entryPoints: [resolve(here, 'mentionMenuPlacement.ts')], + bundle: true, + write: false, + format: 'iife', + globalName: 'MentionMenu', + platform: 'browser', + logLevel: 'silent', + }); + placementScript = bundle.outputFiles[0].text; + placementPage = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + }, 120_000); + + afterAll(async () => { + await placementPage?.close(); + await page?.close(); + await browser?.close(); + }, 120_000); + + const box = async (selector: string) => { + const rect = await page!.locator(selector).boundingBox(); + if (!rect) throw new Error(`no box for ${selector}`); + return rect; + }; + + it('uses the class names the composer really renders', () => { + const source = [ + read('../ChatInput.tsx'), + read('composerCard.ts'), + read('ComposerChips.tsx'), + read('../MessageQueue.tsx'), + read('../QuotedTextChip.tsx'), + read('../MentionPopover.tsx'), + ].join('\n'); + for (const name of [ + 'br-composer-card', + 'br-composer-input', + 'br-composer-controls', + 'br-composer-controls__spacer', + 'br-composer-controls__model', + 'br-composer-chips', + 'br-composer-thumb', + 'br-composer-thumb__scrim', + 'br-queue-row', + 'br-queue-actions', + 'br-queue-glyph', + 'br-queue-grip', + 'br-quote-chip', + 'br-quote-chip__body', + 'br-quote-chip__source', + 'br-quote-chip__text', + 'br-mention-menu', + 'biorouter-composer-card', + ]) { + expect(source, name).toContain(name); + } + // The list is placed by the module the placement cases below lay out. + expect(read('../ChatInput.tsx')).toContain('position: mentionMenuAnchor(textArea)'); + expect(read('../MentionPopover.tsx')).toContain( + 'mentionMenuPlacement(position, window.innerWidth)' + ); + expect(read('../MentionPopover.tsx')).toContain('style={placement}'); + }); + + it('keeps an empty field to one 32px line', async (ctx) => { + if (!page) return ctx.skip(); + await page.fill('#text', ''); + expect((await box('#text')).height).toBe(32); + }); + + it('grows the field with what is typed, then stops at 40% of the window and scrolls', async (ctx) => { + if (!page) return ctx.skip(); + await page.fill('#text', 'one\ntwo\nthree'); + expect((await box('#text')).height).toBe(3 * 20 + 12); + + // 40vh of the 900px viewport; thirty lines would be 612px. + await page.fill('#text', Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n')); + expect((await box('#text')).height).toBe(0.4 * 900); + const scrolls = await page.$eval('#text', (el) => el.scrollHeight > el.clientHeight); + expect(scrolls).toBe(true); + await page.fill('#text', ''); + }); + + it('lays the controls row out at 32px with the + glyph on the text edge', async (ctx) => { + if (!page) return ctx.skip(); + const card = await box('#card'); + expect((await box('#controls')).height).toBe(32); + // Directly under the field, as in Crew's card: no gap between them. + const text = await box('#text'); + expect((await box('#controls')).y).toBe(text.y + text.height); + // The card's text edge: its 1px border plus 16px of padding. + expect((await box('#plus-glyph')).x - card.x).toBe(17); + expect((await box('#text')).x - card.x).toBe(17); + }); + + it('ends the row with Send on the card inner right edge', async (ctx) => { + if (!page) return ctx.skip(); + const card = await box('#card'); + const send = await box('#send'); + expect(send.width).toBe(32); + expect(card.x + card.width - (send.x + send.width)).toBe(1 + 12); + }); + + it('keeps a queue row at 28px and shows its actions only on hover', async (ctx) => { + if (!page) return ctx.skip(); + expect((await box('#queue-row')).height).toBe(28); + const opacity = (selector: string) => + page!.$eval(selector, (el) => Number(getComputedStyle(el).opacity)); + await page.mouse.move(0, 0); + expect(await opacity('#queue-actions')).toBe(0); + // `↳ text` at rest; the grip takes the arrow's place on hover. + expect(await opacity('#queue-arrow')).toBe(1); + expect(await opacity('#queue-grip')).toBe(0); + await page.hover('#queue-row'); + expect(await opacity('#queue-actions')).toBe(1); + expect(await opacity('#queue-arrow')).toBe(0); + expect(await opacity('#queue-grip')).toBe(1); + await page.mouse.move(0, 0); + }); + + it("shows a queue row's actions and grip to the keyboard too, not only to the pointer", async (ctx) => { + if (!page) return ctx.skip(); + const opacity = (selector: string) => + page!.$eval(selector, (el) => Number(getComputedStyle(el).opacity)); + await page.mouse.move(0, 0); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + expect(await opacity('#queue-actions')).toBe(0); + await page.focus('#queue-edit'); + expect(await opacity('#queue-actions')).toBe(1); + expect(await opacity('#queue-grip')).toBe(1); + expect(await opacity('#queue-arrow')).toBe(0); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + }); + + it("shows a thumbnail's remove control on hover and on keyboard focus", async (ctx) => { + if (!page) return ctx.skip(); + const opacity = () => + page!.$eval('#thumb-remove', (el) => Number(getComputedStyle(el).opacity)); + await page.mouse.move(0, 0); + expect(await opacity()).toBe(0); + await page.hover('#thumb'); + expect(await opacity()).toBe(1); + await page.mouse.move(0, 0); + await page.focus('#thumb-remove-button'); + expect(await opacity()).toBe(1); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + }); + + it('draws a 48px thumbnail under a translucent scrim', async (ctx) => { + if (!page) return ctx.skip(); + const thumb = await box('#thumb'); + expect(thumb.width).toBe(48); + expect(thumb.height).toBe(48); + const background = await page.$eval('#scrim', (el) => getComputedStyle(el).backgroundColor); + const alpha = Number(/rgba\([^)]*,\s*([\d.]+)\)/.exec(background)?.[1] ?? '1'); + expect(alpha).toBeGreaterThan(0); + expect(alpha).toBeLessThan(1); + // The spinner sits on a 24px round ground at the thumbnail's centre. + const ground = await box('#spinner-ground'); + expect(ground.width).toBe(24); + expect(ground.height).toBe(24); + expect(ground.x + 12).toBe(thumb.x + 24); + expect(ground.y + 12).toBe(thumb.y + 24); + }); + + it('draws a quotation as a 2px rule with no fill, its words capped at four lines', async (ctx) => { + if (!page) return ctx.skip(); + const style = await page.$eval('#quote', (el) => { + const cs = getComputedStyle(el); + return { + background: cs.backgroundColor, + left: cs.borderLeftWidth, + top: cs.borderTopWidth, + padding: cs.paddingLeft, + }; + }); + expect(style.background).toBe('rgba(0, 0, 0, 0)'); + expect(style.left).toBe('2px'); + expect(style.top).toBe('0px'); + // The words sit 12px inside the rule. + expect((await box('#quote-text')).x - (await box('#quote')).x).toBe(2 + 12); + expect((await box('#quote-text')).height).toBe(4 * 18); + const scrolls = await page.$eval('#quote-text', (el) => el.scrollHeight > el.clientHeight); + expect(scrolls).toBe(true); + }); + + it('folds a quotation in a queue row to the row line', async (ctx) => { + if (!page) return ctx.skip(); + expect((await box('#queue-quote-row')).height).toBe(28); + expect((await box('#queue-quote-text')).height).toBe(18); + }); + + it('opens the mention menu with the menus entrance, composed with its lift', async (ctx) => { + if (!page) return ctx.skip(); + const motion = await page.$eval('#mention', (el) => { + const cs = getComputedStyle(el); + return { name: cs.animationName, origin: cs.transformOrigin, transform: cs.transform }; + }); + expect(motion.name).toBe('br-mention-in'); + expect(motion.origin).toBe('0px 0px'); + // The inline lift is untouched by the animation (it moves `translate` and `scale`). + expect(motion.transform).toBe('matrix(1, 0, 0, 1, 0, -64)'); + }); + + /** + * Lays a card out at `cardTop` (or 104px off the window's bottom, where the + * chat's card sits at 1440x900), opens a list of `rows` 32px rows with the real + * anchor and placement, and returns the boxes. The list's markup is + * `MentionPopover`'s: a fixed flex column, 4px padding, a scrolling body. + */ + const placeList = async (cardTop: number | 'bottom', inside: string, rows: number) => { + const where = cardTop === 'bottom' ? 'bottom: 104px' : `top: ${cardTop}px`; + const list = Array.from({ length: rows }, () => '
    ').join(''); + await placementPage!.setContent( + `` + + `
    ` + + `${inside}${FIELD}${CONTROLS}
    ` + + '
    ' + + `
    ${list}
    ` + + `` + + // In a block: `setContent` keeps the window, and with it any top-level `const`. + `` + ); + const on = async (selector: string) => { + const rect = await placementPage!.locator(selector).boundingBox(); + if (!rect) throw new Error(`no box for ${selector}`); + return rect; + }; + return { card: await on('#card'), text: await on('#text'), list: await on('#list'), on }; + }; + + it('opens the Commands and @ lists 8px above the card, clear of its focus edge', async (ctx) => { + if (!placementPage) return ctx.skip(); + const { card, text, list } = await placeList('bottom', '', 3); + // The instrument: the field's top sits 11px inside the card (1px border, + // 10px padding), so a list hung from the field would cover the card's edge. + expect(text.y - card.y).toBe(11); + expect(list.height).toBe(3 * 32 + 8); + expect(list.y + list.height).toBe(card.y - 8); + // From the text's left edge, as before. + expect(list.x).toBe(text.x); + }); + + it('keeps the gap above a card that holds a queue and chips, and covers neither', async (ctx) => { + if (!placementPage) return ctx.skip(); + const { card, text, list, on } = await placeList('bottom', CARD_TOP_ROWS, 3); + const chips = await on('#chips'); + // The field is pushed well down the card here. + expect(text.y - card.y).toBeGreaterThan(11 + 48); + expect(list.y + list.height).toBe(card.y - 8); + expect(list.y + list.height).toBeLessThan(chips.y); + }); + + it('shortens the list under a card high in the window, never rising into the drag band', async (ctx) => { + if (!placementPage) return ctx.skip(); + // Twelve rows want 392px; the card's top at 200px leaves 200 - 8 - 52 = 140. + const { card, list } = await placeList(200, '', 12); + expect(list.y + list.height).toBe(card.y - 8); + expect(list.y).toBe(52); + const scrolls = await placementPage.$eval( + '#list-body', + (el) => el.scrollHeight > el.clientHeight + ); + expect(scrolls).toBe(true); + }); +}); diff --git a/ui/desktop/src/components/composer/composerHandOff.test.tsx b/ui/desktop/src/components/composer/composerHandOff.test.tsx new file mode 100644 index 000000000..c3489b4cc --- /dev/null +++ b/ui/desktop/src/components/composer/composerHandOff.test.tsx @@ -0,0 +1,110 @@ +import { StrictMode, useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + composerHandOffSizeForTests, + readComposerHandOff, + useComposerHandOff, +} from './composerHandOff'; + +/** + * V2-R8-3. A composer's hand-over to the composer that replaces it: read in the + * replacement's first render, gone with the composer that published it. + */ +function Probe({ + composerKey, + value, + testId, +}: { + composerKey?: string; + value: string | null; + testId: string; +}) { + // What this mount was handed when it began, as a state initialiser reads it. + const [received] = useState(() => readComposerHandOff(composerKey, 'probe')); + useComposerHandOff(composerKey, 'probe', value); + return {received ?? 'nothing'}; +} + +/** One probe, drawn in one of two places, as `BaseChat` draws its composer. */ +function Seat({ + inCentre, + composerKey, + value, +}: { + inCentre: boolean; + composerKey?: string; + value: string | null; +}) { + const probe = ; + return ( +
    + {inCentre ?
    {probe}
    :

    } + {!inCentre &&

    {probe}
    } +
    + ); +} + +describe('composerHandOff', () => { + it('hands the value to the composer that replaces it in the same commit', () => { + const view = render(); + expect(screen.getByTestId('probe')).toHaveTextContent('nothing'); + const before = screen.getByTestId('probe'); + + view.rerender(); + // A new mount (the instrument), which began with what the old one stated. + expect(screen.getByTestId('probe')).not.toBe(before); + expect(screen.getByTestId('probe')).toHaveTextContent('settled'); + // The replacement publishes nothing (`null`), and the old entry left with + // its composer. + expect(composerHandOffSizeForTests()).toBe(0); + view.unmount(); + }); + + it('keeps nothing for a composer that mounts after the publisher is gone', () => { + const first = render(); + expect(composerHandOffSizeForTests()).toBe(1); + first.unmount(); + expect(composerHandOffSizeForTests()).toBe(0); + + render(); + expect(screen.getByTestId('probe')).toHaveTextContent('nothing'); + }); + + it('hands nothing across identities, and nothing without one', () => { + const view = render(); + view.rerender(); + expect(screen.getByTestId('probe')).toHaveTextContent('nothing'); + view.unmount(); + + const anonymous = render(); + expect(composerHandOffSizeForTests()).toBe(0); + anonymous.rerender(); + expect(screen.getByTestId('probe')).toHaveTextContent('nothing'); + }); + + it('keeps the newest value, and the replacement’s own entry survives the old cleanup', () => { + const view = render(); + view.rerender(); + expect(readComposerHandOff('tab-1', 'probe')).toBe('second'); + + // The replacement publishes at once; the old composer's cleanup must not + // take the new entry with it. + view.rerender(); + expect(screen.getByTestId('probe')).toHaveTextContent('second'); + expect(readComposerHandOff('tab-1', 'probe')).toBe('third'); + view.unmount(); + expect(composerHandOffSizeForTests()).toBe(0); + }); + + it('survives StrictMode’s second run of every effect', () => { + const view = render( + + + + ); + expect(readComposerHandOff('tab-1', 'probe')).toBe('settled'); + view.unmount(); + expect(composerHandOffSizeForTests()).toBe(0); + }); +}); diff --git a/ui/desktop/src/components/composer/composerHandOff.ts b/ui/desktop/src/components/composer/composerHandOff.ts new file mode 100644 index 000000000..7eb4c1e86 --- /dev/null +++ b/ui/desktop/src/components/composer/composerHandOff.ts @@ -0,0 +1,77 @@ +import { useLayoutEffect } from 'react'; + +/** + * What a composer states, handed to the composer that replaces it (V2-R8-3). + * + * ⚠ **A new chat's first send REMOUNTS the composer.** `BaseChat` draws it in + * one of two places: centred under the greeting while the chat is empty, and in + * the bar under the transcript once it is not (`renderChatInput()` sits in both + * branches). The send leaves the empty state in the commit that starts creating + * the chat, so React unmounts one `ChatInput` and mounts another, and every + * state the first one held starts over. Measured in the running app: the Tools + * chip read "Tools 5", then "Tools", then "Tools 5" again within 35 ms, and the + * folder a person had picked fell back to the default for the same frames. The + * chips' own carry from "no chat" to the chat they just started could not help: + * it was state of the mount that was gone. + * + * So a composer publishes, under its lasting identity (the key that stays the + * same from the empty chat to the started one: `BaseChat`'s tab-scoped + * `reasoningDraftKey`, Home's draft key), what it states; and a composer that + * mounts under that identity starts from it. + * + * ⚠ **A hand-over, never a cache.** An entry lives exactly as long as the + * composer that published it: it is removed when that composer unmounts. React + * renders the replacement BEFORE the old composer's cleanup runs, so the new + * one reads the entry in its first render; anything mounting later (the tab + * opened again, another visit) finds nothing and asks for itself. A value read + * from here is only shown by a rule that already held inside one mount (the + * same chat, or the chat this composer just started), so nothing read for one + * chat is ever shown under another. + */ + +interface Entry { + readonly value: unknown; +} + +const live = new Map(); + +const entryId = (composerKey: string, slot: string) => `${slot}\u0000${composerKey}`; + +/** + * What the composer currently mounted under `composerKey` published for + * `slot`, or `null`. Read it in a state initialiser: it is the value the + * previous composer showed when this one began. + */ +export function readComposerHandOff(composerKey: string | undefined, slot: string): T | null { + if (!composerKey) return null; + const entry = live.get(entryId(composerKey, slot)); + return entry ? (entry.value as T) : null; +} + +/** + * Publishes `value` for `slot` while this composer is mounted (`null` publishes + * nothing). Keep `value` referentially stable (a state value), since each new + * identity replaces the entry. + */ +export function useComposerHandOff( + composerKey: string | undefined, + slot: string, + value: T | null +): void { + useLayoutEffect(() => { + if (!composerKey || value === null) return; + const id = entryId(composerKey, slot); + const entry: Entry = { value }; + live.set(id, entry); + return () => { + // Only this composer's own entry: a replacement may already have + // published under the same identity. + if (live.get(id) === entry) live.delete(id); + }; + }, [composerKey, slot, value]); +} + +/** Tests only: how many entries are live (each must leave with its composer). */ +export function composerHandOffSizeForTests(): number { + return live.size; +} diff --git a/ui/desktop/src/components/composer/copy.ts b/ui/desktop/src/components/composer/copy.ts new file mode 100644 index 000000000..4584214cc --- /dev/null +++ b/ui/desktop/src/components/composer/copy.ts @@ -0,0 +1,158 @@ +/** + * The chat composer's strings: the card, its `+` menu, Send and Stop, the + * attachments row, the banners, the queue and the mention menu. + * + * One place, so the tests import what the interface says instead of restating + * it (Crew's `copy.ts` pattern). Sentence case, the noun is "chat", the + * ellipsis is "…", and nothing here is punctuated with a dash. + */ + +export const COMPOSER_COPY = { + /** Home, a new tab and an empty chat. `Hub.startFailure.test.tsx` reads it. */ + placeholderEmpty: 'Ask Biorouter anything…', + /** A chat that already has messages. */ + placeholderFollowUp: 'Ask a follow-up', + /** The field's accessible name; the placeholder changes with the chat, the name does not. */ + inputLabel: 'Message', + + send: { + label: 'Send message', + tooltip: 'Send', + waitingForImages: 'Waiting for images to save…', + processingFiles: 'Processing dropped files…', + restarting: 'Restarting chat…', + starting: 'Starting chat…', + loading: 'Loading chat…', + /** The steer chord, named only while a turn is running. */ + steerHint: (chord: string) => `${chord} adds it to the running turn`, + }, + + stop: { + label: 'Stop response', + labelAcknowledged: 'Stopping response', + tooltip: 'Stop', + tooltipAcknowledged: 'Stopping…', + }, + + plus: { + label: 'Add to message', + attachFile: 'Attach file…', + mention: 'Mention', + mentionHint: '@', + commands: 'Commands', + commandsHint: '/', + }, + + attachments: { + saving: 'Saving image…', + removeImage: 'Remove image', + removeFile: 'Remove file', + retryLabel: 'Retry saving image', + imageAlt: (name: string) => `Attached image ${name}`, + pastedImageAlt: 'Pasted image', + }, + + vision: { + banner: "This model can't read images", + help: 'Switch to a model that reads images, or remove the images to send.', + action: 'Switch model', + }, + + queue: { + header: 'Queued', + paused: 'Paused', + expand: (count: number) => `${count} message${count === 1 ? '' : 's'} queued. Expand queue.`, + collapse: 'Collapse queue', + clear: 'Clear queue', + addNowLabel: 'Add this message to the current turn', + addNowTooltip: 'Add to the running turn without stopping', + stopAndSendLabel: 'Stop the current turn and send this message as a new turn', + stopAndSendTooltip: 'Stop the running turn, then send this', + remove: 'Remove from queue', + edit: 'Edit', + save: 'Save', + cancel: 'Cancel', + cannotSendWhileEditing: 'Finish editing first', + dragToReorder: 'Drag to reorder', + attachments: (count: number) => `${count} attachment${count === 1 ? '' : 's'}`, + fallbackLabel: 'Queued message', + }, + + mention: { + /** The menu's accessible names: `@` lists mentions, `/` lists commands. */ + mentionsLabel: 'Mentions', + commandsLabel: 'Commands', + searching: 'Searching…', + noMatch: (query: string) => `Nothing matches "${query}"`, + /** An `@` with nothing typed after it, in a folder with nothing to list. */ + emptyFolder: 'No files in this folder', + }, + + /** + * The one-line descriptions of the commands the client adds to the `/` list. + * A menu description says what the command does, with no period and no + * instruction to press a key. + */ + commands: { + knowledge: 'Use the Knowledge capability', + crew: 'Connect this chat to a Crew channel', + /** `/crew` in a chat with no session yet: Enter can only say to send a message first. */ + crewBeforeFirstMessage: 'Connect this chat to a Crew channel after your first message', + /** The Crew extension's row, when a query reaches it without the `/crew` command. */ + crewExtension: 'Crew tools extension (advanced)', + diverge: 'Continue in a new chat with the full history', + }, + + /** + * The slash commands drawn as a chip (`utils/composerCommand.ts` keys its + * registry on these): what the chip reads as, what it says on hover, and the + * textarea's placeholder while the chip is there and the prose is empty. + */ + chipCommands: { + bug: { + label: 'Report a bug', + title: '/bug: Biorouter investigates this chat and drafts an issue for you to approve', + placeholder: 'Describe what went wrong (optional)', + }, + }, + /** A command chip's words for a screen reader, before its label. */ + chipCommandSpoken: (token: string) => `Command ${token}: `, + /** A command chip's remove control. Removing it keeps the prose. */ + removeChipCommand: (token: string) => `Remove command ${token}`, + + /** Why a pasted or dropped image did not attach; each sits behind the chip's help. */ + imageErrors: { + undecodable: 'Image preview could not be decoded.', + unreadable: 'Failed to read image file.', + saveFailed: 'Failed to save image via Electron.', + }, + + /** Warnings the composer raises when something is kept or refused. */ + toasts: { + queued: { + title: 'Message still queued', + busy: 'Biorouter could not send it while the chat was busy. It is still in the queue, ready to send.', + notStopped: + 'Biorouter could not confirm that the current turn stopped. Your message is still in the queue.', + }, + region: { + title: 'Region not attached', + tooMany: (max: number) => `A message can contain at most ${max} images.`, + unreadable: 'The selected region could not be read.', + }, + crewDraftKept: { + title: 'Draft kept', + message: + 'Remove the attached files, images, and reference chips before using /crew, or open Crew from the sidebar. Nothing was sent.', + }, + crewStartFirst: { + title: 'Start the chat first', + message: + 'Send this chat a message, then type /crew to connect it to a Crew channel. To just open Crew, use the sidebar.', + }, + divergeStartFirst: { + title: 'Start the chat first', + message: '/diverge continues a chat in a new one. Send a message before using it.', + }, + }, +} as const; diff --git a/ui/desktop/src/components/composer/mentionMenuPlacement.ts b/ui/desktop/src/components/composer/mentionMenuPlacement.ts new file mode 100644 index 000000000..6864a1c1d --- /dev/null +++ b/ui/desktop/src/components/composer/mentionMenuPlacement.ts @@ -0,0 +1,81 @@ +/** + * Where the Commands and @ lists open (spec 3.7, V2-R5-2). + * + * The list opens upward from the composer CARD's top edge, 8px clear of it, + * starting at the text's left edge. It used to hang from the text field's top, + * which sits 11px inside the card (its 1px border and 10px of padding, and + * further down under a queue or a chips row), so the list's bottom 3px covered + * the card's coral focus edge, and in a card holding chips it covered the chips. + * + * Plain geometry with no imports, so the Chromium test + * (`composerGeometry.browser.test.ts`) bundles this module into its page and + * lays a list out with the same code `ChatInput` and `MentionPopover` run. + */ + +/** Every composer card carries this class (`composerCard.ts`). */ +export const COMPOSER_CARD_SELECTOR = '.biorouter-composer-card'; + +/** Between the list's bottom edge and the card's top edge. */ +export const MENTION_MENU_GAP = 8; + +/** The list at its tallest (it was the `max-h-72` utility). */ +export const MENTION_MENU_MAX_HEIGHT = 288; + +/** + * The list's top never rises above this line. The top 44px of a chat is a + * window-drag band that eats clicks on whatever overlaps it (CLAUDE.md, + * "Toasts"); 8px below it is the margin the Tools popover keeps too + * (`TOOLS_POPOVER_COLLISION_PADDING`). A card high in the window gets a shorter + * list that scrolls, rather than one that runs under the band or off the window. + */ +export const MENTION_MENU_TOP_CLEARANCE = 52; + +/** One 32px row and the list's 4px padding above and below. */ +export const MENTION_MENU_MIN_HEIGHT = 40; + +const MENU_MIN_WIDTH = 320; +const MENU_MAX_WIDTH = 448; +const WINDOW_MARGIN = 8; + +/** The point the list opens from: the text's left edge, the card's top edge. */ +export interface MentionMenuAnchor { + x: number; + y: number; +} + +export function mentionMenuAnchor(field: Element): MentionMenuAnchor { + const card = field.closest(COMPOSER_CARD_SELECTOR) ?? field; + return { x: field.getBoundingClientRect().left, y: card.getBoundingClientRect().top }; +} + +/** The list's fixed-position box, in px. `top` is its bottom edge before the lift. */ +export interface MentionMenuPlacement { + left: number; + top: number; + width: number; + maxHeight: number; + transform: string; +} + +export function mentionMenuPlacement( + anchor: MentionMenuAnchor, + windowWidth: number +): MentionMenuPlacement { + const width = Math.max(MENU_MIN_WIDTH, Math.min(MENU_MAX_WIDTH, windowWidth - 2 * WINDOW_MARGIN)); + const left = Math.min( + Math.max(WINDOW_MARGIN, anchor.x), + Math.max(WINDOW_MARGIN, windowWidth - width - WINDOW_MARGIN) + ); + const bottom = anchor.y - MENTION_MENU_GAP; + return { + left, + top: bottom, + width, + maxHeight: Math.max( + MENTION_MENU_MIN_HEIGHT, + Math.min(MENTION_MENU_MAX_HEIGHT, bottom - MENTION_MENU_TOP_CLEARANCE) + ), + // Lifted by its own height, so it grows upward from `top`. + transform: 'translateY(-100%)', + }; +} diff --git a/ui/desktop/src/components/composerNoProvider.test.ts b/ui/desktop/src/components/composerNoProvider.test.ts index 2a851c06b..0e8ef5125 100644 --- a/ui/desktop/src/components/composerNoProvider.test.ts +++ b/ui/desktop/src/components/composerNoProvider.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { hasNoModelConfigured } from './composerNoProvider'; +import { + NO_MODEL_CHIP_LABEL, + NO_MODEL_COMPOSER_ACTION, + NO_MODEL_COMPOSER_HINT, + hasNoModelConfigured, +} from './composerNoProvider'; /** * ⚠ **The whole point of this function is the `loading` arm.** @@ -37,3 +42,19 @@ describe('hasNoModelConfigured', () => { expect(hasNoModelConfigured(undefined, 'versa_azure')).toBe(false); }); }); + +/** + * One missing step gets one noun (FR-R2-9). The composer card shows the hint's link and the model + * chip together, and both open the same catalog, so they cannot name the move two ways. + */ +describe('the no-model words', () => { + it('names the next move the same way on the hint link and the chip', () => { + expect(NO_MODEL_COMPOSER_ACTION).toBe(NO_MODEL_CHIP_LABEL); + expect(NO_MODEL_CHIP_LABEL).toBe('Choose a model'); + }); + + it('states the problem in the hint and keeps the remedy out of it', () => { + expect(NO_MODEL_COMPOSER_HINT).toBe('No model selected'); + expect(NO_MODEL_COMPOSER_HINT).not.toContain(NO_MODEL_COMPOSER_ACTION); + }); +}); diff --git a/ui/desktop/src/components/composerNoProvider.ts b/ui/desktop/src/components/composerNoProvider.ts index 7643c4a7a..5f67258db 100644 --- a/ui/desktop/src/components/composerNoProvider.ts +++ b/ui/desktop/src/components/composerNoProvider.ts @@ -1,8 +1,8 @@ import type { ModelConfigStatus } from './ModelAndProviderContext'; /** - * The composer's "no model yet" state — the honest other half of letting a user - * into the app before they have configured a provider. + * The composer's "no model selected" state: the honest other half of letting a + * user into the app before they have configured a provider. * * ⚠ **The decision is here, not at either call site.** The chip and the send * guard must agree exactly: a chip that says "Choose a model" over a composer @@ -14,9 +14,16 @@ import type { ModelConfigStatus } from './ModelAndProviderContext'; /** What the model chip reads when nothing is configured. */ export const NO_MODEL_CHIP_LABEL = 'Choose a model'; -/** The one line above the composer, and the link on the end of it. */ -export const NO_MODEL_COMPOSER_HINT = 'No model yet — choose a provider to start chatting'; -export const NO_MODEL_COMPOSER_ACTION = 'Choose a provider'; +/** + * The one line inside the composer card, and the link on the end of it. + * + * ⚠ **The link is the chip's own label, not a second spelling of it.** Both sit in the same card + * and both open the same catalog, and one missing step used to read "Choose a provider" in the + * hint and "Choose a model" on the chip: two nouns for one next move on the first screen after + * leaving first run (FR-R2-9). Deriving it makes the drift impossible rather than merely tested. + */ +export const NO_MODEL_COMPOSER_HINT = 'No model selected'; +export const NO_MODEL_COMPOSER_ACTION = NO_MODEL_CHIP_LABEL; /** * Is there genuinely no model bound? diff --git a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx index 568fb779b..18f03d966 100644 --- a/ui/desktop/src/components/context_management/SystemNotificationInline.tsx +++ b/ui/desktop/src/components/context_management/SystemNotificationInline.tsx @@ -23,5 +23,11 @@ export const SystemNotificationInline: React.FC = return null; } - return
    {systemNotification.msg}
    ; + // The transcript row's type and rhythm (a 28px line in muted 13px ink), so a + // notice reads as a line of the chat rather than a footnote. + return ( +
    + {systemNotification.msg} +
    + ); }; diff --git a/ui/desktop/src/components/conversation/ChatTurnError.test.tsx b/ui/desktop/src/components/conversation/ChatTurnError.test.tsx index 66d49d362..0326b2da9 100644 --- a/ui/desktop/src/components/conversation/ChatTurnError.test.tsx +++ b/ui/desktop/src/components/conversation/ChatTurnError.test.tsx @@ -1,8 +1,14 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { ChatTurnError, hasVisibleTurnErrorMessage, presentChatTurnError } from './ChatTurnError'; +import { + ChatTurnError, + hasVisibleTurnErrorMessage, + presentChatTurnError, + presentStoredTurnError, +} from './ChatTurnError'; import type { Message } from '../../api'; import type { ChatTurnErrorData } from '../../types/turnError'; +import { transcriptCopy } from '../transcript/copy'; const QUOTA_ERROR = 'Stream error: provider_failure: Request failed: Stream decode error: Responses API error: Object {"type": String("insufficient_quota"), "code": String("insufficient_quota"), "message": String("You exceeded your current quota, please check your plan and billing details."), "param": Null}'; @@ -64,14 +70,20 @@ describe('ChatTurnError', () => { /> ); - const disclosure = screen.getByText('Technical details').closest('details'); - expect(disclosure).not.toHaveAttribute('open'); - fireEvent.click(screen.getByText('Technical details')); - expect(disclosure).toHaveAttribute('open'); + // The shared Disclosure, labelled "Details": closed, the payload is not + // mounted at all; opened, it wraps however long the payload is. + const trigger = screen.getByRole('button', { name: transcriptCopy.turn.details }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText(details)).toBeNull(); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); const detailBody = screen.getByText(details); expect(detailBody).toHaveClass('break-words'); expect(detailBody).toHaveClass('[overflow-wrap:anywhere]'); + // One well, as a tool call's expanded body is (R-27: a fill, no frame). + expect(detailBody).toHaveClass('rounded-element', 'bg-background-well', 'p-3'); + expect(detailBody.className).not.toMatch(/\bborder\b/); }); it('labels a transient backend-unreachable failure distinctly from a provider failure', () => { @@ -115,10 +127,28 @@ describe('ChatTurnError', () => { ); const retry = screen.getByTestId('chat-turn-error-retry'); + expect(retry).toHaveAccessibleName(transcriptCopy.turn.retry); fireEvent.click(retry); expect(onRetry).toHaveBeenCalledTimes(1); }); + it('is a transcript row: a warning glyph, a title and a muted message, no toast surface', () => { + const { container } = render( + + ); + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('data-testid', 'chat-turn-error'); + expect(alert.querySelector('svg.text-text-danger')).not.toBeNull(); + expect(screen.getByText('Model turn ended unexpectedly')).toHaveClass('text-label'); + expect(screen.getByText('boom')).toHaveClass('text-secondary', 'text-text-muted'); + // The one secondary button, not a hand-rolled 13px link. + expect(screen.getByTestId('chat-turn-error-retry').className).not.toMatch(/text-\[13px\]/); + expect(container.querySelector('[data-testid="notification-surface"]')).toBeNull(); + }); + it('omits the Retry action when the error is not retryable', () => { render(); expect(screen.queryByTestId('chat-turn-error-retry')).toBeNull(); @@ -261,7 +291,7 @@ describe('ChatTurnError', () => { expect(alert).toHaveTextContent(SETTINGS); expect(alert).not.toHaveTextContent('Model request failed'); expect(alert).not.toHaveTextContent(/grant_expired|\{|"code"/); - expect(screen.queryByText('Technical details')).toBeNull(); + expect(screen.queryByRole('button', { name: transcriptCopy.turn.details })).toBeNull(); // Retrying would be refused the same way: the chat's Crew bar is the way on. expect(screen.queryByTestId('chat-turn-error-retry')).toBeNull(); }); @@ -309,4 +339,63 @@ describe('ChatTurnError', () => { expect(hasVisibleTurnErrorMessage(turnError, messages)).toBe(true); expect(hasVisibleTurnErrorMessage(error({ message: 'Failed to fetch' }), messages)).toBe(false); }); + + // Vision V2-07: the daemon's stored notice ("Ran into this error: …", from + // `stop_notice` in agents/mistakes.rs) is the same failure the live row draws, + // so it is presented the same way, with the raw response under Details. + describe('the stored turn-ending notice', () => { + const AUTH_ADVICE = + 'Retrying will not help until the key or sign-in is fixed. Once it is, send your message again: this chat reads the new credentials with its next message.'; + + it('names an authentication failure and keeps the raw response under Details', () => { + const raw = + 'Authentication error: Authentication failed. Status: 401 Unauthorized. Response: {"error":"Invalid client id or secret"}.'; + expect(presentStoredTurnError(`Ran into this error: ${raw}\n\n${AUTH_ADVICE}`)).toEqual({ + title: 'Model authentication failed', + message: `Authentication failed. Status: 401 Unauthorized. ${AUTH_ADVICE}`, + details: raw, + }); + }); + + it('keeps a bare status with its kind, and needs no Details when nothing was hidden', () => { + expect( + presentStoredTurnError( + 'Ran into this error: Server error: 502.\n\nPlease retry if you think this is a transient or recoverable error. Biorouter already retried it 2 times.' + ) + ).toEqual({ + title: 'Model provider unavailable', + message: + 'Server error: 502. Please retry if you think this is a transient or recoverable error. Biorouter already retried it 2 times.', + details: undefined, + }); + }); + + it('keeps an actionable vendor sentence in view', () => { + const presentation = presentStoredTurnError( + "Ran into this error: Request failed: The 'gpt-6-astra' model requires a newer version of Codex.\n\nRetrying will not help: this one returns the same way until its cause changes." + ); + expect(presentation?.title).toBe('Model request failed'); + expect(presentation?.message).toBe( + "The 'gpt-6-astra' model requires a newer version of Codex. Retrying will not help: this one returns the same way until its cause changes." + ); + }); + + it('names a failed compaction as such', () => { + expect( + presentStoredTurnError( + 'Ran into this error trying to compact: Context length exceeded: too long.\n\nPlease try again or create a new chat' + ) + ).toEqual({ + title: 'Could not compact this chat', + message: 'Too long. Please try again or create a new chat.', + details: undefined, + }); + }); + + it('leaves every other answer to markdown', () => { + expect(presentStoredTurnError('Here is the answer.')).toBeNull(); + expect(presentStoredTurnError('I ran into this error: nothing.')).toBeNull(); + expect(presentStoredTurnError('Ran into this error: \n\nadvice')).toBeNull(); + }); + }); }); diff --git a/ui/desktop/src/components/conversation/ChatTurnError.tsx b/ui/desktop/src/components/conversation/ChatTurnError.tsx index d8076c768..e0dae3aa3 100644 --- a/ui/desktop/src/components/conversation/ChatTurnError.tsx +++ b/ui/desktop/src/components/conversation/ChatTurnError.tsx @@ -1,10 +1,14 @@ -import { NotificationSurface } from '../alerts/NotificationSurface'; +import { AlertTriangle } from '../icons/app-icons'; +import { Button } from '../ui/button'; +import { Disclosure } from '../ui/disclosure'; +import { transcriptCopy } from '../transcript/copy'; import type { Message } from '../../api'; import type { ChatTurnErrorData } from '../../types/turnError'; import { isConnectionError } from '../../utils/conversionUtils'; import { crewTurnRefusalOf } from '../crew/access/crewTurnRefusal'; +import { cn } from '../../utils'; -interface ChatTurnErrorPresentation { +export interface ChatTurnErrorPresentation { title: string; message: string; details?: string; @@ -143,6 +147,63 @@ export function presentChatTurnError(error: ChatTurnErrorData): ChatTurnErrorPre }; } +// The daemon's stored turn-ending notice (`stop_notice` in +// `crates/biorouter/src/agents/mistakes.rs`, and the compaction failure in +// `agents/agent.rs`): "Ran into this error: \n\n". It is +// an ordinary assistant message, so the model reads it next turn and a reload +// shows it; drawn as prose it was a second look for the same failure the live +// error row draws (vision V2-07). +const STORED_NOTICE = /^Ran into this error( trying to compact)?: */; + +// `ProviderError`'s Display prefixes (`crates/biorouter/src/providers/errors.rs`). +const STORED_TITLES: ReadonlyArray = [ + [/^Authentication error:\s*/, PROVIDER_TITLES.auth], + [/^Context length exceeded:\s*/, PROVIDER_TITLES.context_length], + [/^Rate limit exceeded:\s*/, PROVIDER_TITLES.rate_limit], + [/^Server error:\s*/, PROVIDER_TITLES.server], + [/^(?:Request failed|Execution error|Usage data error|Unsupported operation):\s*/, ''], +]; + +/** One stop at the end, never two (the daemon's `end_sentence`). */ +function withStop(text: string): string { + return /[.!?]["')\]}]*$/.test(text) ? text : `${text}.`; +} + +/** + * A stored "Ran into this error" notice as the error row presents it, or null + * for any other text. The title names the kind of failure, the message is the + * reason in words followed by the daemon's advice, and a raw provider response + * (`… Response: {"error": …}`) moves under Details with the full error. + */ +export function presentStoredTurnError(text: string): ChatTurnErrorPresentation | null { + const notice = STORED_NOTICE.exec(text); + if (!notice) return null; + const compact = notice[1] !== undefined; + const rest = text.slice(notice[0].length); + const split = rest.lastIndexOf('\n\n'); + const errorText = (split === -1 ? rest : rest.slice(0, split)).trim(); + const advice = (split === -1 ? '' : rest.slice(split + 2)).trim(); + if (!errorText) return null; + + let title = compact ? 'Could not compact this chat' : 'Model request failed'; + let reason = errorText; + for (const [prefix, kindTitle] of STORED_TITLES) { + if (!prefix.test(errorText)) continue; + if (!compact && kindTitle) title = kindTitle; + const stripped = errorText.replace(prefix, ''); + // "Server error: 502." keeps its prefix: a bare status is not a reason. + if (/[A-Za-z]{3,}/.test(stripped)) reason = stripped; + break; + } + if (!compact && errorText.includes('insufficient_quota')) title = 'Model quota exceeded'; + + const body = /\s*\bResponse:\s*/.exec(reason); + if (body && body.index > 0) reason = reason.slice(0, body.index).trim(); + reason = reason.charAt(0).toUpperCase() + reason.slice(1); + const message = [withStop(reason), advice ? withStop(advice) : ''].filter(Boolean).join(' '); + return { title, message, details: body ? errorText : undefined }; +} + export function hasVisibleTurnErrorMessage(error: ChatTurnErrorData, messages: Message[]): boolean { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -168,37 +229,74 @@ export function ChatTurnError({ // A turn refused for its Crew access would be refused again: the way on is the chat's Crew bar. const canRetry = error.retryable && !!onRetry && !crewTurnRefusalOf(error); + // It sits a turn's width (24px) under the chat, as the agent's answer would + // have. + return ( + + ); +} + +/** + * The one error row (spec 3.6 "Errors"), for the live turn error and for the + * daemon's stored notice alike: a transcript row, not a toast pasted into the + * chat. A 16px warning glyph in danger ink, the title in `text-label`, the + * message in muted `text-secondary`, Retry as the one secondary button, and the + * raw provider payload behind the shared Disclosure. + * + * `alert` announces it: only for a failure that happens while the person + * watches, never for one read back from history. + */ +export function TurnErrorRow({ + presentation, + alert = false, + className, + testId, + onRetry, +}: { + presentation: ChatTurnErrorPresentation; + alert?: boolean; + className?: string; + testId?: string; + onRetry?: () => void; +}) { return ( -
    - - Retry - - ) : undefined - } - > +
    +
    ); } diff --git a/ui/desktop/src/components/conversation/ChatTurnStopped.tsx b/ui/desktop/src/components/conversation/ChatTurnStopped.tsx index 042c80cdf..764608b0f 100644 --- a/ui/desktop/src/components/conversation/ChatTurnStopped.tsx +++ b/ui/desktop/src/components/conversation/ChatTurnStopped.tsx @@ -1,4 +1,15 @@ import Stop from '../ui/Stop'; +import { TranscriptRow } from '../TranscriptRow'; +import { cn } from '../../utils'; +import { TURN_STOPPED_NOTICE } from './turnStoppedNotice'; + +/** The Stop button's own square, on the row's 16px glyph box. */ +function StopGlyph() { + return ; +} + +/** The words the daemon stores for a confirmed Stop, so both lines read the same. */ +const STOPPED = TURN_STOPPED_NOTICE; /** * F5 — what a Stop the daemon CONFIRMED did, said once and quietly. @@ -7,14 +18,13 @@ import Stop from '../ui/Stop'; * failed Stop gets a card because the user has something to do about it: the * turn may still be running. A confirmed one gets a line because nothing is left * to do. The turn is over, Send is back, and the only thing missing was the - * words. So it carries no surface, no status hue and no action — - * `text-supporting` in `--text-muted`, the settings vocabulary's status line - * (rule 6) — in the slot the failed Stop's card would take. + * words. So it carries no surface, no status hue and no action: the transcript's + * one row (`TranscriptRow`), static, in `text-secondary` muted ink. * - * It borrows the trailing activity line's geometry on purpose. That line + * It is the trailing activity line's own row on purpose. That line * (`TurnActivityIndicator`) is what the user was watching when they pressed * Stop, and this one lands where it was, at its height, with the Stop button's - * own glyph where the working pulse had been: the working line's last state + * own square where the working spinner had been: the working line's last state * rather than a new element. * * It is drawn from two places, and they are the same fact: @@ -23,29 +33,22 @@ import Stop from '../ui/Stop'; * turn (item 7, `turnStoppedNotice.ts`). That row is what a reload, another * window and History show, and in the window that pressed Stop the cancel * response puts it into the transcript directly. `inTranscript` drops the top - * margin, which the transcript's row wrapper already provides. + * margin and the entrance, which the transcript's row wrapper already + * provides. * - **BaseChat's tail slot**, from the store's transient * `ChatStreamSnapshot.stopConfirmed` — now only the fallback for a daemon that * returned no stored record, and never beside a transcript that already ends * on the stored line. It is retracted after `STOP_CONFIRMED_NOTICE_MS` or by * the next turn. * - * Reduced motion is handled by the global reset in `styles/main.css`. + * Live in the tail slot it enters with the shared `.br-enter` (its own + * reduced-motion rest in `main.css`). */ export function ChatTurnStopped({ inTranscript = false }: { inTranscript?: boolean }) { return ( -
    -
    - - Stopped. +
    +
    +
    ); diff --git a/ui/desktop/src/components/conversation/SearchBar.test.tsx b/ui/desktop/src/components/conversation/SearchBar.test.tsx index 1c625c1b5..e0711f574 100644 --- a/ui/desktop/src/components/conversation/SearchBar.test.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import SearchBar from './SearchBar'; +import { findInChatCopy } from './searchCopy'; describe('SearchBar', () => { afterEach(() => { @@ -9,43 +10,91 @@ describe('SearchBar', () => { Reflect.deleteProperty(window, 'matchMedia'); }); - it('renders as a centered floating search surface', () => { + it('renders as a floating overlay surface, not a row in the transcript', () => { render(); const surface = screen.getByTestId('conversation-search-bar'); - // A floating search panel is a popover: 16px radius, --shadow-popover, and an - // opaque fill (no backdrop blur). See design.md §3.4, §3.5 and the anti-patterns. - expect(surface).toHaveClass('max-w-[720px]'); - expect(surface).toHaveClass('rounded-2xl'); - expect(surface).toHaveClass('shadow-popover'); + // A floating search surface: the container radius (12px; 16px is the + // preview sheet's alone), the chat measure as its ceiling, the one + // floating-surface recipe (hairline + --shadow-popover, lifted in dark), + // and an opaque fill (no backdrop blur). + expect(surface).toHaveClass('max-w-measure-chat'); + expect(surface).toHaveClass('rounded-container'); + expect(surface).not.toHaveClass('rounded-2xl'); + expect(surface).toHaveClass('biorouter-popover-surface'); + expect(surface).toHaveClass('bg-background-default'); expect(surface).not.toHaveClass('backdrop-blur-md'); + // The overlay class positions it (styles/search.css); nothing sticky. + const overlay = surface.parentElement as HTMLElement; + expect(overlay).toHaveClass('br-find-overlay'); + expect(overlay.className).not.toMatch(/sticky|mb-4/); + expect(overlay).toHaveAttribute('data-state', 'open'); }); it('keeps search behavior while using the compact surface', () => { const onSearch = vi.fn(); render(); - fireEvent.change(screen.getByPlaceholderText('Search chat...'), { + fireEvent.change(screen.getByPlaceholderText(findInChatCopy.placeholder), { target: { value: 'agent' }, }); expect(screen.getByDisplayValue('agent')).toBeInTheDocument(); }); - it('cancels an in-progress close when the user invokes find again', () => { + it('names its controls and puts the shortcuts in tooltips, never in title=', () => { + render(); + + expect(screen.getByRole('textbox', { name: findInChatCopy.inputLabel })).toBeInTheDocument(); + for (const name of [ + findInChatCopy.matchCase, + findInChatCopy.previous, + findInChatCopy.next, + findInChatCopy.close, + ]) { + const button = screen.getByRole('button', { name }); + expect(button).not.toHaveAttribute('title'); + } + // The ⌘F load-all tip is the field's InfoTip, read as a description. + expect( + screen.getByRole('button', { name: `About ${findInChatCopy.helpLabel}` }) + ).toHaveAccessibleDescription(findInChatCopy.help()); + }); + + it('closes on the close keyframe ending, not on a timer', () => { vi.useFakeTimers(); const onClose = vi.fn(); render(); - fireEvent.click(screen.getByTitle('Close (Esc)')); + fireEvent.click(screen.getByRole('button', { name: findInChatCopy.close })); + const overlay = screen.getByTestId('conversation-search-bar').parentElement as HTMLElement; + expect(overlay).toHaveAttribute('data-state', 'closed'); + + // Time alone closes nothing. + act(() => vi.advanceTimersByTime(1000)); + expect(onClose).not.toHaveBeenCalled(); + + // A child's animation ending is not the bar's. + fireEvent.animationEnd(screen.getByTestId('conversation-search-bar')); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.animationEnd(overlay); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('cancels an in-progress close when the user invokes find again', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: findInChatCopy.close })); fireEvent.keyDown(window, { key: 'f', metaKey: true }); - act(() => vi.advanceTimersByTime(150)); + const overlay = screen.getByTestId('conversation-search-bar').parentElement as HTMLElement; + expect(overlay).toHaveAttribute('data-state', 'open'); + // The opening keyframe ending closes nothing. + fireEvent.animationEnd(overlay); expect(onClose).not.toHaveBeenCalled(); - expect(screen.getByTestId('conversation-search-bar').parentElement).toHaveClass( - 'search-bar-enter' - ); }); it('closes immediately when reduced motion is requested', () => { @@ -56,21 +105,77 @@ describe('SearchBar', () => { }); render(); - fireEvent.click(screen.getByTitle('Close (Esc)')); + fireEvent.click(screen.getByRole('button', { name: findInChatCopy.close })); expect(onClose).toHaveBeenCalledTimes(1); }); - it('clears the delayed close when it unmounts', () => { - vi.useFakeTimers(); + it('Escape in the field starts the close', () => { const onClose = vi.fn(); - const { unmount } = render(); + render(); - fireEvent.click(screen.getByTitle('Close (Esc)')); - unmount(); - act(() => vi.advanceTimersByTime(150)); + fireEvent.keyDown(screen.getByRole('textbox', { name: findInChatCopy.inputLabel }), { + key: 'Escape', + }); + const overlay = screen.getByTestId('conversation-search-bar').parentElement as HTMLElement; + expect(overlay).toHaveAttribute('data-state', 'closed'); + fireEvent.animationEnd(overlay); + expect(onClose).toHaveBeenCalledTimes(1); + }); - expect(onClose).not.toHaveBeenCalled(); + // Vision V2-15: closing the bar with Escape left focus on . + describe('focus after closing', () => { + const field = () => screen.getByRole('textbox', { name: findInChatCopy.inputLabel }); + + it('Escape gives focus back to the element that had it when the bar opened', () => { + const before = document.createElement('button'); + document.body.appendChild(before); + try { + before.focus(); + render(); + expect(document.activeElement).toBe(field()); + + fireEvent.keyDown(field(), { key: 'Escape' }); + expect(document.activeElement).toBe(before); + } finally { + before.remove(); + } + }); + + it("falls back to this chat's composer when nothing had focus, and never another pane's", () => { + render( +
    +
    + + ` + ); + return await tab.evaluate(() => { + const el = document.querySelector('textarea')!; + return { height: el.getBoundingClientRect().height, resize: getComputedStyle(el).resize }; + }); + } finally { + await tab.close(); + } + }; + const withRule = await measure(WORKFLOWS_CSS); + expect(withRule.height, 'editor text area height').toBeGreaterThanOrEqual(400); + expect(withRule.resize).toBe('none'); + // The instrument: with only the primitive's rule the same element is the 74px it was. + const without = await measure(''); + expect(without.height, 'without the authored rule').toBeLessThan(100); + }); +}); diff --git a/ui/desktop/src/contexts/ThemeContext.browserBridge.test.tsx b/ui/desktop/src/contexts/ThemeContext.browserBridge.test.tsx index c94ec01d2..59e9f601d 100644 --- a/ui/desktop/src/contexts/ThemeContext.browserBridge.test.tsx +++ b/ui/desktop/src/contexts/ThemeContext.browserBridge.test.tsx @@ -83,7 +83,7 @@ describe.each(bridges)('Appearance controls on %s', (_name, makeBridge) => { expect(() => fireEvent.click(screen.getByTestId('dark-mode-button'))).not.toThrow(); expect(capture.errors).toEqual([]); expect(document.documentElement.classList.contains('dark')).toBe(true); - expect(screen.getByTestId('dark-mode-button').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('dark-mode-button').getAttribute('aria-checked')).toBe('true'); expect(() => fireEvent.click(screen.getByTestId('system-mode-button'))).not.toThrow(); expect(() => fireEvent.click(screen.getByTestId('dark-mode-button'))).not.toThrow(); @@ -94,7 +94,7 @@ describe.each(bridges)('Appearance controls on %s', (_name, makeBridge) => { document.documentElement.classList.remove('dark', 'light'); mount(); expect(document.documentElement.classList.contains('dark')).toBe(true); - expect(screen.getByTestId('dark-mode-button').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('dark-mode-button').getAttribute('aria-checked')).toBe('true'); expect(capture.errors).toEqual([]); }); diff --git a/ui/desktop/src/contexts/ThemeContext.tsx b/ui/desktop/src/contexts/ThemeContext.tsx index e72edfbc4..4c40d334f 100644 --- a/ui/desktop/src/contexts/ThemeContext.tsx +++ b/ui/desktop/src/contexts/ThemeContext.tsx @@ -1,4 +1,5 @@ import { useFontSize } from '../hooks/useFontSize'; +import { suspendTransitionsForThemeChange } from '../styles/motion'; import { THEME_FAMILY_IDS } from '../styles/themes.generated'; import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; @@ -95,9 +96,21 @@ function saveThemePreference(preference: ThemePreference): void { } function applyThemeToDocument(theme: ResolvedTheme): void { + const root = document.documentElement; + // A theme change is a jump, not an element changing: still every transition for the frame it + // lands in, or buttons and rows walk to the new colours over 95 to 125ms (vision V4-15). Only + // when the mode really changes, so a mount and the effect that follows a click start none. + // + // "The mode" is whether the page is dark, which is the one class the stylesheet reads. Asking + // whether `theme`'s own class is present instead made a light page with no `light` class (a + // bare , or one stamped by an older index.html) look like a change on every mount, and + // the frame that stilled every transition included the boot splash's 260ms fade-out (vision + // FR-R4-1). index.html now stamps `light` too; this does not depend on it. + const isDarkNow = root.classList.contains('dark'); + if (isDarkNow !== (theme === 'dark')) suspendTransitionsForThemeChange(root); const toRemove = theme === 'dark' ? 'light' : 'dark'; - document.documentElement.classList.add(theme); - document.documentElement.classList.remove(toRemove); + root.classList.add(theme); + root.classList.remove(toRemove); } function loadThemeFamily(): ThemeFamily { @@ -113,7 +126,10 @@ function applyFamilyToDocument(family: ThemeFamily): void { // `data-theme` sits alongside the `.dark`/`.light` class; main.css scopes the // Alma Mater token overrides to `[data-theme='alma-mater']`. Parchment has no // matching rules, so the bare `:root`/`.dark` defaults render. - document.documentElement.setAttribute('data-theme', family); + const root = document.documentElement; + // Same as the mode: a palette change is a jump (vision V4-15), and only a real change counts. + if (root.getAttribute('data-theme') !== family) suspendTransitionsForThemeChange(root); + root.setAttribute('data-theme', family); } interface ThemeProviderProps { diff --git a/ui/desktop/src/contexts/themeSwitchSuspension.test.tsx b/ui/desktop/src/contexts/themeSwitchSuspension.test.tsx new file mode 100644 index 000000000..138a8fe35 --- /dev/null +++ b/ui/desktop/src/contexts/themeSwitchSuspension.test.tsx @@ -0,0 +1,157 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ThemeProvider } from './ThemeContext'; +import ThemeSelector from '../components/BioRouterSidebar/ThemeSelector'; +import ThemeFamilySelector from '../components/BioRouterSidebar/ThemeFamilySelector'; +import { THEME_SWITCHING_CLASS } from '../styles/motion'; + +/** + * A theme change is a jump: the page snaps its grounds, so every control that eases its colour + * would otherwise walk from the old theme to the new one over 95 to 125ms, a dark button on a + * white page (vision V4-15). `ThemeContext` puts `biorouter-theme-switching` on for the + * frame the mode or the palette changes in, and only then. + * + * jsdom runs no style or transitions, so what these pin is the wiring: the class is on in + * the same task as the flip, it comes off two animation frames later, and a mount or the effect + * that follows a click (the value is already applied) starts nothing. The rule itself and the + * helper are measured in a real browser by `styles/themeSwitch.browser.test.ts`. + */ +const root = () => document.documentElement; +const switching = () => root().classList.contains(THEME_SWITCHING_CLASS); + +let frames: ((time: number) => void)[] = []; +const runFrame = () => { + const due = frames; + frames = []; + due.forEach((callback) => callback(0)); +}; + +function renderProvider() { + return render( + + + + + ); +} + +describe('ThemeContext stills transitions for a theme change', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem('use_system_theme', 'false'); + localStorage.setItem('theme', 'light'); + localStorage.setItem('theme_family', 'parchment'); + root().classList.remove('dark', 'light', THEME_SWITCHING_CLASS); + root().removeAttribute('data-theme'); + frames = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + root().classList.remove(THEME_SWITCHING_CLASS); + }); + + it('suspends for the frame a mode click lands in, and only until the second frame', () => { + renderProvider(); + act(() => { + runFrame(); + runFrame(); + }); + expect(switching()).toBe(false); + + fireEvent.click(screen.getByTestId('dark-mode-button')); + expect(root().classList.contains('dark')).toBe(true); + expect(switching()).toBe(true); + + act(() => runFrame()); + expect(switching()).toBe(true); + act(() => runFrame()); + expect(switching()).toBe(false); + }); + + it('suspends for a palette click', () => { + renderProvider(); + act(() => { + runFrame(); + runFrame(); + }); + expect(switching()).toBe(false); + + fireEvent.click(screen.getByTestId('theme-family-alma-mater-button')); + expect(root().getAttribute('data-theme')).toBe('alma-mater'); + expect(switching()).toBe(true); + act(() => { + runFrame(); + runFrame(); + }); + expect(switching()).toBe(false); + }); + + it('starts nothing when a light page mounts, with or without a light class on (FR-R4-1)', () => { + // The boot splash fades out in the frame the app mounts. A mount that stills every transition + // on stills that fade too, so a light page must not look like a change from nothing. + // index.html stamps `light` itself now; a bare is the same page and must read the same. + for (const stamped of [[], ['light']]) { + root().className = ''; + stamped.forEach((name) => root().classList.add(name)); + root().setAttribute('data-theme', 'parchment'); + frames = []; + const view = renderProvider(); + expect(switching(), `classes ${JSON.stringify(stamped)}`).toBe(false); + expect(frames, `classes ${JSON.stringify(stamped)}`).toHaveLength(0); + expect(root().classList.contains('light')).toBe(true); + expect(root().classList.contains('dark')).toBe(false); + view.unmount(); + } + }); + + it('starts nothing when a dark page mounts over the dark class index.html stamped', () => { + localStorage.setItem('theme', 'dark'); + root().classList.add('dark'); + root().setAttribute('data-theme', 'parchment'); + renderProvider(); + expect(switching()).toBe(false); + expect(frames).toHaveLength(0); + }); + + it('still suspends when a mount really lands on the other mode than the page was stamped with', () => { + // The page says dark and the saved choice is light (a choice made in another window, say): + // the mode does change on mount, and a change is a jump. + root().classList.add('dark'); + root().setAttribute('data-theme', 'parchment'); + renderProvider(); + expect(root().classList.contains('light')).toBe(true); + expect(switching()).toBe(true); + act(() => { + runFrame(); + runFrame(); + }); + expect(switching()).toBe(false); + + // And the other way: a bare is a light page, so a dark choice over it is a change. + root().className = ''; + localStorage.setItem('theme', 'dark'); + cleanup(); + renderProvider(); + expect(root().classList.contains('dark')).toBe(true); + expect(switching()).toBe(true); + }); + + it('starts nothing when the mode or the palette is already what the page shows', () => { + root().classList.add('light'); + root().setAttribute('data-theme', 'parchment'); + renderProvider(); + expect(switching()).toBe(false); + expect(frames).toHaveLength(0); + + // Choosing what is already chosen applies the same class and attribute again. + fireEvent.click(screen.getByTestId('light-mode-button')); + fireEvent.click(screen.getByTestId('theme-family-parchment-button')); + expect(switching()).toBe(false); + expect(frames).toHaveLength(0); + }); +}); diff --git a/ui/desktop/src/dragGhostWindow.test.ts b/ui/desktop/src/dragGhostWindow.test.ts index 0ec969a2e..9dda55422 100644 --- a/ui/desktop/src/dragGhostWindow.test.ts +++ b/ui/desktop/src/dragGhostWindow.test.ts @@ -286,6 +286,22 @@ describe('GHOST_PROBE_SCRIPT', () => { expect(GHOST_PROBE_SCRIPT).toContain('data-grab-y'); expect(GHOST_PROBE_SCRIPT).toContain('--accent-bar'); }); + + it('folds the tab’s transparent 1px edge into the inset, so the label lands where the DOM ghost’s does', () => { + // The tab keeps a transparent 1px border (R-27) with 10px of padding; the + // ghost window draws no border, so its padding must be 11px. + const el = document.createElement('div'); + el.className = 'br-tab-ghost'; + el.style.paddingLeft = '10px'; + el.style.borderLeft = '1px solid transparent'; + document.body.append(el); + try { + const probed = (0, eval)(GHOST_PROBE_SCRIPT) as { style: { paddingX: string } }; + expect(probed.style.paddingX).toBe('11px'); + } finally { + el.remove(); + } + }); }); // ── The lifecycle ────────────────────────────────────────────────────────── @@ -722,3 +738,32 @@ describe('the main-process half, read from main.ts', () => { expect(released).toBeLessThan(firstReturn); }); }); + +/** + * Spec 2.1 and 3.16: the ghost's fallback look speaks the app's one UI stack + * (`--font-body`, the website's Arial-first stack) and draws the app's chat + * glyph, so a torn-off tab does not change face or icon the moment the probe + * comes back empty. + */ +describe('the ghost fallback matches the app', () => { + it('uses the --font-body stack from main.css, byte for byte (whitespace aside)', () => { + const css = readFileSync(join(__dirname, 'styles/main.css'), 'utf8'); + const match = css.match(/--font-body:\s*([^;]+);/); + expect(match, 'no --font-body in main.css').not.toBeNull(); + const normalise = (value: string) => value.replace(/\s+/g, ' ').trim(); + expect(normalise(DEFAULT_GHOST_STYLE.fontFamily)).toBe(normalise(match![1])); + // And it survives the sanitiser it is the fallback for. + expect(sanitizeCssValue(DEFAULT_GHOST_STYLE.fontFamily, 'x')).toBe( + normalise(DEFAULT_GHOST_STYLE.fontFamily) + ); + }); + + it('draws the chat bubble the strip draws, at the app stroke', () => { + const html = ghostWindowHtml( + { ...DEFAULT_GHOST_SPEC, title: 'Cohort query' }, + { transparent: true } + ); + expect(html).toContain('stroke-width="1.5"'); + expect(html).toContain('M3 8a3.5 3.5 0 0 1 3.5-3.5h11A3.5 3.5 0 0 1 21 8v5.5'); + }); +}); diff --git a/ui/desktop/src/dragGhostWindow.ts b/ui/desktop/src/dragGhostWindow.ts index afd464c7f..4f50d34eb 100644 --- a/ui/desktop/src/dragGhostWindow.ts +++ b/ui/desktop/src/dragGhostWindow.ts @@ -54,13 +54,13 @@ export interface GhostStyle { * The raw `--accent-bar` token, which the dashed detach outline is mixed from. * * NOT the ghost's computed `outline-color`: `.br-tab-ghost` TRANSITIONS - * `outline-color` over `--motion-fast`, and the probe runs on the very frame + * `outline-color` over `--dur-fast`, and the probe runs on the very frame * `data-detach` flips, so the computed value at that instant is still mid-fade * — usually fully transparent. Reading the token instead reads the value the * transition is heading towards. */ accent: string; - /** Resolved `border-radius` (`--radius-inner`). */ + /** Resolved `border-radius` (`--radius-element`, the tab's corner). */ radius: string; /** Resolved `font-family` (`--font-sans`), quotes and all. */ fontFamily: string; @@ -68,7 +68,12 @@ export interface GhostStyle { fontWeight: string; /** Resolved `box-shadow` (`--shadow-popover`). Dropped on opaque platforms. */ shadow: string; - /** Resolved horizontal padding and icon gap, so the chip's metrics match. */ + /** + * Resolved horizontal inset and icon gap, so the chip's metrics match. The inset + * is the padding PLUS the tab's 1px edge (transparent, R-27): this page draws no + * edge, so the edge's width is folded into its padding and the label lands at + * the same x as in the DOM ghost. + */ paddingX: string; gap: string; } @@ -129,13 +134,16 @@ export const DEFAULT_GHOST_STYLE: GhostStyle = { background: '#ffffff', color: '#2a2520', accent: '#cf6d47', - radius: '4px', - fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', + radius: '8px', + // The app's one UI stack, `--font-body` in main.css (spec 2.1; the website's + // Arial-first stack). `dragGhostWindow.test.ts` pins the two together. + fontFamily: + "Arial, 'Helvetica Neue', Helvetica, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", fontSize: '13px', fontWeight: '400', shadow: '0 8px 24px rgba(0, 0, 0, 0.18)', paddingX: '11px', - gap: '7px', + gap: '8px', }; export const DEFAULT_GHOST_SPEC: GhostSpec = { @@ -295,7 +303,7 @@ export function escapeHtml(value: string): string { } /** - * `MessageSquare` from `components/icons/app-icons`, inlined. + * `ChatBubble` (`ENTITY_ICONS.chat`) from `components/icons/app-icons`, inlined. * * The design doc's Phase 4b sketch reuses the real component at a `#/drag-ghost` * route; that needs a route in `App.tsx`, and a second full renderer boot per @@ -303,10 +311,10 @@ export function escapeHtml(value: string): string { * cost — and this window has no React, no preload and no app bundle, which is * also what keeps its creation cheap enough to sit inside a pointer gesture. */ -const MESSAGE_SQUARE_SVG = +const CHAT_BUBBLE_SVG = ''; + ' stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + + ''; /** * The ghost window's whole document. @@ -356,7 +364,7 @@ export function ghostWindowHtml(spec: GhostSpec, options: { transparent: boolean .label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } svg { flex: none; } -
    ${MESSAGE_SQUARE_SVG}${escapeHtml(spec.title)}
    `; +
    ${CHAT_BUBBLE_SVG}${escapeHtml(spec.title)}
    `; } /** @@ -415,7 +423,7 @@ export const GHOST_PROBE_SCRIPT = `(() => { fontSize: style.fontSize, fontWeight: style.fontWeight, shadow: style.boxShadow, - paddingX: style.paddingLeft, + paddingX: (parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth || '0')) + 'px', gap: style.columnGap, }, }; diff --git a/ui/desktop/src/hooks/chatStreamStore.resumeMetadata.test.ts b/ui/desktop/src/hooks/chatStreamStore.resumeMetadata.test.ts new file mode 100644 index 000000000..018ad23c1 --- /dev/null +++ b/ui/desktop/src/hooks/chatStreamStore.resumeMetadata.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatStreamRegistry } from './chatStreamStore'; +import type { Message, Session } from '../api'; +import { resumeAgent, updateFromSession } from '../api'; + +/** + * Ruling 28 (V2-R9-3): the agent-loading resume answers without the transcript. + * + * Opening a chat sends `POST /agent/resume` twice, on purpose: the paint resume + * (`load_model_and_extensions: false`) carries the transcript, and + * `ensureAgentLoaded`'s resume (`true`) starts the agent. The daemon used to + * answer the second one with the whole transcript as well, which on a large + * chat was megabytes nobody read. It now answers with the chat's row and + * `conversation: null` (`read_resume_session` in `routes/agent.rs`). + * + * That is safe only while nothing in the store reads `session.conversation` + * from that second answer. These tests hand the store a row that records every + * property read from it and assert `conversation` is never one of them, on the + * cold open and on the two paths where the agent load is the only resume (a + * transcript served from the LRU, a controller reused with its transcript up). + * A spread, a cache write or a snapshot of that row would read it and fail here. + */ +vi.mock('../api', () => ({ + cancelTurn: vi.fn(), + editMessage: vi.fn(), + getSession: vi.fn(async () => ({ data: null })), + interrupt: vi.fn(), + listSessions: vi.fn(async () => ({ data: { sessions: [] } })), + reply: vi.fn(), + resumeAgent: vi.fn(), + updateFromSession: vi.fn(async () => ({ data: {} })), + updateSessionUserWorkflowValues: vi.fn(async () => ({ data: {} })), +})); + +vi.mock('../utils/continuationLease', () => ({ + abandonContinuationLease: vi.fn(async () => undefined), + getContinuationOwnerId: vi.fn(() => 'test-window-owner'), + recoverContinuationGroup: vi.fn(), +})); + +vi.mock('../utils/userAction', async (importOriginal) => ({ + ...(await importOriginal()), + userActionHeaders: async () => ({ 'X-User-Action': 'test-key' }), +})); + +function message(id: string, text: string): Message { + return { + id, + role: 'user', + created: 1, + content: [{ type: 'text', text }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +/** What the paint resume answers with: the row and its transcript. */ +function paintedSession(id: string, conversation: Message[]): Session { + return { + id, + name: `Session ${id}`, + working_dir: '/tmp', + conversation, + message_count: conversation.length, + total_tokens: 0, + created_at: '', + updated_at: '', + extension_data: {}, + user_set_name: false, + } as Session; +} + +/** + * What the agent load answers with now: the same row, `conversation: null`, + * behind a proxy that records each property the store reads from it. + */ +function metadataRow(id: string, messageCount: number, reads: string[]): Session { + const row = { ...paintedSession(id, []), conversation: null, message_count: messageCount }; + return new Proxy(row, { + get(target, key, receiver) { + if (typeof key === 'string') reads.push(key); + return Reflect.get(target, key, receiver); + }, + }) as Session; +} + +type ResumeBody = { body: { load_model_and_extensions: boolean } }; + +function answerResumes(id: string, transcript: Message[], reads: string[]) { + vi.mocked(resumeAgent).mockImplementation(async (options: unknown) => { + const { load_model_and_extensions } = (options as ResumeBody).body; + if (!load_model_and_extensions) { + return { data: { session: paintedSession(id, transcript), initializing: false } } as never; + } + return { + data: { + session: metadataRow(id, transcript.length, reads), + initializing: false, + extension_results: [], + }, + } as never; + }); +} + +function agentLoads(): number { + return vi + .mocked(resumeAgent) + .mock.calls.filter(([options]) => (options as ResumeBody).body.load_model_and_extensions) + .length; +} + +beforeEach(() => { + vi.mocked(resumeAgent).mockReset(); + vi.mocked(updateFromSession).mockClear(); + Object.assign(window, { + electron: { + openExternal: vi.fn(async () => undefined), + showNotification: vi.fn(), + logInfo: vi.fn(), + }, + }); +}); + +describe('the agent-loading resume answers without the transcript', () => { + it('opens a chat without reading a transcript from the agent load', async () => { + const transcript = [message('m1', 'first'), message('m2', 'second')]; + const reads: string[] = []; + answerResumes('rm-cold', transcript, reads); + + const controller = new ChatStreamRegistry().getController('rm-cold'); + await controller.loadSession(); + await vi.waitFor(() => expect(controller.getSnapshot().agentReady).toBe(true)); + + expect(agentLoads()).toBe(1); + // The instrument works: the store did read the row it was handed. + expect(reads).toContain('id'); + expect(reads).not.toContain('conversation'); + // The model was bound from the row's id, as before. + expect(updateFromSession).toHaveBeenCalledWith( + expect.objectContaining({ body: { session_id: 'rm-cold' } }) + ); + // The transcript the paint resume carried is what the chat shows, and the + // row it shows is the paint resume's, not the agent load's. + const snapshot = controller.getSnapshot(); + expect(snapshot.messages.map((m) => m.id)).toEqual(['m1', 'm2']); + expect(snapshot.session?.conversation).toHaveLength(2); + expect(snapshot.turnError).toBeUndefined(); + }); + + it('keeps a transcript served from the cache when the agent load is the only resume', async () => { + const transcript = [message('c1', 'cached one'), message('c2', 'cached two')]; + const firstReads: string[] = []; + answerResumes('rm-cached', transcript, firstReads); + const first = new ChatStreamRegistry().getController('rm-cached'); + await first.loadSession(); + await vi.waitFor(() => expect(first.getSnapshot().agentReady).toBe(true)); + + vi.mocked(resumeAgent).mockClear(); + const reads: string[] = []; + answerResumes('rm-cached', transcript, reads); + const fresh = new ChatStreamRegistry().getController('rm-cached'); + await fresh.loadSession(); + await vi.waitFor(() => expect(fresh.getSnapshot().agentReady).toBe(true)); + + // The LRU painted it, so the agent load was the only resume sent. + expect(vi.mocked(resumeAgent).mock.calls).toHaveLength(1); + expect(agentLoads()).toBe(1); + expect(reads).toContain('id'); + expect(reads).not.toContain('conversation'); + expect(firstReads).not.toContain('conversation'); + expect(fresh.getSnapshot().messages.map((m) => m.id)).toEqual(['c1', 'c2']); + }); + + it("keeps a queued child's painted transcript while its reused controller loads the agent", async () => { + // A delegated child still queued: the daemon answers both resumes with + // `initializing: true`, the agent load with the row alone. Once the child + // is ready, the reused controller (transcript up, agent not loaded) runs + // the agent load again, and that is the only resume it sends. + const transcript = [message('q1', 'delegated task')]; + const reads: string[] = []; + let childReady = false; + const childRow = (row: Session): Session => { + (row as { session_type?: string }).session_type = 'sub_agent'; + return row; + }; + vi.mocked(resumeAgent).mockImplementation(async (options: unknown) => { + const { load_model_and_extensions } = (options as ResumeBody).body; + if (!load_model_and_extensions) { + return { + data: { + session: childRow(paintedSession('rm-queued', transcript)), + initializing: !childReady, + }, + } as never; + } + return { + data: { + session: childRow(metadataRow('rm-queued', transcript.length, reads)), + initializing: !childReady, + extension_results: childReady ? [] : null, + }, + } as never; + }); + + const controller = new ChatStreamRegistry().getController('rm-queued'); + await controller.loadSession(); + await vi.waitFor(() => expect(agentLoads()).toBe(1)); + expect(controller.getSnapshot().agentReady).toBe(false); + + childReady = true; + vi.mocked(resumeAgent).mockClear(); + await controller.loadSession(); + await vi.waitFor(() => expect(controller.getSnapshot().agentReady).toBe(true)); + + expect(vi.mocked(resumeAgent).mock.calls).toHaveLength(1); + expect(agentLoads()).toBe(1); + expect(reads).toContain('id'); + expect(reads).not.toContain('conversation'); + expect(controller.getSnapshot().messages.map((m) => m.id)).toEqual(['q1']); + }); +}); diff --git a/ui/desktop/src/hooks/use-text-animator.test.tsx b/ui/desktop/src/hooks/use-text-animator.test.tsx index 68b9372e2..107fc70f1 100644 --- a/ui/desktop/src/hooks/use-text-animator.test.tsx +++ b/ui/desktop/src/hooks/use-text-animator.test.tsx @@ -1,16 +1,16 @@ import { render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DUR, EASE_OUT } from '../styles/motion'; import { useTextAnimator } from './use-text-animator'; /** - * The unroll's contract is about the FIRST FRAME, so that is what these pin. + * The arrival's contract is about the FIRST FRAME and about its size. * - * The greeting is rendered with its text at full opacity and the animator is - * what hides the characters so they can arrive. That makes *when* the animator - * runs the whole behaviour: a turn taken after the browser has painted shows - * the finished sentence, then blanks it, then unrolls it — the answer, a blink - * of nothing, then the answer again. A layout effect runs before the paint, so - * the characters are already hidden in the first frame the user ever sees. + * The greeting is rendered with its text at full opacity and the animation is + * what holds it at 0 until it fades in. That makes *when* the hook runs the + * whole behaviour: a turn taken after the browser has painted shows the + * finished sentence, then blanks it, then fades it in. A layout effect runs + * before the paint, so the animation is already running in the first frame. * * jsdom never paints, so none of this can be observed the way a user observes * it. What jsdom *can* answer exactly is the question underneath: had the work @@ -19,7 +19,15 @@ import { useTextAnimator } from './use-text-animator'; * hundred milliseconds later. */ -const started: Array<{ el: Element; delay: number }> = []; +const started: Array<{ + el: Element; + delay: number; + duration?: number; + easing?: string; + fill?: string; + keyframes: Record[]; +}> = []; +let cancelled = 0; function Harness({ text, enabled = true }: { text: string; enabled?: boolean }) { const ref = useTextAnimator({ text, enabled }); @@ -32,16 +40,29 @@ function Harness({ text, enabled = true }: { text: string; enabled?: boolean }) beforeEach(() => { started.length = 0; + cancelled = 0; vi.useFakeTimers(); // jsdom implements no Web Animations API. The stub records what was started // and returns just enough of an `Animation` for the animator to drive. Element.prototype.animate = function ( this: Element, - _keyframes: unknown, - options: { delay?: number } + keyframes: Record[], + options: { delay?: number; duration?: number; easing?: string; fill?: string } ) { - started.push({ el: this, delay: options?.delay ?? 0 }); - return { cancel: () => {}, onfinish: null } as unknown as globalThis.Animation; + started.push({ + el: this, + delay: options?.delay ?? 0, + duration: options?.duration, + easing: options?.easing, + fill: options?.fill, + keyframes, + }); + return { + cancel: () => { + cancelled += 1; + }, + onfinish: null, + } as unknown as globalThis.Animation; } as unknown as Element['animate']; window.matchMedia = ((q: string) => ({ matches: false, @@ -56,24 +77,42 @@ afterEach(() => { }); describe('useTextAnimator', () => { - it('has hidden the characters and started the unroll before the first paint', () => { + it('has started the fade before the first paint', () => { const { container } = render(); - // NOTHING is advanced between the render and these assertions. Anything the - // animator defers — a `setTimeout`, or simply being a `useEffect` that the - // browser paints ahead of — leaves both of these unsatisfied. - expect(started.length).toBeGreaterThan(0); + // NOTHING is advanced between the render and this assertion. Anything the + // hook defers (a `setTimeout`, or simply being a `useEffect` that the + // browser paints ahead of) leaves it unsatisfied. + expect(started).toHaveLength(1); + expect(started[0].el).toBe(container.querySelector('span')); + // `backwards` holds the first keyframe only while the fade runs. + expect(started[0].fill).toBe('backwards'); + }); - const chars = container.querySelectorAll('.char'); - expect(chars.length).toBeGreaterThan(0); - expect([...chars].every((c) => c.style.opacity === '0')).toBe(true); + /** + * One element, one fade (spec 2.4: a stagger animates at most five items). + * The per-character unroll it replaced split a 44-letter question into 44 + * staggered elements that took about 950ms to finish. + */ + it('fades the whole line once: opacity only, the fast-max tier on the one curve', () => { + const { container } = render( + + ); + expect(started).toHaveLength(1); + const [fade] = started; + expect(fade.duration).toBe(DUR.fastMax); + expect(fade.easing).toBe(EASE_OUT); + expect(fade.delay).toBe(0); + expect(fade.keyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]); + // No split: the text stays one node, so it is read as a sentence. + expect(container.querySelector('span')?.childNodes).toHaveLength(1); + expect(container.querySelector('span')?.getAttribute('style')).toBeNull(); }); - it('staggers the characters rather than revealing them together', () => { - render(); - const delays = started.map((s) => s.delay); - expect(new Set(delays).size).toBeGreaterThan(1); - expect(delays).toEqual([...delays].sort((a, b) => a - b)); + it('cancels the fade when the heading unmounts, leaving nothing behind', () => { + const { unmount } = render(); + unmount(); + expect(cancelled).toBe(1); }); /** diff --git a/ui/desktop/src/hooks/use-text-animator.tsx b/ui/desktop/src/hooks/use-text-animator.tsx index de378a65b..1756fdb03 100644 --- a/ui/desktop/src/hooks/use-text-animator.tsx +++ b/ui/desktop/src/hooks/use-text-animator.tsx @@ -1,156 +1,19 @@ -import SplitType from 'split-type'; import { useLayoutEffect, useRef } from 'react'; - -interface TextSplitterOptions { - resizeCallback?: () => void; - splitTypeTypes?: ('lines' | 'words' | 'chars')[]; -} - -// Class to split text into lines, words, and characters for animation -export class TextSplitter { - textElement: HTMLElement; - onResize: (() => void) | null; - splitText: SplitType; - previousContainerWidth: number | null = null; - - constructor(textElement: HTMLElement, options: TextSplitterOptions = {}) { - if (!textElement || !(textElement instanceof HTMLElement)) { - throw new Error('Invalid text element provided.'); - } - - const { resizeCallback, splitTypeTypes } = options; - this.textElement = textElement; - this.onResize = typeof resizeCallback === 'function' ? resizeCallback : null; - - const splitOptions = splitTypeTypes ? { types: splitTypeTypes } : {}; - this.splitText = new SplitType(this.textElement, splitOptions); - - if (this.onResize) { - this.initResizeObserver(); - } - } - - initResizeObserver() { - // Use a simpler approach to avoid type issues - const resizeObserver = new ResizeObserver(() => { - // Just check the current width directly from the element - if (this.textElement) { - const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width); - - if (this.previousContainerWidth && this.previousContainerWidth !== currentWidth) { - this.splitText.split({ types: ['chars'] }); - this.onResize?.(); - } - - this.previousContainerWidth = currentWidth; - } - }); - - resizeObserver.observe(this.textElement); - } - - getLines(): HTMLElement[] { - return this.splitText.lines ?? []; - } - - getChars(): HTMLElement[] { - return this.splitText.chars ?? []; - } -} - -export class TextAnimator { - textElement: HTMLElement; - splitter!: TextSplitter; - originalChars!: string[]; - activeAnimations: globalThis.Animation[] = []; - activeTimeouts: ReturnType[] = []; - - constructor(textElement: HTMLElement) { - if (!textElement || !(textElement instanceof HTMLElement)) { - throw new Error('Invalid text element provided.'); - } - - this.textElement = textElement; - this.splitText(); - } - - private splitText() { - this.splitter = new TextSplitter(this.textElement, { - splitTypeTypes: ['words', 'chars'], - }); - this.originalChars = this.splitter.getChars().map((char) => char.textContent || ''); - } - - animate() { - this.reset(); - - const chars = this.splitter.getChars(); - - chars.forEach((char, position) => { - char.style.opacity = '0'; - char.style.display = 'inline-block'; - char.style.position = 'relative'; - char.style.transform = 'translateX(-0.18em)'; - char.style.filter = 'blur(2px)'; - - const animation = char.animate( - [ - { - opacity: 0, - transform: 'translateX(-0.18em)', - filter: 'blur(2px)', - }, - { - opacity: 0.7, - transform: 'translateX(-0.04em)', - filter: 'blur(0.6px)', - }, - { - opacity: 1, - transform: 'translateX(0)', - filter: 'blur(0)', - }, - ], - { - duration: 420, - easing: 'cubic-bezier(0.22, 1, 0.36, 1)', - delay: position * 12, - iterations: 1, - fill: 'forwards', - } - ); - - this.activeAnimations.push(animation); - - animation.onfinish = () => { - char.style.opacity = '1'; - char.style.transform = ''; - char.style.filter = ''; - }; - }); - } - - reset() { - // Clear all timeouts - this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); - this.activeTimeouts = []; - - // Cancel all animations - this.activeAnimations.forEach((animation) => animation.cancel()); - this.activeAnimations = []; - - // Reset text content - const chars = this.splitter.getChars(); - chars.forEach((char, index) => { - if (this.originalChars[index]) { - char.textContent = this.originalChars[index]; - } - char.style.opacity = ''; - char.style.transform = ''; - char.style.filter = ''; - }); - } -} +import { DUR, EASE_OUT, prefersReducedMotion } from '../styles/motion'; + +/** + * The greeting's arrival: ONE fade of the whole line, `--dur-fast-max` on the + * one curve, opacity only (spec 2.4 and principle 9). + * + * It used to be a per-character unroll: `split-type` cut the sentence into one + * element per letter and each slid in 12ms after the one before, so a 44-letter + * question took about 950ms to finish and read "Which patient trends are + * waiting to" a third of a second in. That is 44 staggered items against the + * spec's cap of five, and the vision review measured it replaying on every + * return to Home. One element fading is inside every rule, needs no split, and + * leaves the heading's text a single node a screen reader reads as a sentence. + */ +const FADE_MS = DUR.fastMax; interface UseTextAnimatorProps { /** When false the text renders immediately, unanimated. Defaults to true. */ @@ -160,56 +23,43 @@ interface UseTextAnimatorProps { export function useTextAnimator({ text, enabled = true }: UseTextAnimatorProps) { const elementRef = useRef(null); - const animator = useRef(null); // ⚠ `useLayoutEffect`, and NOT `useEffect`, because the first frame the user - // sees is the whole point of an unroll. + // sees is the whole point of an arrival. // - // React paints the heading with its text at full opacity; `animate()` is what - // hides the characters so they can arrive. Run that from `useEffect` and the - // browser has already painted the finished sentence before the hook gets a - // turn — so the arrival reads as: the answer, a blink of nothing, then the - // answer again, slowly. The reported symptom on opening a new window, and it - // was two stacked delays rather than one: a post-paint effect, and then a - // further 100 ms `setTimeout` "to ensure content is ready" with the finished - // text on screen the whole time. A layout effect runs after the DOM is - // committed and BEFORE the paint, so the first frame already has the - // characters hidden with their animations running. - // - // The 100 ms bought nothing to begin with: `TextAnimator`'s constructor - // splits synchronously, and this split is `['words', 'chars']` with no - // `lines`, so nothing here depends on a measured layout (or on a webfont - // having settled) the way a line split would. + // React paints the heading with its text at full opacity; the animation is + // what holds it at 0 until it fades in. Started from `useEffect`, the browser + // has already painted the finished sentence before the hook gets a turn, so + // the arrival reads as: the answer, a blink of nothing, then the answer + // again. A layout effect runs after the DOM is committed and BEFORE the + // paint, so the first frame already holds the animation's first keyframe. // // ⚠ Hiding belongs HERE and not in the rendered markup. A `style={{opacity: // 0}}` on the span would also kill the flash, and would leave the greeting - // permanently invisible for anyone whose branch below returns early — - // `enabled: false`, or `prefers-reduced-motion` — and for anyone whose - // animator throws. Nothing that has to run in order for text to be *visible* - // should live in an effect. + // permanently invisible for anyone whose branch below returns early + // (`enabled: false`, `prefers-reduced-motion`, no Web Animations). The + // animation's `backwards` fill only holds opacity while it runs; when it + // ends or is cancelled the text is simply visible, with no inline style left + // behind to undo. useLayoutEffect(() => { - if (!elementRef.current) return; + const element = elementRef.current; + if (!element) return; - // ⚠ The caller decides WHETHER, this hook decides HOW. The greeting plays - // its unroll on a new chat and stays still on every remount of the same - // one, and that gate lives at the call site because only it knows which - // chat this is. + // ⚠ The caller decides WHETHER, this hook decides HOW. The greeting fades + // in on an arrival and stays still on every remount of the same one, and + // that gate lives at the call site because only it knows which arrival this + // is. if (!enabled) return; + if (prefersReducedMotion()) return; + if (typeof element.animate !== 'function') return; - if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { - return; - } - - animator.current = new TextAnimator(elementRef.current); - animator.current.animate(); - - // Cleanup - return () => { - if (animator.current) { - animator.current.reset(); - } - }; - }, [text, enabled]); // Re-run when text changes + const fade = element.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: FADE_MS, + easing: EASE_OUT, + fill: 'backwards', + }); + return () => fade.cancel(); + }, [text, enabled]); return elementRef; } diff --git a/ui/desktop/src/hooks/useColumnGlide.test.ts b/ui/desktop/src/hooks/useColumnGlide.test.ts new file mode 100644 index 000000000..ec94e6d06 --- /dev/null +++ b/ui/desktop/src/hooks/useColumnGlide.test.ts @@ -0,0 +1,200 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DUR, EASE_OUT } from '../styles/motion'; +import { captureScrollAnchor, GLIDE_CAPTURE_TTL_MS, useColumnGlide } from './useColumnGlide'; + +/** + * jsdom lays nothing out and implements no WAAPI, so each test builds the split + * box by hand: the grid items, the measured elements inside them with a left + * edge the test controls, and an `animate` spy on the items the glide moves. + */ +type Fixture = ReturnType; + +function rectAt(left: number) { + return { left, top: 0, right: left + 760, bottom: 100, width: 760, height: 100 } as DOMRect; +} + +function buildSplit(splitWidth = 1152) { + const split = document.createElement('div'); + Object.defineProperty(split, 'clientWidth', { configurable: true, value: splitWidth }); + const transcript = document.createElement('div'); + transcript.setAttribute('data-preview-area', 'transcript'); + const column = document.createElement('div'); + column.className = 'biorouter-chat-column'; + transcript.appendChild(column); + const bar = document.createElement('div'); + bar.setAttribute('data-preview-area', 'composer'); + const shell = document.createElement('div'); + shell.setAttribute('data-composer-shell', 'true'); + bar.appendChild(shell); + split.append(transcript, bar); + document.body.appendChild(split); + + const lefts = { column: 196, shell: 196 }; + column.getBoundingClientRect = () => rectAt(lefts.column); + shell.getBoundingClientRect = () => rectAt(lefts.shell); + const animations: Array<{ + element: Element; + keyframes: Array>; + options: unknown; + }> = []; + for (const element of [transcript, bar]) { + (element as HTMLElement).animate = vi.fn( + (keyframes: Array>, options: unknown) => { + animations.push({ element, keyframes, options }); + return { cancel: vi.fn(), onfinish: null, oncancel: null } as unknown as Animation; + } + ) as unknown as HTMLElement['animate']; + } + return { split, transcript, bar, column, shell, lefts, animations }; +} + +function hookFor(fixture: Fixture) { + const ref = { current: fixture.split }; + return renderHook(() => useColumnGlide(ref)).result; +} + +let reduced = false; +beforeEach(() => { + reduced = false; + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ matches: reduced && query.includes('reduce'), media: query })) + ); + window.matchMedia = globalThis.matchMedia; +}); +afterEach(() => { + document.body.innerHTML = ''; + document.body.className = ''; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('useColumnGlide', () => { + it('FLIPs the transcript cell and the composer bar from their captured place', () => { + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.capture(); + // The preview took its column: the column now starts at 28. + fixture.lefts.column = 28; + fixture.lefts.shell = 28; + expect(glide.current.playCaptured({ duration: DUR.med })).toBe(true); + expect(fixture.animations.map((a) => a.element)).toEqual([fixture.transcript, fixture.bar]); + expect(fixture.animations[0].keyframes).toEqual([ + { transform: 'translateX(168px)' }, + { transform: 'translateX(0)' }, + ]); + expect(fixture.animations[0].options).toEqual({ duration: DUR.med, easing: EASE_OUT }); + }); + + it('glides by a known distance for the rail', () => { + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.glideBy(140, { duration: DUR.med }); + expect(fixture.animations.map((a) => a.element)).toEqual([fixture.transcript, fixture.bar]); + expect(fixture.animations[0].keyframes[0]).toEqual({ transform: 'translateX(140px)' }); + glide.current.glideBy(-140, { duration: DUR.fastMax, composer: false }); + expect(fixture.animations).toHaveLength(3); + expect(fixture.animations[2].options).toEqual({ duration: DUR.fastMax, easing: EASE_OUT }); + }); + + it('does not glide while the window or the sidebar is being resized', () => { + for (const name of ['biorouter-window-resizing', 'biorouter-sidebar-resizing']) { + const fixture = buildSplit(); + const glide = hookFor(fixture); + document.body.classList.add(name); + glide.current.glideBy(140, { duration: DUR.med }); + glide.current.capture(); + fixture.lefts.column = 28; + glide.current.playCaptured({ duration: DUR.med }); + expect(fixture.animations, name).toHaveLength(0); + document.body.classList.remove(name); + } + }); + + it('does not glide under reduced motion', () => { + reduced = true; + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.glideBy(140, { duration: DUR.med }); + glide.current.capture(); + fixture.lefts.column = 28; + glide.current.playCaptured({ duration: DUR.med }); + expect(fixture.animations).toHaveLength(0); + }); + + it('snaps a move under 8px', () => { + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.glideBy(7, { duration: DUR.med }); + glide.current.capture(); + fixture.lefts.column = 190; + fixture.lefts.shell = 190; + glide.current.playCaptured({ duration: DUR.med }); + expect(fixture.animations).toHaveLength(0); + }); + + it('skips when the window grew to seat the preview, or the pane itself changed width', () => { + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.capture({ windowGrowing: true }); + fixture.lefts.column = 28; + expect(glide.current.playCaptured({ duration: DUR.med })).toBe(true); + expect(fixture.animations).toHaveLength(0); + + glide.current.capture(); + Object.defineProperty(fixture.split, 'clientWidth', { configurable: true, value: 1300 }); + glide.current.playCaptured({ duration: DUR.med }); + expect(fixture.animations).toHaveLength(0); + }); + + it('forgets a capture nothing committed against in time', () => { + vi.useFakeTimers(); + const fixture = buildSplit(); + const glide = hookFor(fixture); + glide.current.capture(); + vi.advanceTimersByTime(GLIDE_CAPTURE_TTL_MS + 1); + fixture.lefts.column = 28; + expect(glide.current.playCaptured({ duration: DUR.med })).toBe(false); + expect(fixture.animations).toHaveLength(0); + // A capture is consumed once. + expect(glide.current.playCaptured({ duration: DUR.med })).toBe(false); + }); + + it('leaves the composer to its own FLIP when it is already moving', () => { + const fixture = buildSplit(); + (fixture.shell as HTMLElement).getAnimations = () => + [{ playState: 'running' }] as unknown as Animation[]; + const glide = hookFor(fixture); + glide.current.glideBy(140, { duration: DUR.med }); + expect(fixture.animations.map((a) => a.element)).toEqual([fixture.transcript]); + }); +}); + +describe('captureScrollAnchor', () => { + it('scrolls by however far the clicked card moved', () => { + const viewport = document.createElement('div'); + const card = document.createElement('button'); + viewport.appendChild(card); + document.body.appendChild(viewport); + viewport.scrollTop = 400; + let cardTop = 300; + viewport.getBoundingClientRect = () => ({ top: 50 }) as DOMRect; + card.getBoundingClientRect = () => ({ top: cardTop }) as DOMRect; + const restore = captureScrollAnchor(viewport, card); + expect(restore).not.toBeNull(); + // The narrower column reflowed: the card is 120px lower than it was. + cardTop = 420; + restore!(); + expect(viewport.scrollTop).toBe(520); + }); + + it('anchors nothing outside the transcript', () => { + const viewport = document.createElement('div'); + const elsewhere = document.createElement('button'); + document.body.append(viewport, elsewhere); + expect(captureScrollAnchor(viewport, elsewhere)).toBeNull(); + expect(captureScrollAnchor(viewport, null)).toBeNull(); + expect(captureScrollAnchor(null, elsewhere)).toBeNull(); + }); +}); diff --git a/ui/desktop/src/hooks/useColumnGlide.ts b/ui/desktop/src/hooks/useColumnGlide.ts new file mode 100644 index 000000000..792d2faac --- /dev/null +++ b/ui/desktop/src/hooks/useColumnGlide.ts @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'; +import { EASE_OUT, MOTION, isWindowResizing, prefersReducedMotion } from '../styles/motion'; + +/** + * THE CONVERSATION GLIDE. + * + * When the summary rail or a side preview takes its column, the grid changes in + * ONE frame (the layout snaps) and the conversation then glides from where it + * was to where it is now: a FLIP with `transform` only. A width tween would + * re-lay out a transcript that is not virtualised on every frame, which is + * exactly the horizontal jitter Codex shipped with its pinned summary. + * + * What moves are the GRID ITEMS, the transcript cell and the composer bar, not + * `.biorouter-chat-column` inside the scroller: a transform on the column would + * add to the viewport's scrollable overflow and flash a horizontal scrollbar. + * The split box clips (`overflow: clip`), so a cell's overshoot is bounded. + * + * It never animates against geometry: nothing plays while the window or the + * sidebar is being resized, under reduced motion, for a move under 8px, while + * the window grows to seat a preview, or when the split box's own width changed + * between the capture and the commit (the change was the window's, not the + * person's). + */ + +const TRANSCRIPT = "[data-preview-area='transcript']"; +const COMPOSER = "[data-preview-area='composer']"; +const TRANSCRIPT_MEASURE = '.biorouter-chat-column, .biorouter-clean-conversation-content'; +const COMPOSER_MEASURE = '[data-composer-shell]'; + +/** A capture older than this is not the "before" of anything that commits now. */ +export const GLIDE_CAPTURE_TTL_MS = 1000; + +type Capture = { + at: number; + splitWidth: number; + transcriptLeft: number | null; + composerLeft: number | null; + windowGrowing: boolean; +}; + +export interface GlideOptions { + /** The glide's length, in ms (`DUR.med` to open, `DUR.fastMax` to close). */ + duration: number; + /** Also move the composer bar. False when the composer is already moving. */ + composer?: boolean; +} + +export interface ColumnGlide { + /** + * Read where the conversation is, BEFORE an action changes the grid (a + * preview opening or closing). The matching `playCaptured` after the commit + * moves it from there. + */ + capture: (options?: { windowGrowing?: boolean }) => void; + /** + * Play the pending capture against the layout that has just committed. Call + * from a layout effect. Returns whether a capture was consumed. + */ + playCaptured: (options: GlideOptions) => boolean; + /** Glide by a known distance: the summary rail, whose `dx` is exact. */ + glideBy: (dx: number, options: GlideOptions) => void; +} + +function leftOf(element: Element | null | undefined): number | null { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return Number.isFinite(rect.left) ? rect.left : null; +} + +/** The translateX an in-flight glide holds an element at, so a new one starts there. */ +function currentTranslateX(element: HTMLElement): number { + try { + const transform = getComputedStyle(element).transform; + if (!transform || transform === 'none') return 0; + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/); + if (!match) return 0; + const values = match[1].split(',').map((value) => Number.parseFloat(value)); + const x = values.length === 16 ? values[12] : values[4]; + return Number.isFinite(x) ? x : 0; + } catch { + return 0; + } +} + +function isAnimating(element: Element | null): boolean { + if (!element || typeof (element as HTMLElement).getAnimations !== 'function') return false; + return (element as HTMLElement) + .getAnimations() + .some((animation) => animation.playState === 'running' || animation.pending); +} + +export function useColumnGlide(splitPaneRef: RefObject): ColumnGlide { + const pendingRef = useRef(null); + const runningRef = useRef(new Map()); + + const stopAll = useCallback(() => { + for (const animation of runningRef.current.values()) animation.cancel(); + runningRef.current.clear(); + }, []); + + useEffect(() => stopAll, [stopAll]); + + const animate = useCallback((element: HTMLElement, dx: number, duration: number) => { + const running = runningRef.current; + // Start a new glide from where an unfinished one holds the element, so a + // quick second toggle continues the motion rather than jumping. + const offset = running.has(element) ? currentTranslateX(element) : 0; + running.get(element)?.cancel(); + running.delete(element); + const from = dx + offset; + if (Math.abs(from) < MOTION.minGlidePx || typeof element.animate !== 'function') return; + const animation = element.animate( + [{ transform: `translateX(${from}px)` }, { transform: 'translateX(0)' }], + { duration, easing: EASE_OUT } + ); + running.set(element, animation); + const forget = () => { + if (running.get(element) === animation) running.delete(element); + }; + animation.onfinish = forget; + animation.oncancel = forget; + }, []); + + const blocked = useCallback(() => isWindowResizing() || prefersReducedMotion(), []); + + const capture = useCallback( + (options: { windowGrowing?: boolean } = {}) => { + const split = splitPaneRef.current; + if (!split) { + pendingRef.current = null; + return; + } + const transcript = split.querySelector(TRANSCRIPT); + const composer = split.querySelector(COMPOSER); + pendingRef.current = { + at: performance.now(), + splitWidth: split.clientWidth, + transcriptLeft: leftOf(transcript?.querySelector(TRANSCRIPT_MEASURE) ?? transcript), + composerLeft: leftOf(composer?.querySelector(COMPOSER_MEASURE) ?? composer), + windowGrowing: options.windowGrowing === true, + }; + }, + [splitPaneRef] + ); + + const playCaptured = useCallback( + ({ duration, composer: moveComposer = true }: GlideOptions) => { + const pending = pendingRef.current; + pendingRef.current = null; + const split = splitPaneRef.current; + if (!pending || !split) return false; + if (performance.now() - pending.at > GLIDE_CAPTURE_TTL_MS) return false; + if (pending.windowGrowing || pending.splitWidth !== split.clientWidth || blocked()) { + return true; + } + const transcript = split.querySelector(TRANSCRIPT); + if (transcript && pending.transcriptLeft !== null) { + // Cancel first: the "after" rect must not include an old glide's offset. + runningRef.current.get(transcript)?.cancel(); + const after = leftOf(transcript.querySelector(TRANSCRIPT_MEASURE) ?? transcript); + if (after !== null) animate(transcript, pending.transcriptLeft - after, duration); + } + const bar = split.querySelector(COMPOSER); + const shell = bar?.querySelector(COMPOSER_MEASURE) ?? null; + if (bar && moveComposer && pending.composerLeft !== null && !isAnimating(shell)) { + runningRef.current.get(bar)?.cancel(); + const after = leftOf(shell ?? bar); + if (after !== null) animate(bar, pending.composerLeft - after, duration); + } + return true; + }, + [animate, blocked, splitPaneRef] + ); + + const glideBy = useCallback( + (dx: number, { duration, composer: moveComposer = true }: GlideOptions) => { + const split = splitPaneRef.current; + if (!split || blocked() || Math.abs(dx) < MOTION.minGlidePx) return; + const transcript = split.querySelector(TRANSCRIPT); + if (transcript) animate(transcript, dx, duration); + const bar = split.querySelector(COMPOSER); + // The composer's own FLIP (the first turn's move from the centred empty + // state) already lands on the final rect, rail included; a second + // transform on top of it would double the move. + if (bar && moveComposer && !isAnimating(bar.querySelector(COMPOSER_MEASURE))) { + animate(bar, dx, duration); + } + }, + [animate, blocked, splitPaneRef] + ); + + return useMemo(() => ({ capture, playCaptured, glideBy }), [capture, playCaptured, glideBy]); +} + +/** + * Keep the artifact card the person clicked where it was while the transcript + * narrows for a side preview: read its offset from the viewport's top before the + * commit, and scroll by the difference after it. A transcript that follows its + * bottom keeps doing that instead. + */ +export function captureScrollAnchor( + viewport: HTMLElement | null | undefined, + candidate: Element | null +): (() => void) | null { + if (!viewport || !candidate || !viewport.contains(candidate) || candidate === viewport) { + return null; + } + const offset = () => candidate.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + const before = offset(); + return () => { + if (!candidate.isConnected) return; + const delta = offset() - before; + if (Math.abs(delta) >= 1) viewport.scrollTop += delta; + }; +} diff --git a/ui/desktop/src/hooks/useCostTracking.ledger.test.tsx b/ui/desktop/src/hooks/useCostTracking.ledger.test.tsx new file mode 100644 index 000000000..4bf4379d8 --- /dev/null +++ b/ui/desktop/src/hooks/useCostTracking.ledger.test.tsx @@ -0,0 +1,129 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ModelUsageRow, Session } from '../api'; +import { useCostTracking } from './useCostTracking'; + +/** + * Vision V2-R5-5: until a chat's usage ledger answers, the hook used to hand + * back `[]`, which every reader takes as "no ledger rows" and falls back to the + * accumulated counters. The summary's Tokens value and the footer cost then + * showed a figure ("2.2M", "$11.75") that the ledger withdrew a moment later + * ("N/A", no cost). `ledgerSettled` is what lets a reader wait. + */ +const mocks = vi.hoisted(() => ({ + getSessionUsage: vi.fn(), + pricing: vi.fn(), + headers: vi.fn(), +})); +vi.mock('../api', () => ({ getSessionUsage: mocks.getSessionUsage })); +vi.mock('../utils/pricing', () => ({ fetchModelPricing: mocks.pricing })); +vi.mock('../utils/userAction', () => ({ userActionHeaders: mocks.headers })); + +const usageRow: ModelUsageRow = { + provider: 'anthropic', + modelId: 'claude-test', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: null, + cacheCreationTokens: null, + totalTokens: null, + turns: 2, +}; + +const chat = (id: string, total = 120) => + ({ id, accumulated_total_tokens: total }) as unknown as Session; + +/** A usage request the test answers by hand. */ +function deferred() { + let resolve!: (value: { data: { models: ModelUsageRow[] } }) => void; + const promise = new Promise<{ data: { models: ModelUsageRow[] } }>((r) => (resolve = r)); + return { promise, answer: (models: ModelUsageRow[]) => resolve({ data: { models } }) }; +} + +beforeEach(() => { + mocks.getSessionUsage.mockReset(); + mocks.pricing.mockReset().mockResolvedValue(null); + mocks.headers.mockReset().mockResolvedValue({ 'X-User-Action': 'synthetic-proof' }); +}); + +describe('useCostTracking: a reader can tell a pending ledger from an empty one', () => { + it('is not settled, and holds no rows, while the usage request is pending', async () => { + const usage = deferred(); + mocks.getSessionUsage.mockReturnValue(usage.promise); + const { result } = renderHook(() => useCostTracking({ session: chat('a') })); + + expect(result.current.ledgerSettled).toBe(false); + expect(result.current.modelRows).toEqual([]); + // Sent after the proof is in hand, and still pending after it was sent. + await waitFor(() => + expect(mocks.getSessionUsage).toHaveBeenCalledWith( + expect.objectContaining({ + path: { session_id: 'a' }, + headers: { 'X-User-Action': 'synthetic-proof' }, + }) + ) + ); + expect(result.current.ledgerSettled).toBe(false); + expect(result.current.modelRows).toEqual([]); + + await act(async () => usage.answer([usageRow])); + await waitFor(() => expect(result.current.ledgerSettled).toBe(true)); + expect(result.current.modelRows).toHaveLength(1); + expect(result.current.modelRows[0]).toMatchObject({ model: 'claude-test', totalTokens: null }); + }); + + it('settles on an answer with no rows, and on a failed request', async () => { + mocks.getSessionUsage.mockResolvedValueOnce({ data: { models: [] } }); + const empty = renderHook(() => useCostTracking({ session: chat('a') })); + await waitFor(() => expect(empty.result.current.ledgerSettled).toBe(true)); + expect(empty.result.current.modelRows).toEqual([]); + + mocks.getSessionUsage.mockRejectedValueOnce(new Error('offline')); + const failed = renderHook(() => useCostTracking({ session: chat('b') })); + await waitFor(() => expect(failed.result.current.ledgerSettled).toBe(true)); + expect(failed.result.current.modelRows).toEqual([]); + }); + + it('is settled with no chat, and asks for nothing', () => { + const { result } = renderHook(() => useCostTracking({ session: undefined })); + expect(result.current.ledgerSettled).toBe(true); + expect(result.current.modelRows).toEqual([]); + expect(mocks.getSessionUsage).not.toHaveBeenCalled(); + }); + + it('never lets one chat’s ledger stand in for another’s', async () => { + mocks.getSessionUsage.mockResolvedValueOnce({ data: { models: [usageRow] } }); + const { result, rerender } = renderHook(({ session }) => useCostTracking({ session }), { + initialProps: { session: chat('a') }, + }); + await waitFor(() => expect(result.current.ledgerSettled).toBe(true)); + + const usage = deferred(); + mocks.getSessionUsage.mockReturnValue(usage.promise); + rerender({ session: chat('b') }); + expect(result.current.ledgerSettled).toBe(false); + expect(result.current.modelRows).toEqual([]); + + await act(async () => usage.answer([])); + await waitFor(() => expect(result.current.ledgerSettled).toBe(true)); + }); + + it('keeps the answer it has while a turn refreshes the same chat', async () => { + mocks.getSessionUsage.mockResolvedValueOnce({ data: { models: [usageRow] } }); + const { result, rerender } = renderHook(({ session }) => useCostTracking({ session }), { + initialProps: { session: chat('a', 120) }, + }); + await waitFor(() => expect(result.current.modelRows).toHaveLength(1)); + + const usage = deferred(); + mocks.getSessionUsage.mockReturnValue(usage.promise); + rerender({ session: chat('a', 400) }); + await waitFor(() => expect(mocks.getSessionUsage).toHaveBeenCalledTimes(2)); + // Still settled, still the rows it had: a refresh is not a withdrawal. + expect(result.current.ledgerSettled).toBe(true); + expect(result.current.modelRows).toHaveLength(1); + + await act(async () => usage.answer([usageRow, { ...usageRow, modelId: 'other' }])); + await waitFor(() => expect(result.current.modelRows).toHaveLength(2)); + }); +}); diff --git a/ui/desktop/src/hooks/useCostTracking.ts b/ui/desktop/src/hooks/useCostTracking.ts index a847e6e33..07dc8ded2 100644 --- a/ui/desktop/src/hooks/useCostTracking.ts +++ b/ui/desktop/src/hooks/useCostTracking.ts @@ -138,6 +138,9 @@ export async function buildModelCostRows( ); } +/** A chat's rows before its ledger answers: one array, so the prop stays stable. */ +const NO_ROWS: ModelCostRow[] = []; + /** * Per-model cost tracking backed by the real `token_events` ledger. * @@ -147,9 +150,18 @@ export async function buildModelCostRows( * from before the app opened and mis-attributed everything after a mid-thread * switch (Issue #1). This fetches the authoritative `(model, provider)` rollup * from `GET /sessions/{id}/usage` instead and only prices it client-side. + * + * `ledgerSettled` says whether THIS chat's ledger has answered (with rows, or + * by failing). Until it has, `modelRows` is empty, and a reader that falls back + * to the accumulated counters shows a figure the ledger then replaces: the + * summary's Tokens value and the footer cost read "2.2M" and "$11.75", then + * "N/A" and nothing, on every open (vision V2-R5-5). So a reader waits for it. + * The answer is kept with the chat it was read for, so one chat's ledger never + * stands in for another's, and a refresh of the same chat (a turn moved its + * counters) keeps the answer it has until the new one lands. */ export const useCostTracking = ({ session }: UseCostTrackingProps) => { - const [modelRows, setModelRows] = useState([]); + const [ledger, setLedger] = useState<{ sessionId: string; rows: ModelCostRow[] } | null>(null); const sessionId = session?.id; // Some providers report only the split and others only the total. Observe all @@ -159,13 +171,10 @@ export const useCostTracking = ({ session }: UseCostTrackingProps) => { const accumulatedTotal = session?.accumulated_total_tokens; useEffect(() => { + if (!sessionId) return; let cancelled = false; const load = async () => { - if (!sessionId) { - setModelRows([]); - return; - } try { // With the user's proof: a private chat's usage is refused to a caller // without it (issue #56, QA 2026-09-10). @@ -177,11 +186,11 @@ export const useCostTracking = ({ session }: UseCostTrackingProps) => { const rows = response.data?.models ?? []; const priced = await buildModelCostRows(rows, fetchModelPricing); if (!cancelled) { - setModelRows(priced); + setLedger({ sessionId, rows: priced }); } } catch { if (!cancelled) { - setModelRows([]); + setLedger({ sessionId, rows: [] }); } } }; @@ -192,6 +201,9 @@ export const useCostTracking = ({ session }: UseCostTrackingProps) => { }; }, [sessionId, accumulatedInput, accumulatedOutput, accumulatedTotal]); + const ledgerIsThisChats = !!sessionId && ledger?.sessionId === sessionId; + const modelRows = ledgerIsThisChats ? ledger.rows : NO_ROWS; + // Back-compat shape for older CostTracker callers. Unknown and partial rows // stay in modelRows instead of being forged into exact legacy costs here. const sessionCosts = buildLegacySessionCosts(modelRows); @@ -199,5 +211,7 @@ export const useCostTracking = ({ session }: UseCostTrackingProps) => { return { sessionCosts, modelRows, + /** No chat, or this chat's ledger has answered. */ + ledgerSettled: !sessionId || ledgerIsThisChats, }; }; diff --git a/ui/desktop/src/hooks/useStopAcknowledgement.ts b/ui/desktop/src/hooks/useStopAcknowledgement.ts index 32c3f16fd..c9d173a8b 100644 --- a/ui/desktop/src/hooks/useStopAcknowledgement.ts +++ b/ui/desktop/src/hooks/useStopAcknowledgement.ts @@ -3,11 +3,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; /** * How long the Stop control holds its "received" state. * - * Tuned against the motion scale in styles/main.css (--motion-fast 120ms / - * --motion-base 180ms / --motion-slow 260ms): long enough that the confirmation - * completes its own transition and is legible as deliberate, short enough that - * it cannot outlive the stop it is confirming and start lying about a turn that - * already ended. + * Tuned against the motion scale in styles/main.css (`--dur-fast` 125ms, + * `--dur-fast-max` 175ms, `--dur-med` 300ms; mirrored as `DUR` in + * styles/motion.ts): long enough that the confirmation completes its own + * transition and is legible as deliberate, short enough that it cannot outlive + * the stop it is confirming and start lying about a turn that already ended. */ export const STOP_ACK_MS = 450; diff --git a/ui/desktop/src/hooks/useSummaryRail.test.tsx b/ui/desktop/src/hooks/useSummaryRail.test.tsx new file mode 100644 index 000000000..a9c1a2a76 --- /dev/null +++ b/ui/desktop/src/hooks/useSummaryRail.test.tsx @@ -0,0 +1,460 @@ +import { act, render } from '@testing-library/react'; +import { StrictMode, useRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + SUMMARY_RAIL_EXIT_FALLBACK_MS, + SUMMARY_RAIL_SHOW_SETTLE_MS, + summaryRailChat, + useSummaryRail, + type SummaryRailController, + type UseSummaryRailOptions, +} from './useSummaryRail'; +import { resetSummaryRailPreferenceForTests } from '../components/Layout/summaryRailPreference'; + +// Every observer the hook made, and whether it is still connected: a callback +// that fires on a disconnected observer would hide the bug StrictMode exposed +// (V2-01), where the only observer had been disconnected by the simulated unmount. +let observers: Array<{ callback: () => void; connected: boolean }> = []; +const originalResizeObserver = globalThis.ResizeObserver; +const pane = { width: 1152 }; + +type HarnessProps = Omit; + +let latest: SummaryRailController; +let commits: Array> = []; + +function Harness(props: HarnessProps) { + const ref = useRef(null); + const attach = (element: HTMLDivElement | null) => { + if (element) { + Object.defineProperty(element, 'clientWidth', { + configurable: true, + get: () => pane.width, + }); + } + ref.current = element; + }; + const rail = useSummaryRail({ splitPaneRef: ref, ...props }); + latest = rail; + commits.push({ rendered: rail.rendered, still: rail.still, shown: rail.shown }); + return
    ; +} + +const defaults: HarnessProps = { + active: true, + chatPhase: 'idle', + isMobile: false, + previewMode: null, + previewWidth: 0, +}; + +function mount(over: Partial = {}, { strict = false } = {}) { + const props = { ...defaults, ...over }; + const tree = () => + strict ? ( + + + + ) : ( + + ); + const view = render(tree()); + return { + ...view, + update: (next: Partial) => { + Object.assign(props, next); + view.rerender(tree()); + }, + }; +} + +function resizeTo(width: number) { + pane.width = width; + act(() => { + for (const observer of observers) if (observer.connected) observer.callback(); + }); +} + +beforeEach(() => { + observers = []; + commits = []; + pane.width = 1152; + window.localStorage.clear(); + resetSummaryRailPreferenceForTests(); + class FakeResizeObserver { + private entry: { callback: () => void; connected: boolean }; + constructor(callback: () => void) { + this.entry = { callback, connected: false }; + observers.push(this.entry); + } + observe() { + this.entry.connected = true; + } + disconnect() { + this.entry.connected = false; + } + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + vi.useRealTimers(); + window.localStorage.clear(); + resetSummaryRailPreferenceForTests(); +}); + +describe('useSummaryRail', () => { + it('measures before the first paint: no observer callback and no timer needed', () => { + vi.useFakeTimers(); + const { getByTestId } = mount(); + expect(latest.shown).toBe(true); + expect(latest.width).toBe(280); + expect(latest.mode).toBe('rail'); + const split = getByTestId('split'); + expect(split).toHaveAttribute('data-summary-rail', ''); + expect(split.style.getPropertyValue('--summary-rail-width')).toBe('280px'); + }); + + it('appears at rest on mount: a tab switch or reload does not animate it', () => { + const onGlide = vi.fn(); + mount({ onGlide }); + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + + it('opens the popover instead where it does not fit', () => { + pane.width = 1000; + const { getByTestId } = mount(); + expect(latest.shown).toBe(false); + expect(latest.mode).toBe('popover'); + expect(getByTestId('split')).not.toHaveAttribute('data-summary-rail'); + }); + + it('hides at once when the pane narrows', () => { + vi.useFakeTimers(); + mount(); + expect(latest.shown).toBe(true); + resizeTo(1000); + expect(latest.shown).toBe(false); + expect(latest.rendered).toBe(false); + }); + + it('shows only after the width has held still for the settle delay', () => { + vi.useFakeTimers(); + pane.width = 1000; + mount(); + expect(latest.shown).toBe(false); + resizeTo(1152); + expect(latest.shown).toBe(false); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS - 10)); + // Another sample restarts the settle. + resizeTo(1160); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS - 1)); + expect(latest.shown).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(latest.shown).toBe(true); + // A geometry change appears at rest. + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + }); + + it('keeps the return buffer after a measured hide', () => { + vi.useFakeTimers(); + mount(); + resizeTo(1055); + expect(latest.shown).toBe(false); + resizeTo(1067); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS)); + expect(latest.shown).toBe(false); + resizeTo(1068); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS)); + expect(latest.shown).toBe(true); + }); + + it('hides in the same render a side preview takes the room, and returns when it closes', () => { + const view = mount(); + expect(latest.shown).toBe(true); + view.update({ previewMode: 'side', previewWidth: 512 }); + expect(latest.shown).toBe(false); + view.update({ previewMode: null, previewWidth: 0 }); + expect(latest.shown).toBe(true); + view.update({ previewMode: 'stack', previewWidth: 0 }); + expect(latest.shown).toBe(false); + }); + + it('stays hidden in an empty chat and on a phone-width browser', () => { + const view = mount({ active: false }); + expect(latest.shown).toBe(false); + expect(latest.mode).toBe('popover'); + view.update({ active: true, isMobile: true }); + expect(latest.shown).toBe(false); + expect(latest.mode).toBe('popover'); + }); + + it('animates when the first turn starts, and glides the conversation left by half the rail', () => { + const onGlide = vi.fn(); + const view = mount({ active: false, onGlide }); + commits = []; + view.update({ active: true, chatPhase: 'running' }); + expect(latest.shown).toBe(true); + // The commit that mounted the card carried `still: false`; the card latches it. + expect(commits.some((c) => c.rendered && !c.still)).toBe(true); + expect(onGlide).toHaveBeenCalledWith(140, 'open'); + // Only that one commit: later geometry changes are still again. + expect(latest.still).toBe(true); + }); + + describe("a new chat's first turn (V2-08)", () => { + const empty = { + clean: true, + messageCount: 0, + loading: false, + running: false, + creatingChat: false, + }; + + it('counts from the commit that starts creating the chat, before any message exists', () => { + expect(summaryRailChat(empty)).toEqual({ active: false, chatPhase: 'idle' }); + expect(summaryRailChat({ ...empty, clean: false, creatingChat: true })).toEqual({ + active: true, + chatPhase: 'running', + }); + // The created chat loads, then its message arrives with the stream. + expect( + summaryRailChat({ ...empty, clean: false, creatingChat: true, loading: true }) + ).toEqual({ active: true, chatPhase: 'loading' }); + expect( + summaryRailChat({ + ...empty, + clean: false, + creatingChat: true, + messageCount: 1, + running: true, + }) + ).toEqual({ active: true, chatPhase: 'running' }); + // `isCreatingSession` is never cleared after a chat is created: once the + // message is in the transcript it no longer makes an idle chat "running". + expect( + summaryRailChat({ ...empty, clean: false, creatingChat: true, messageCount: 2 }) + ).toEqual({ active: true, chatPhase: 'idle' }); + }); + + it('is not active in the empty state, nor while a workflow form waits', () => { + expect(summaryRailChat({ ...empty, creatingChat: true }).active).toBe(false); + expect(summaryRailChat({ ...empty, clean: false }).active).toBe(false); + }); + + it('enters and glides ONCE, in the creating commit, and stays put as the chat fills', () => { + const onGlide = vi.fn(); + const view = mount({ ...summaryRailChat(empty), onGlide }); + expect(latest.shown).toBe(false); + commits = []; + view.update(summaryRailChat({ ...empty, clean: false, creatingChat: true })); + expect(latest.shown).toBe(true); + expect(commits.some((c) => c.rendered && !c.still)).toBe(true); + expect(onGlide).toHaveBeenCalledWith(140, 'open'); + view.update(summaryRailChat({ ...empty, clean: false, creatingChat: true, loading: true })); + view.update( + summaryRailChat({ + ...empty, + clean: false, + creatingChat: true, + messageCount: 1, + running: true, + }) + ); + expect(latest.shown).toBe(true); + expect(onGlide).toHaveBeenCalledTimes(1); + }); + }); + + describe('a chat that becomes active because its history arrived appears at rest', () => { + // A tab opened from History, or restored after a restart, mounts empty and + // fills from the store: the chat turns active, but no turn started here. + it('after a fetch (loading, then idle with the transcript)', () => { + const onGlide = vi.fn(); + const view = mount({ active: false, chatPhase: 'loading', onGlide }); + commits = []; + view.update({ active: true, chatPhase: 'idle' }); + expect(latest.shown).toBe(true); + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + + it('after a cached transcript lands in one step (idle, then idle with messages)', () => { + const onGlide = vi.fn(); + const view = mount({ active: false, chatPhase: 'idle', onGlide }); + commits = []; + view.update({ active: true, chatPhase: 'idle' }); + expect(latest.shown).toBe(true); + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + + it('when the reloaded chat is rejoining a turn that is still running', () => { + const onGlide = vi.fn(); + const view = mount({ active: false, chatPhase: 'loading', onGlide }); + commits = []; + view.update({ active: true, chatPhase: 'running' }); + expect(latest.shown).toBe(true); + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + }); + + describe('a chat known to have messages keeps the rail while its transcript loads (V2-R5-6)', () => { + // BaseChat in `LoadingConversation`: not clean (the state is not idle), no + // messages yet, not running. + const loading = { + clean: false, + messageCount: 0, + loading: true, + running: false, + creatingChat: false, + }; + + it('is active, in the loading phase, when the row says the chat has messages', () => { + expect(summaryRailChat({ ...loading, knownToHaveMessages: true })).toEqual({ + active: true, + chatPhase: 'loading', + }); + // Nothing known (a workflow chat before its first run is not listed, and + // a row the app has not read yet says nothing): the old answer. + expect(summaryRailChat(loading)).toEqual({ active: false, chatPhase: 'loading' }); + expect(summaryRailChat({ ...loading, knownToHaveMessages: false }).active).toBe(false); + // The flag means nothing once the load is over: the transcript decides. + expect( + summaryRailChat({ ...loading, loading: false, clean: true, knownToHaveMessages: true }) + ).toEqual({ active: false, chatPhase: 'idle' }); + }); + + it('seats the rail at rest from the first frame and never moves the column as the load ends', () => { + const onGlide = vi.fn(); + const view = mount({ + ...summaryRailChat({ ...loading, knownToHaveMessages: true }), + onGlide, + }); + expect(latest.shown).toBe(true); + expect(latest.width).toBe(280); + expect(commits.filter((c) => c.rendered).every((c) => c.still)).toBe(true); + commits = []; + // The transcript lands: idle with its messages, the row no longer read. + view.update(summaryRailChat({ ...loading, loading: false, messageCount: 12 })); + expect(latest.shown).toBe(true); + expect(latest.width).toBe(280); + expect(commits.every((c) => c.rendered && c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + + it('stays at rest when the loaded chat rejoins a running turn', () => { + const onGlide = vi.fn(); + const view = mount({ + ...summaryRailChat({ ...loading, knownToHaveMessages: true }), + onGlide, + }); + commits = []; + view.update(summaryRailChat({ ...loading, loading: false, messageCount: 3, running: true })); + expect(latest.shown).toBe(true); + expect(commits.every((c) => c.rendered && c.still)).toBe(true); + expect(onGlide).not.toHaveBeenCalled(); + }); + }); + + it('closes on a toggle: plays the exit, then drops the column and glides back', () => { + const onGlide = vi.fn(); + mount({ onGlide }); + act(() => latest.toggle()); + expect(latest.preference).toBe('closed'); + expect(latest.shown).toBe(false); + expect(latest.rendered).toBe(true); + expect(latest.state).toBe('closed'); + expect(onGlide).not.toHaveBeenCalled(); + act(() => latest.onExited()); + expect(latest.rendered).toBe(false); + expect(onGlide).toHaveBeenCalledWith(-140, 'close'); + // A second report changes nothing. + act(() => latest.onExited()); + expect(onGlide).toHaveBeenCalledTimes(1); + }); + + it('drops the column after the exit fallback when the card never reports', () => { + vi.useFakeTimers(); + mount(); + act(() => latest.toggle()); + expect(latest.rendered).toBe(true); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_EXIT_FALLBACK_MS)); + expect(latest.rendered).toBe(false); + }); + + it('opens on a toggle with its entrance and a glide', () => { + window.localStorage.setItem('biorouter:summary-rail', 'closed'); + resetSummaryRailPreferenceForTests(); + const onGlide = vi.fn(); + mount({ onGlide }); + expect(latest.shown).toBe(false); + expect(latest.mode).toBe('rail'); + commits = []; + act(() => latest.toggle()); + expect(latest.shown).toBe(true); + expect(latest.preference).toBe('open'); + expect(commits.some((c) => c.rendered && !c.still)).toBe(true); + expect(onGlide).toHaveBeenCalledWith(140, 'open'); + }); + + describe('under StrictMode (the renderer mounts in it, renderer.tsx)', () => { + // V2-01: the simulated unmount ran the observer's teardown, and the attach + // effect then saw the same element and kept the dead observer, so the rail + // never re-measured after mount: it kept a stale width while the window, + // the sidebar and the preview moved under it. + it('keeps exactly one live observer after the simulated unmount', () => { + mount({}, { strict: true }); + expect(observers.filter((o) => o.connected)).toHaveLength(1); + }); + + it('still hides at once when the pane narrows, and shows again after the settle', () => { + vi.useFakeTimers(); + mount({}, { strict: true }); + expect(latest.shown).toBe(true); + resizeTo(1032); + expect(latest.shown).toBe(false); + expect(latest.rendered).toBe(false); + resizeTo(1152); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS)); + expect(latest.shown).toBe(true); + expect(latest.width).toBe(280); + }); + + it('shows once a pane measured too narrow at mount widens (the sidebar collapsing after a reload)', () => { + vi.useFakeTimers(); + pane.width = 812; + mount({}, { strict: true }); + expect(latest.shown).toBe(false); + resizeTo(1100); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS)); + expect(latest.shown).toBe(true); + expect(latest.width).toBe(280); + }); + + it('disconnects its observer on a real unmount', () => { + const view = mount({}, { strict: true }); + view.unmount(); + expect(observers.filter((o) => o.connected)).toHaveLength(0); + }); + }); + + it('does not glide back for a rail the grid already dropped mid-exit', () => { + vi.useFakeTimers(); + const onGlide = vi.fn(); + mount({ onGlide }); + act(() => latest.toggle()); + resizeTo(1000); + expect(latest.rendered).toBe(false); + act(() => latest.onExited()); + expect(onGlide).not.toHaveBeenCalled(); + // A later geometry change is not mistaken for the close. + resizeTo(1152); + act(() => vi.advanceTimersByTime(SUMMARY_RAIL_SHOW_SETTLE_MS)); + expect(onGlide).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/hooks/useSummaryRail.ts b/ui/desktop/src/hooks/useSummaryRail.ts new file mode 100644 index 000000000..ee1cb197d --- /dev/null +++ b/ui/desktop/src/hooks/useSummaryRail.ts @@ -0,0 +1,389 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react'; +import { + summaryRailFit, + summaryRailVisible, + summaryToggleMode, + type PreviewPanelMode, + type SummaryRailFit, + type SummaryRailPreference, +} from '../components/Layout/yieldLadder'; +import { useSummaryRailPreference } from '../components/Layout/summaryRailPreference'; + +/** + * How long a width that would SHOW the rail must hold still before the rail + * appears. A hide is immediate: the conversation's measure is protected first. + * The delay keeps the column from jumping halfway through someone else's motion, + * such as the sidebar's 300ms collapse sweeping the pane through the threshold. + */ +export const SUMMARY_RAIL_SHOW_SETTLE_MS = 160; + +/** + * The longest a closing rail keeps its column while its card plays the exit + * (`--dur-fast`, 125ms) before it is removed anyway. The card normally reports + * `animationend` first; this covers a card whose animation never ran (a resize + * zeroes animations, a hidden window throttles them). + */ +export const SUMMARY_RAIL_EXIT_FALLBACK_MS = 200; + +/** + * Where the chat's stream is, as far as the rail cares: + * + * loading the transcript is being fetched (`ChatState.LoadingConversation`) + * running a turn is under way + * idle neither + * + * It exists to tell a FIRST TURN from a history load. Both turn the chat + * `active` (the transcript goes from empty to something), but only a turn that + * starts in this mount is something the person did; a reload, a tab opened from + * History or a transcript restored from the cache arrives with the chat already + * written, and the rail then appears at rest like every other geometry change. + */ +export type SummaryRailChatPhase = 'loading' | 'running' | 'idle'; + +/** What the chat knows about itself, for {@link summaryRailChat}. */ +export interface SummaryRailChatInputs { + /** The empty state is on screen (BaseChat's `isCleanConversation`). */ + clean: boolean; + messageCount: number; + /** The transcript is being fetched (`ChatState.LoadingConversation`). */ + loading: boolean; + /** A turn is under way (`isRunningState`). */ + running: boolean; + /** + * The person sent the first message of a chat that does not exist yet, and + * it is being created (BaseChat's `isCreatingSession`). The turn has begun as + * far as anyone can see: the composer leaves the empty state in this commit. + */ + creatingChat: boolean; + /** + * While the transcript loads: the chat's row says it has messages (the + * listing's, or the row itself; `summary/useLoadingChatRow.ts`). + */ + knownToHaveMessages?: boolean; +} + +/** + * Whether the chat is `active` for the rail, and its {@link SummaryRailChatPhase}. + * + * A new chat's first turn starts in the commit where the composer leaves the + * empty state, which is the commit that begins creating the chat: the message + * and the running stream only arrive about 300ms later, once the chat exists. + * Counting that commit as the turn's start seats the rail in the same frame the + * composer's FLIP measures its target, so the composer makes ONE move, straight + * to its place beside the rail, and only the transcript glides (spec 3.5 item + * 5, V2-08). Waiting for the stream made it move twice: the FLIP to the centre, + * then the glide 140px left. + * + * A chat that is still loading its transcript, and is known to have messages, + * is active too (vision V2-R5-6). It is a chat with a turn as far as the layout + * goes, and dropping the rail for the load re-centred the column 140px and + * brought it back when the transcript landed. Its phase stays `loading`, so the + * rail appears at rest. + */ +export function summaryRailChat({ + clean, + messageCount, + loading, + running, + creatingChat, + knownToHaveMessages = false, +}: SummaryRailChatInputs): { active: boolean; chatPhase: SummaryRailChatPhase } { + // Only until the transcript holds the message: from then on it is a chat + // with a turn like any other (`isCreatingSession` is never cleared after a + // chat is created). + const firstTurnStarting = creatingChat && messageCount === 0; + const loadingKnownChat = loading && knownToHaveMessages; + return { + active: !clean && (messageCount > 0 || running || firstTurnStarting || loadingKnownChat), + chatPhase: loading ? 'loading' : running || firstTurnStarting ? 'running' : 'idle', + }; +} + +/** Spread onto the split box beside the artifact panel's own props. */ +export interface SummaryRailSplitPaneProps { + 'data-summary-rail'?: ''; + style?: CSSProperties; +} + +export interface SummaryRailController { + /** Rung 0's answer for this pane right now. */ + fit: SummaryRailFit; + /** The rail is wanted, active, fits and this is not a phone-width browser. */ + shown: boolean; + /** Shown, or still playing its exit after the person closed it. */ + rendered: boolean; + /** The card's state: `closed` only while it plays its exit. */ + state: 'open' | 'closed'; + /** + * True when the rail appears because of geometry (a resize, the sidebar, a + * preview, a tab switch, a reload): it then appears at rest. False only for + * the commit in which the person opened it or the chat's first turn started. + * The card reads it once, at mount. + */ + still: boolean; + /** What the header button does: toggle the rail, or open the popover. */ + mode: 'rail' | 'popover'; + preference: SummaryRailPreference; + /** The width the grid gives the rail, in px. Meaningful while rendered. */ + width: number; + /** Open or close the docked rail (rail mode only). */ + toggle: () => void; + /** The card's exit has finished: remove the column. */ + onExited: () => void; + splitPaneProps: SummaryRailSplitPaneProps; +} + +export interface UseSummaryRailOptions { + /** The split box: the box the conversation, the rail and the preview share. */ + splitPaneRef: RefObject; + /** The chat has had a turn (or one is running). */ + active: boolean; + /** See {@link SummaryRailChatPhase}: `active` turning on only enters the rail in a `running` chat. */ + chatPhase: SummaryRailChatPhase; + isMobile: boolean; + /** Null when no preview is mounted. */ + previewMode: PreviewPanelMode | null; + /** The side preview's resolved width (`ArtifactPanelController.sideWidth`). */ + previewWidth: number; + /** + * Called after the commit in which the rail's column appeared or left because + * of an action (the person's toggle, or the first turn). `dx` is how far the + * centred conversation moved: half the rail's width, positive when the column + * opened (the conversation was to the right of where it is now). + * + * Exact, not estimated: rung 0 only seats the rail when the conversation keeps + * its full 760px measure on both sides of the change, and both the transcript + * column and the composer are centred in the cell the rail narrows. + */ + onGlide?: (dx: number, cause: 'open' | 'close') => void; +} + +function sameFit(a: SummaryRailFit | null, b: SummaryRailFit): boolean { + return !!a && a.fits === b.fits && a.width === b.width && a.measured === b.measured; +} + +function readWidth(element: HTMLElement | null): number { + const width = element?.clientWidth; + return typeof width === 'number' && Number.isFinite(width) ? width : 0; +} + +/** + * Rung 0's state for one chat: whether the docked Chat summary rail is on + * screen, how wide, and how it got there. + * + * The decision is derived during render from the measured pane width and the + * preview's live mode and width, so a preview that opens hides the rail in the + * SAME commit, and one that closes brings it back in the commit that removes the + * panel. Only the pane width is observed (a `ResizeObserver` on the split box, + * plus one synchronous read before the first paint so a tab switch never shows + * the column centred and then jumping left). A width that would hide the rail is + * applied at once; one that would show it waits for 160ms of stable width. + * + * It never resizes the window. + */ +export function useSummaryRail({ + splitPaneRef, + active, + chatPhase, + isMobile, + previewMode, + previewWidth, + onGlide, +}: UseSummaryRailOptions): SummaryRailController { + const [preference, setPreference] = useSummaryRailPreference(); + const [paneWidth, setPaneWidth] = useState(0); + // The previous committed answer, for the hysteresis. Null until this mount has + // committed one: the first sample uses the base threshold. + const [lastFit, setLastFit] = useState(null); + const fit = summaryRailFit({ paneWidth, previewMode, previewWidth, previous: lastFit }); + const shown = summaryRailVisible({ fit, preference, active, isMobile }); + + // "The first turn starts": `active` turning on, in a chat whose turn is + // running, after this chat mounted. A chat that mounts already active (a tab + // switch) is at rest, and so is one that becomes active because its history + // arrived (`chatPhase` was `loading`, or the transcript landed with no turn + // under way). This is React's "store information from previous renders" + // pattern, so the answer is known in the same render the rail first appears in. + const [previousActive, setPreviousActive] = useState(active); + const [previousPhase, setPreviousPhase] = useState(chatPhase); + const [animateEntrance, setAnimateEntrance] = useState(false); + if (previousPhase !== chatPhase) setPreviousPhase(chatPhase); + if (previousActive !== active) { + setPreviousActive(active); + if (active && chatPhase === 'running' && previousPhase !== 'loading') { + setAnimateEntrance(true); + } + } + + const [exiting, setExiting] = useState(false); + // A rail that stops fitting while it exits leaves at once with the grid. + const rendered = shown || (exiting && fit.fits); + const renderedWidth = rendered ? fit.width : 0; + const railMode = active && !isMobile ? summaryToggleMode(fit) : 'popover'; + + // ---- Measurement -------------------------------------------------------- + + // The latest inputs, for the observer's callback (it outlives renders). + const inputsRef = useRef({ previewMode, previewWidth, lastFit, fit }); + inputsRef.current = { previewMode, previewWidth, lastFit, fit }; + const settleTimerRef = useRef | null>(null); + const observedRef = useRef(null); + const observerRef = useRef(null); + + const sample = useCallback((width: number) => { + const { + previewMode: mode, + previewWidth: preview, + lastFit: previous, + fit: now, + } = inputsRef.current; + const next = summaryRailFit({ + paneWidth: width, + previewMode: mode, + previewWidth: preview, + previous, + }); + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } + if (!now.fits && next.fits) { + // Show only once the width has held still. + settleTimerRef.current = setTimeout(() => { + settleTimerRef.current = null; + setPaneWidth(width); + }, SUMMARY_RAIL_SHOW_SETTLE_MS); + return; + } + setPaneWidth(width); + }, []); + + // Attach to whichever element is the split box now. Runs after every commit + // but only does work when the element changed (an error screen replaces it), + // so the state it sets cannot chain: deliberately no dependency list. + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + const element = splitPaneRef.current; + if (element === observedRef.current) return; + observerRef.current?.disconnect(); + observerRef.current = null; + observedRef.current = element; + if (!element) return; + // Synchronous first read: React applies it before the first paint. + setPaneWidth(readWidth(element)); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => sample(readWidth(element))); + observer.observe(element); + observerRef.current = observer; + }); + + // The teardown FORGETS what it observed as well as disconnecting it. StrictMode + // (the renderer runs in it) unmounts every effect once and mounts it again; a + // teardown that only disconnected left the attach effect above looking at "the + // same element" with no observer behind it, so the rail never measured again + // after mount (V2-01): it kept its width while the window, the sidebar and the + // preview moved, and never came back after a hide. A layout effect, so it + // pairs with the layout effect that attaches. + useLayoutEffect( + () => () => { + observerRef.current?.disconnect(); + observerRef.current = null; + observedRef.current = null; + if (settleTimerRef.current) clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + }, + [] + ); + + // Keep the hysteresis input current. Idempotent: re-deriving with the answer + // as its own previous answer gives the same answer. + useLayoutEffect(() => { + if (!sameFit(lastFit, fit)) setLastFit(fit); + }, [fit, lastFit]); + + // ---- Motion ------------------------------------------------------------- + + const glideCauseRef = useRef<'close' | null>(null); + const previousRenderedWidthRef = useRef(renderedWidth); + const onGlideRef = useRef(onGlide); + onGlideRef.current = onGlide; + useLayoutEffect(() => { + const previous = previousRenderedWidthRef.current; + previousRenderedWidthRef.current = renderedWidth; + const cause: 'open' | 'close' | null = + glideCauseRef.current === 'close' && renderedWidth < previous + ? 'close' + : animateEntrance && renderedWidth > previous + ? 'open' + : null; + glideCauseRef.current = null; + if (!cause || previous === renderedWidth) return; + onGlideRef.current?.((renderedWidth - previous) / 2, cause); + }, [renderedWidth, animateEntrance]); + + // The entrance cause belongs to ONE commit: the card latched it at mount, and + // a rail that appears later (a resize) must appear at rest. + useLayoutEffect(() => { + if (animateEntrance) setAnimateEntrance(false); + }, [animateEntrance]); + + const exitingRef = useRef(exiting); + exitingRef.current = exiting; + const renderedWidthRef = useRef(renderedWidth); + renderedWidthRef.current = renderedWidth; + const finishExit = useCallback(() => { + if (!exitingRef.current) return; + // Only a column that is still on screen glides back; a rail the grid + // already dropped (it stopped fitting mid-exit) has nothing left to move. + if (renderedWidthRef.current > 0) glideCauseRef.current = 'close'; + setExiting(false); + }, []); + + useEffect(() => { + if (!exiting) return; + const timer = setTimeout(finishExit, SUMMARY_RAIL_EXIT_FALLBACK_MS); + return () => clearTimeout(timer); + }, [exiting, finishExit]); + + const shownRef = useRef(shown); + shownRef.current = shown; + const toggle = useCallback(() => { + if (shownRef.current) { + setExiting(true); + setPreference('closed'); + } else { + setExiting(false); + setAnimateEntrance(true); + setPreference('open'); + } + }, [setPreference]); + + const splitPaneProps: SummaryRailSplitPaneProps = rendered + ? { + 'data-summary-rail': '', + style: { '--summary-rail-width': `${renderedWidth}px` } as CSSProperties, + } + : {}; + + return { + fit, + shown, + rendered, + state: shown ? 'open' : 'closed', + still: !animateEntrance, + mode: railMode, + preference, + width: renderedWidth, + toggle, + onExited: finishExit, + splitPaneProps, + }; +} diff --git a/ui/desktop/src/main.appConfigHomeDir.test.ts b/ui/desktop/src/main.appConfigHomeDir.test.ts new file mode 100644 index 000000000..78433d0ae --- /dev/null +++ b/ui/desktop/src/main.appConfigHomeDir.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Two renderer files read `window.appConfig.get('BIOROUTER_HOME_DIR')` to write a path as `~/…`: + * the sidebar (its hover card and folder groups) and the terminal dock. The main process never + * put the key in the config it hands every window, so both read `undefined` and showed + * `/Users/me/Desktop` and a folder group named `me` instead of `~/Desktop` and `~` (vision V1-M6). + * The main process cannot be mounted in a unit test, so the config is read at the source, next to + * the readers, so the key cannot drift apart from them again. + */ +const MAIN = readFileSync(join(__dirname, 'main.ts'), 'utf8'); +const READERS = [ + 'components/BioRouterSidebar/AppSidebar.tsx', + 'components/InAppTerminalDock.tsx', +].map((file) => ({ file, source: readFileSync(join(__dirname, file), 'utf8') })); + +describe('BIOROUTER_HOME_DIR', () => { + it('is in the config object every window is created with, set to the OS home folder', () => { + const start = MAIN.indexOf('let appConfig = {'); + expect(start, 'no appConfig object in main.ts').toBeGreaterThan(-1); + const object = MAIN.slice(start, MAIN.indexOf('\n};\n', start)); + expect(object).toMatch(/^\s*BIOROUTER_HOME_DIR: os\.homedir\(\),$/m); + }); + + it('reaches every window: the per-window config spreads appConfig and does not override it', () => { + expect(MAIN).toMatch(/JSON\.stringify\(\{\s*\.\.\.appConfig,/); + expect(MAIN).not.toMatch(/^\s*BIOROUTER_HOME_DIR: (?!os\.homedir\(\),)/m); + }); + + it.each(READERS)('is read by $file under the same name', ({ source }) => { + expect(source).toContain("get('BIOROUTER_HOME_DIR')"); + }); +}); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 4d5849b7f..82eaf88d6 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -34,6 +34,7 @@ import { isIP } from 'node:net'; import dns from 'node:dns/promises'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; +import { createRequire } from 'node:module'; import started from 'electron-squirrel-startup'; import path from 'node:path'; import os from 'node:os'; @@ -48,6 +49,7 @@ import { type BiorouterdResult, type SharedDaemonLink, } from './biorouterd'; +import { loadNodePtyFrom, type NodePtyModule } from './nodePtyLoader'; import { TerminalSessionRegistry, maxTerminalSessionsPerOwner, @@ -218,6 +220,7 @@ import { biorouterExtensionsDir, unsandboxedConfigDirCandidates, } from './utils/biorouterPaths'; +import { describeLegacyFolderNotice, legacyFolderNotice } from './utils/appFolder'; import { fetchRegistryWithLastGood } from './utils/registryCache'; import { stagedAssetPath } from './utils/registryDownload'; import { readArtifactDirectoryTree } from './utils/artifactDirectory'; @@ -1319,6 +1322,10 @@ let appConfig = { BIOROUTER_PREDEFINED_MODELS: predefinedModels, BIOROUTER_API_HOST: 'http://127.0.0.1', BIOROUTER_WORKING_DIR: '', + // The person's home folder, so the renderer can write a path as `~/…` (the sidebar's hover card + // and folder groups, the terminal dock's working directory). Display only: nothing reads it + // to decide what a chat may touch. + BIOROUTER_HOME_DIR: os.homedir(), BIOROUTER_DEV_PROFILE_NAME: process.env.BIOROUTER_DEV_PROFILE_NAME || '', BIOROUTER_DEV_PROFILE_ROOT: process.env.BIOROUTER_DEV_PROFILE_ROOT || '', // If BIOROUTER_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) @@ -1481,6 +1488,51 @@ function showWindowPrompt(options: Electron.MessageBoxOptions) { return parent ? dialog.showMessageBox(parent, options) : dialog.showMessageBox(options); } +/** + * R-31: on Windows, when both Biorouter's folder and the one earlier releases + * used exist, the current one is used and anything saved in the other since + * the update is hidden. The daemon only logs that; this says it to the person, + * naming the folder, once per launch until they ask not to hear it again for + * that folder. Runs after the first window (so it is a sheet on it), and only + * in the instance that holds the single-instance lock. Never throws. + */ +async function noticeHiddenLegacyFolder(): Promise { + try { + const notice = legacyFolderNotice({ + platform: process.platform, + env: process.env, + homedir: os.homedir(), + }); + if (!notice) return; + log.warn( + `[Main] both ${notice.current} and ${notice.legacy} (the folder earlier releases used) exist; using ${notice.current}` + ); + if (loadSettings().dismissedFolderNotices?.includes(notice.legacy)) return; + const words = describeLegacyFolderNotice(notice); + const { response, checkboxChecked } = await showWindowPrompt({ + type: 'warning', + title: words.title, + message: words.message, + detail: words.detail, + buttons: words.buttons, + defaultId: 1, + cancelId: 1, + checkboxLabel: words.checkboxLabel, + noLink: true, + }); + if (response === 0) void shell.openPath(notice.legacy); + if (checkboxChecked) { + const settings = loadSettings(); + settings.dismissedFolderNotices = [ + ...new Set([...(settings.dismissedFolderNotices ?? []), notice.legacy]), + ]; + saveSettings(settings); + } + } catch (error) { + log.error('[Main] could not check for a hidden older Biorouter folder:', error); + } +} + const daemonReattach = createDaemonReattachController({ reportFailure: async (message) => { const { response } = await showWindowPrompt({ @@ -2113,7 +2165,8 @@ const createChat = async ( // Set up local keyboard shortcuts that only work when the window is focused mainWindow.webContents.on('before-input-event', (event, input) => { - if (input.key === 'r' && input.meta) { + // ⌘R reloads; ⌘⌥R is "Rename chat" in the renderer (spec 3.4), so it must pass through. + if (input.key === 'r' && input.meta && !input.alt) { mainWindow.reload(); event.preventDefault(); } @@ -2927,6 +2980,22 @@ ipcMain.handle('copy-selected-text', (event, id: unknown) => { ); }); +/** + * Whether an app-initiated window grow may animate (spec 3.16, Windows). The OS + * animates it, except when the person turned on Reduce Motion: then the grow is + * instant and the preview's own entrance (which already honours the setting in + * the renderer) is the only motion. The main process has no CSS media query to + * lean on, so it asks the system directly. A failure to ask answers "animate", + * the behaviour this replaced. + */ +function windowResizeMayAnimate(): boolean { + try { + return !systemPreferences.getAnimationSettings().prefersReducedMotion; + } catch { + return true; + } +} + ipcMain.handle('window:ensure-content-width', (event, minWidth: number) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win || !Number.isFinite(minWidth)) { @@ -2947,7 +3016,10 @@ ipcMain.handle('window:ensure-content-width', (event, minWidth: number) => { const maxContentWidth = Math.max(720, display.workArea.width); const targetContentWidth = Math.min(Math.ceil(minWidth), maxContentWidth); - win.setContentSize(targetContentWidth, currentContentBounds.height, true); + // Grow only, never shrink: the early return above leaves a wide enough + // window alone. + const animate = windowResizeMayAnimate(); + win.setContentSize(targetContentWidth, currentContentBounds.height, animate); const nextWindowBounds = win.getBounds(); const maxX = display.workArea.x + display.workArea.width - nextWindowBounds.width; @@ -2962,7 +3034,7 @@ ipcMain.handle('window:ensure-content-width', (event, minWidth: number) => { : Math.min(Math.max(nextWindowBounds.y, display.workArea.y), maxY); if (adjustedX !== nextWindowBounds.x || adjustedY !== nextWindowBounds.y) { - win.setBounds({ ...nextWindowBounds, x: adjustedX, y: adjustedY }, true); + win.setBounds({ ...nextWindowBounds, x: adjustedX, y: adjustedY }, animate); } const nextContentBounds = win.getContentBounds(); @@ -4934,12 +5006,11 @@ type TerminalSession = RegisteredTerminalSession & { resize: (cols: number, rows: number) => void; }; -type NodePtyModule = typeof import('node-pty'); - const terminalSessions = new TerminalSessionRegistry((error) => log.warn('[terminal] failed to dispose session:', error) ); let nodePtyModule: NodePtyModule | null | undefined; +let nodePtyLoading: Promise | undefined; const crewAuthenticationSessions = new Map(); const crewAuthenticationPending = new Set(); @@ -5081,12 +5152,31 @@ function ensureSpawnHelperExecutable(): void { } } +// V1-R3-1: the CommonJS loader goes first. `await import('node-pty')` alone can +// resolve to a module namespace that lists `spawn` and holds `undefined` for it, +// which surfaced as "pty.spawn is not a function" on every dock tab while the +// pipe fallback (which only runs on a thrown import) never got a turn. See +// `nodePtyLoader.ts`. The import stays as a second chance, and a candidate only +// counts once it has a `spawn` function. async function loadNodePty(): Promise { if (nodePtyModule !== undefined) return nodePtyModule; - try { - nodePtyModule = await import('node-pty'); - ensureSpawnHelperExecutable(); - } catch (error) { + // One load for concurrent callers (several dock tabs restore at once), so the + // verdict is logged once. + nodePtyLoading ??= (async () => { + const { module, via, problems } = await loadNodePtyFrom([ + { via: 'require', load: () => createRequire(__filename)('node-pty') }, + { via: 'import', load: () => import('node-pty') }, + ]); + if (module) { + for (const problem of problems) log.warn(`[terminal] node-pty ${problem}`); + if (problems.length > 0) log.warn(`[terminal] node-pty loaded through ${via} instead`); + try { + ensureSpawnHelperExecutable(); + } catch (error) { + log.warn('[terminal] could not check the node-pty spawn helper:', error); + } + return module; + } // In a packaged app this is a packaging regression, not a missing optional // feature: node-pty is a declared dependency that forge.config.ts keeps out // of `packagerConfig.ignore` precisely so it ships. The pipe fallback below @@ -5094,9 +5184,12 @@ async function loadNodePty(): Promise { // — so it looks like a broken terminal rather than a broken build. Log it // loudly enough that the next person greps for it. const level = app.isPackaged ? 'error' : 'warn'; - log[level]('[terminal] node-pty unavailable; falling back to process pipes:', error); - nodePtyModule = null; - } + log[level]( + `[terminal] node-pty unavailable; falling back to process pipes: ${problems.join('; ')}` + ); + return null; + })(); + nodePtyModule = await nodePtyLoading; return nodePtyModule; } @@ -6419,6 +6512,8 @@ async function appMain() { log.info('[Main] Skipping window creation in appMain - open-url already handled launch'); } + void noticeHiddenLegacyFolder(); + // Watch for the class of bug this whole area was fixed for (#88): anything // that blocks the main thread now says so in the log instead of being visible // only as "the app feels stuck". diff --git a/ui/desktop/src/mainWindowGrow.test.ts b/ui/desktop/src/mainWindowGrow.test.ts new file mode 100644 index 000000000..8ac9cce6e --- /dev/null +++ b/ui/desktop/src/mainWindowGrow.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Spec 3.16, Windows: when the app grows its own window (opening a preview in a + * window too narrow for it, `window:ensure-content-width`), the OS animates the + * grow unless the person turned on Reduce Motion, and the app never shrinks the + * window. The main process cannot be mounted in a unit test, so the handler is + * read at the source. + */ +const MAIN = readFileSync(join(__dirname, 'main.ts'), 'utf8'); + +function handlerBody(): string { + const start = MAIN.indexOf("ipcMain.handle('window:ensure-content-width'"); + expect(start, 'no window:ensure-content-width handler').toBeGreaterThan(-1); + const end = MAIN.indexOf('\n});\n', start); + return MAIN.slice(start, end); +} + +describe('the app-initiated window grow', () => { + it('asks the system for Reduce Motion', () => { + expect(MAIN).toMatch( + /function windowResizeMayAnimate\(\): boolean \{[\s\S]*?systemPreferences\.getAnimationSettings\(\)\.prefersReducedMotion/ + ); + }); + + it('passes that answer to both resize calls, never a literal true', () => { + const body = handlerBody(); + expect(body).toMatch(/const animate = windowResizeMayAnimate\(\);/); + expect(body).toMatch(/win\.setContentSize\([^)]*,\s*animate\)/); + expect(body).toMatch(/win\.setBounds\([\s\S]*?,\s*animate\)/); + expect(body).not.toMatch(/,\s*true\)/); + }); + + it('only ever grows: a wide enough window is left alone', () => { + expect(handlerBody()).toMatch( + /if \(currentContentBounds\.width >= minWidth[\s\S]*?\) \{\s*return \{\s*expanded: false/ + ); + }); +}); diff --git a/ui/desktop/src/nativeSecretPrompt.ts b/ui/desktop/src/nativeSecretPrompt.ts index ecd0f8b04..4eea0bf42 100644 --- a/ui/desktop/src/nativeSecretPrompt.ts +++ b/ui/desktop/src/nativeSecretPrompt.ts @@ -113,9 +113,9 @@ export async function promptNativeSecret( const env = Object.fromEntries( Object.entries(process.env).filter( ([key]) => - !/^(BIOROUTER|GOOSE)_.+(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|PASSCODE|CREDENTIAL|KEY|DIGEST)/i.test( + !/^BIOROUTER_.+(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|PASSCODE|CREDENTIAL|KEY|DIGEST)/i.test( key - ) && !/^(BIOROUTER|GOOSE)_SERVER__/i.test(key) + ) && !/^BIOROUTER_SERVER__/i.test(key) ) ); const child = spawn(program, args, { diff --git a/ui/desktop/src/nodePtyLoader.test.ts b/ui/desktop/src/nodePtyLoader.test.ts new file mode 100644 index 000000000..5d1eb1fd5 --- /dev/null +++ b/ui/desktop/src/nodePtyLoader.test.ts @@ -0,0 +1,173 @@ +/** + * V1-R3-1: every terminal dock tab printed "Could not start terminal: pty.spawn is + * not a function". `await import('node-pty')` had resolved, to a module namespace + * that listed `spawn` and held `undefined` for it, so nothing threw and the pipe + * fallback never ran. These pin what `loadNodePty` now does with each shape a + * loader can hand back. + * + * The broken namespace is built by hand. It cannot be produced from a vitest + * process (the state came from a long-lived Electron main process, and a fresh + * one in the same directory loaded node-pty fine), and the native `pty.node` is + * built for Electron's ABI, so a test that loaded the real package would fail on + * every machine for an unrelated reason. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describeNodePtyShape, loadNodePtyFrom, usableNodePty } from './nodePtyLoader'; + +const spawn = () => ({ pid: 1 }); +const working = { spawn, fork: spawn, open: spawn, native: {} }; + +// The namespace seen in the failing process: the right names, no values. +const brokenNamespace = { + __esModule: true, + createTerminal: undefined, + default: undefined, + fork: undefined, + native: undefined, + open: undefined, + spawn: undefined, +}; + +describe('usableNodePty', () => { + it('accepts CommonJS exports that have a spawn function', () => { + expect(usableNodePty(working)).toBe(working); + }); + + it('accepts a namespace that carries spawn itself', () => { + const namespace = { ...working, default: working }; + expect(usableNodePty(namespace)).toBe(namespace); + }); + + it('takes module.exports from default when the names were not detected', () => { + expect(usableNodePty({ default: working })).toBe(working); + }); + + it('refuses a namespace that lists spawn and holds nothing for it', () => { + expect(usableNodePty(brokenNamespace)).toBeNull(); + }); + + it('refuses a spawn that is not a function', () => { + expect(usableNodePty({ spawn: 'spawn' })).toBeNull(); + expect(usableNodePty({ default: { spawn: {} } })).toBeNull(); + }); + + it('refuses things that are not modules at all', () => { + expect(usableNodePty(undefined)).toBeNull(); + expect(usableNodePty(null)).toBeNull(); + expect(usableNodePty('node-pty')).toBeNull(); + expect(usableNodePty(42)).toBeNull(); + }); +}); + +describe('describeNodePtyShape', () => { + it('says the names are there and the function is not', () => { + const line = describeNodePtyShape(brokenNamespace); + expect(line).toContain('spawn is undefined'); + expect(line).toContain('default.spawn is undefined'); + expect(line).toContain('open, spawn]'); + }); + + it('says what a non-object was', () => { + expect(describeNodePtyShape(undefined)).toBe('got undefined'); + expect(describeNodePtyShape(null)).toBe('got null'); + }); +}); + +describe('loadNodePtyFrom', () => { + it('returns the first loader that works and never runs the rest', async () => { + const second = vi.fn(() => working); + const result = await loadNodePtyFrom([ + { via: 'require', load: () => working }, + { via: 'import', load: second }, + ]); + expect(result.module).toBe(working); + expect(result.via).toBe('require'); + expect(result.problems).toEqual([]); + expect(second).not.toHaveBeenCalled(); + }); + + it('moves on from a namespace with no spawn function, the V1-R3-1 shape', async () => { + const result = await loadNodePtyFrom([ + { via: 'import', load: async () => brokenNamespace }, + { via: 'require', load: () => working }, + ]); + expect(result.module).toBe(working); + expect(result.via).toBe('require'); + expect(result.problems).toHaveLength(1); + expect(result.problems[0]).toMatch(/^import: loaded, but there is no spawn function/); + }); + + it('moves on from a loader that throws, and keeps its message', async () => { + const result = await loadNodePtyFrom([ + { + via: 'require', + load: () => { + throw new Error("Cannot find module 'node-pty'"); + }, + }, + { via: 'import', load: async () => ({ default: working }) }, + ]); + expect(result.module).toBe(working); + expect(result.via).toBe('import'); + expect(result.problems).toEqual(["require: Cannot find module 'node-pty'"]); + }); + + it('moves on from a loader that rejects', async () => { + const result = await loadNodePtyFrom([ + { via: 'import', load: () => Promise.reject(new Error('dlopen failed')) }, + { via: 'require', load: () => working }, + ]); + expect(result.module).toBe(working); + expect(result.problems).toEqual(['import: dlopen failed']); + }); + + it('reports null with a line per loader when none works, so the caller can use pipes', async () => { + const result = await loadNodePtyFrom([ + { via: 'require', load: () => brokenNamespace }, + { + via: 'import', + load: () => { + throw 'not an Error'; + }, + }, + ]); + expect(result.module).toBeNull(); + expect(result.via).toBeNull(); + expect(result.problems).toHaveLength(2); + expect(result.problems[0]).toMatch(/^require: /); + expect(result.problems[1]).toBe('import: not an Error'); + }); + + it('reports null for an empty list', async () => { + expect(await loadNodePtyFrom([])).toEqual({ module: null, via: null, problems: [] }); + }); +}); + +describe('main.ts loadNodePty', () => { + // jsdom runs no Electron, so the order the loaders are tried in is pinned at + // the source, where it is written. + const main = readFileSync(resolve(__dirname, 'main.ts'), 'utf8'); + const start = main.indexOf('async function loadNodePty()'); + const body = main.slice(start, main.indexOf('function disposeTerminalSession')); + + it('asks the CommonJS loader before the ESM import', () => { + expect(start).toBeGreaterThan(-1); + const requireAt = body.indexOf("via: 'require'"); + const importAt = body.indexOf("via: 'import'"); + expect(requireAt).toBeGreaterThan(-1); + expect(importAt).toBeGreaterThan(requireAt); + expect(body).toContain("createRequire(__filename)('node-pty')"); + }); + + it('does not take the bare import as the answer any more', () => { + expect(body).not.toMatch(/nodePtyModule\s*=\s*await import\(/); + expect(body).toContain('loadNodePtyFrom('); + }); + + it('still falls back to pipes when no loader worked', () => { + expect(body).toContain('falling back to process pipes'); + expect(body).toContain('return null;'); + }); +}); diff --git a/ui/desktop/src/nodePtyLoader.ts b/ui/desktop/src/nodePtyLoader.ts new file mode 100644 index 000000000..cb8eb3b77 --- /dev/null +++ b/ui/desktop/src/nodePtyLoader.ts @@ -0,0 +1,107 @@ +/** + * Getting a usable `node-pty` out of the Electron main process. + * + * `loadNodePty` in `main.ts` used to be one line, `await import('node-pty')`, and + * trusted whatever came back. node-pty is a CommonJS package, so that line goes + * through Node's ESM-to-CJS bridge, and the bridge can hand back a module + * namespace whose NAMES are right and whose VALUES are all `undefined`: the + * namespace lists `spawn`, `fork` and `open`, and `typeof ns.spawn` is + * `'undefined'`. The terminal dock then printed + * `Could not start terminal: pty.spawn is not a function` on every tab, and + * because the import itself had not thrown, the pipe fallback never ran. + * + * Seen in a dev app launched from a git worktree whose `node_modules` is a + * symlink into the main checkout (round 3 of the redesign review, V1-R3-1). In + * that process `Module._cache` held two entries for node-pty's `lib/index.js`: + * the real path, fully loaded with a working `spawn`, and the worktree path, + * `loaded: false` with empty exports. The ESM namespace had been built from the + * second. `createRequire()('node-pty')` in the SAME process returned + * the first, so the plain CommonJS loader is immune. Fresh processes in the same + * layout (Electron run as Node with that directory as its cwd, and a bare + * Electron main process over a symlinked `node_modules`) did not reproduce it, so + * what puts a long-lived process in that state is not pinned down. The cure does + * not need it to be: do not depend on the bridge. + * + * So: ask the CommonJS loader first, keep the import as a second chance, and + * accept a candidate only when it really has a `spawn` function. Anything else + * is reported with its shape and the caller falls back to pipes, instead of + * surfacing a `TypeError` from the first keystroke. + */ +import type * as NodePty from 'node-pty'; + +export type NodePtyModule = typeof NodePty; + +export interface NodePtyLoader { + /** Names the loader in the log line: `require` or `import`. */ + via: string; + /** May return the module or a promise of it. A throw or a rejection is reported, not propagated. */ + load: () => unknown; +} + +export interface NodePtyLoadResult { + module: NodePtyModule | null; + /** The loader that produced `module`, or null when none did. */ + via: string | null; + /** One line per loader that produced nothing usable, in the order they were tried. */ + problems: string[]; +} + +function isObjectLike(value: unknown): value is Record { + return (typeof value === 'object' || typeof value === 'function') && value !== null; +} + +/** + * The part of a loader's result that can start a shell, or null. + * + * `candidate` is either CommonJS `module.exports` or an ESM namespace. A namespace + * whose names were detected carries `spawn` itself; one whose names were not + * carries only `default`, which is `module.exports`. A `spawn` that is not a + * function does not count, which is the whole point: the broken namespace above + * has the key and not the function. + */ +export function usableNodePty(candidate: unknown): NodePtyModule | null { + if (!isObjectLike(candidate)) return null; + if (typeof candidate.spawn === 'function') return candidate as unknown as NodePtyModule; + const inner = candidate.default; + if (isObjectLike(inner) && typeof inner.spawn === 'function') { + return inner as unknown as NodePtyModule; + } + return null; +} + +/** A one-line description of what a loader returned, for the log of a load that did not work. */ +export function describeNodePtyShape(candidate: unknown): string { + if (!isObjectLike(candidate)) return `got ${candidate === null ? 'null' : typeof candidate}`; + const inner = candidate.default; + const names = Object.keys(candidate); + return ( + `names [${names.join(', ')}], spawn is ${typeof candidate.spawn}, ` + + `default.spawn is ${isObjectLike(inner) ? typeof inner.spawn : typeof inner}` + ); +} + +/** + * Try each loader in order and return the first usable module. + * + * Loaders after the winner are not called. A loader that throws, rejects or + * returns something unusable is recorded in `problems` and the next one runs; the + * caller decides how loudly to report them. + */ +export async function loadNodePtyFrom( + loaders: readonly NodePtyLoader[] +): Promise { + const problems: string[] = []; + for (const { via, load } of loaders) { + try { + const candidate = await load(); + const module = usableNodePty(candidate); + if (module) return { module, via, problems }; + problems.push( + `${via}: loaded, but there is no spawn function (${describeNodePtyShape(candidate)})` + ); + } catch (error) { + problems.push(`${via}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { module: null, via: null, problems }; +} diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 65ff9b0f7..1b23cba4e 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -6,6 +6,8 @@ if (typeof (globalThis as Record).process === 'undefined') { import React, { Suspense, lazy } from 'react'; import ReactDOM from 'react-dom/client'; import { ConfigProvider } from './components/ConfigContext'; +import { startFirstRunNoticeCount } from './components/privacy/firstRunNoticeDecision'; +import { holdFirstRunNoticeForFirstPage } from './components/privacy/firstRunNoticeHandoff'; import { ErrorBoundary } from './components/ErrorBoundary'; import { client } from './api/client.gen'; import { daemonClientConfig } from './utils/daemonClient'; @@ -36,7 +38,7 @@ declare global { interface Window { __BIOROUTER_HEADLESS_CONFIG__?: Partial; /** Boot splash controller, defined by the inline script in index.html. */ - __brBootSplash?: { dismiss: () => void; isShown: () => boolean }; + __brBootSplash?: { dismiss: () => void; isShown: () => boolean; isGone: () => boolean }; } } @@ -582,6 +584,15 @@ if (needsHeadlessElectron || typeof window.appConfig === 'undefined') { } console.log('connecting at', biorouterApiHost); client.setConfig(daemonClientConfig(biorouterApiHost, await window.electron.getSecretKey())); + // The day-one privacy notice's count starts here, in parallel with the app chunk loading and + // the first-run page's provider fetch, and the first-run page waits for its answer so the + // notice and the page arrive together (FR-R4-4). A leaf module: it pulls in no dialog. + startFirstRunNoticeCount(); + // And the notice then waits for the splash to leave and the first page to draw before it + // opens, so a count that wins the race does not put the dialog over the fading wordmark and + // an empty ground (FR-R5-1). Declared here, before React renders, because the page that + // clears it mounts in the same commit as the gate and could not declare it in time. + holdFirstRunNoticeForFirstPage(); } ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/ui/desktop/src/store/reasoningEffort.ts b/ui/desktop/src/store/reasoningEffort.ts index 2d252932e..76ee5b126 100644 --- a/ui/desktop/src/store/reasoningEffort.ts +++ b/ui/desktop/src/store/reasoningEffort.ts @@ -13,12 +13,24 @@ export const REASONING_EFFORT_LABELS: Record = { deep: 'Deep', }; +/** + * One line each, read in the effort item's tooltip (spec 3.7), so no trailing + * period (copy rules: one-line menu descriptions take none). + */ export const REASONING_EFFORT_DESCRIPTIONS: Record = { - quick: 'Fast answers with minimal exploration.', - normal: "Use this chat's /effort setting, or the model's default depth.", - deep: 'More thinking and exploration.', + quick: 'Fast answers, little exploration', + normal: "This chat's /effort setting, or the model's default", + deep: 'More thinking and exploration', }; +/** + * What the three levels mean, in the InfoTip beside the model menu's Effort slider (R-26): the + * descriptions above, as the two sentences an InfoTip holds. The slider's thumb is described by + * it, so a screen reader hears it on focus. + */ +export const REASONING_EFFORT_HELP = + "Quick answers fast with little exploration, and Deep thinks and explores more. Normal uses this chat's /effort setting, or the model's default."; + export function sessionReasoningScope(sessionId: string): string { return `session:${sessionId}`; } diff --git a/ui/desktop/src/styles/artifactPaper.test.ts b/ui/desktop/src/styles/artifactPaper.test.ts index 6ebd9a44c..9158025a6 100644 --- a/ui/desktop/src/styles/artifactPaper.test.ts +++ b/ui/desktop/src/styles/artifactPaper.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import postcss from 'postcss'; import { describe, expect, it } from 'vitest'; import { PAPER_GUTTER_EM } from '../components/artifacts/artifactUtils'; import { GENERATED_THEMES, THEME_FAMILY_IDS } from './themes.generated'; @@ -125,32 +126,90 @@ describe('artifact panel paper', () => { expect(css.trimEnd().slice(-400)).not.toContain('.br-paper'); }); - it('gives the code view and table an overflow hint', () => { - const body = ruleBody(".br-paper-scroll[data-view='code'], .br-paper-table-scroll"); + it('gives the code view an overflow hint', () => { + const body = ruleBody(".br-paper-scroll[data-view='code']"); expect(body).toMatch(/no-repeat local,/); expect(body).toMatch( /var\(--paper-hint-ink\), transparent\) 100% 0 \/ 14px 100% no-repeat scroll/ ); + // A table draws its own fade; the scroller's background hint is the code view's. + expect(flat).not.toMatch(/\.br-paper-table-scroll \{[^}]*no-repeat local/); + expect(flat).not.toContain('br-paper-fill-hint'); + expect(flat).not.toContain('scroll-state(stuck'); }); - it('keeps fenced code on the syntax palette ground with an overflow hint', () => { - const body = ruleBody('.biorouter-markdown .biorouter-md-code-body'); - expect(body).toContain('var(--background-code)'); - expect(body).toMatch(/no-repeat local,/); - expect(body).toMatch(/no-repeat scroll/); + // V2-09: a CSV scrolls sideways in a narrow panel, and nothing said so. Every + // row's filler cell sticks to the scroller's right edge and paints 40px of the + // row's own ground over the text, while `data-overflow` says more lies to the + // right (DelimitedTable). A mask on the scroller would fade its vertical + // scrollbar too, which is why the fade is not one. + it('fades a scrolling table out at its right edge, on each row’s own ground', () => { + expect(ruleBody('.br-paper-table')).toContain('--paper-fade-width: 40px;'); + // Every row, the header included, rests on the table's one fill (R-27), so + // there is one fade ground and no second one for the header. + expect(ruleBody('.br-paper-table')).toContain('--paper-fade-ground: var(--background-fill);'); + expect(flat).not.toContain('--paper-fade-head-ground'); + const filler = ruleBody('.br-paper-table .br-paper-fill'); + expect(filler).toContain('position: sticky;'); + expect(filler).toContain('right: 0;'); + const fade = ruleBody('.br-paper-table .br-paper-fill::after'); + expect(fade).toContain('width: var(--paper-fade-width);'); + expect(fade).toContain('pointer-events: none;'); + expect(fade).toContain('display: none;'); + expect(fade).toMatch(/linear-gradient\(to left, var\(--paper-fade-ground\), transparent\)/); + // A hovered row fades into its own ground: the strong step the row takes. + expect(ruleBody('.br-paper-table tbody tr:hover .br-paper-fill::after')).toContain( + 'var(--background-fill-strong)' + ); + // Drawn only while more lies to the right, and never over forced colours. + expect( + ruleBody(".br-paper-table-scroll[data-overflow='true'] .br-paper-fill::after") + ).toContain('display: block;'); + expect(flat).toMatch( + /@media \(forced-colors: active\) \{ \.br-paper-table \.br-paper-fill::after \{ display: none !important;/ + ); + }); + + it('keeps fenced code in the well with a right-edge fade while it scrolls', () => { + expect(ruleBody('.br-md-code')).toContain('background-color: var(--background-well);'); + expect(css).toMatch( + /\.br-md-code-body\[data-overflow='true'\],\s*\.br-md-table-scroll\[data-overflow='true'\]\s*\{\s*mask-image: linear-gradient\(to right, black calc\(100% - 40px\), transparent\);/ + ); }); // A narrow CSV used to stretch its columns across the whole column. it('packs table columns and lets a filler cell take the slack', () => { - expect(ruleBody('.br-paper-table td')).toContain('width: 1px;'); - expect(ruleBody('.br-paper-table th')).toContain('width: 1px;'); - expect(ruleBody('.br-paper-table .br-paper-fill')).toContain('width: auto;'); + expect(ruleBody('.br-paper-table.br-md-table td')).toContain('width: 1px;'); + expect(ruleBody('.br-paper-table.br-md-table th')).toContain('width: 1px;'); + expect(ruleBody('.br-paper-table.br-md-table .br-paper-fill')).toContain('width: auto;'); expect(ruleBody('.br-paper-table')).toContain('width: max-content;'); expect(ruleBody('.br-paper-table')).toContain( - 'min-width: min( calc(var(--paper-column) + var(--paper-rownum-width)), calc(100cqw - var(--paper-lead) - var(--paper-gutter)) );' + 'min-width: min(var(--paper-column), calc(100cqw - var(--paper-lead) - var(--paper-gutter)));' ); }); + // One table look (spec section 0): the CSV wears the shared recipe, and only + // undoes the corner clip so its header can stick. + it('puts a CSV on the shared table recipe with a sticky header', () => { + const source = readFileSync('src/components/artifacts/DelimitedTable.tsx', 'utf-8'); + expect(source).toContain('className="br-md-table br-paper-table"'); + expect(ruleBody('.br-paper-table.br-md-table')).toContain('overflow: visible;'); + const head = ruleBody('.br-paper-table.br-md-table th'); + expect(head).toContain('position: sticky;'); + expect(head).toContain('box-shadow: inset 0 -1px 0 var(--border-subtle);'); + // The sticky header paints the table's own fill, so rows passing under it + // never show through (R-27: no second ground, which would read as a hover). + expect(head).toContain('background-color: var(--background-fill);'); + // The frame sits on the column edge the bar's name and the prose share… + expect(ruleBody('.br-paper-table-frame')).toContain('padding-left: var(--paper-inset);'); + // …and leaves it only for a table that fits by doing so (V2-09: a 1300px + // table in a 600px panel sat at the 10px lead, 18px left of the bar's name). + const yields = ruleBody('.br-paper-table-frame[data-yield]'); + expect(yields).toContain('min(var(--paper-inset),'); + expect(yields).toContain('var(--paper-lead)'); + expect(flat).not.toContain('.br-paper-table-note'); + }); + // The gutter stays put while a long line scrolls under it; it must stick to // the paper scroller and be opaque, so no glyph shows through. it('keeps the code gutter sticky and opaque', () => { @@ -175,24 +234,18 @@ describe('artifact panel paper', () => { // dark). What is left for this file is the part that checker cannot see — // whether this rule uses those tokens at all. it('paints the shared markdown table header from tokens, never literals', () => { - const header = ruleBody('.biorouter-markdown.prose thead th'); - const band = ruleBody('.biorouter-markdown.prose thead'); - expect(band, 'thead rule').not.toBeNull(); - expect(header, 'thead th rule').not.toBeNull(); - expect(band).toContain('background: var(--background-medium);'); - expect(header).toContain('color: var(--text-default);'); - // The point of the exercise: no hex may come back, in either rule. - expect(band).not.toMatch(/#[0-9a-f]{3,8}\b/i); + const header = ruleBody('.br-md-table thead :where(th, td), .br-md-table th[data-head]'); + expect(header, 'table header rule').not.toBeNull(); + // The header rests on a ground token: the page's muted ground, or (R-27, the + // filled table) the table's own fill, never a literal. + expect(header).toMatch(/background-color: var\(--background-(muted|fill)\);/); + expect(header).toContain('color: var(--text-muted);'); + // The point of the exercise: no hex may come back. expect(header).not.toMatch(/#[0-9a-f]{3,8}\b/i); - // And no `.dark` override may reintroduce one — the tokens already flip, so + // And no `.dark` override may reintroduce one: the tokens already flip, so // a dark-mode rule here is a literal waiting to be written. - expect(ruleBody('.dark .biorouter-markdown.prose thead')).toBeNull(); - expect(ruleBody('.dark .biorouter-markdown.prose thead th')).toBeNull(); - expect( - ruleBody( - '.biorouter-markdown.prose thead th :where(strong, em, a, button:not(.biorouter-inline-code), span:not(.biorouter-inline-code))' - ) - ).toContain('color: inherit;'); + expect(css).not.toMatch(/\.dark \.br-md-table/); + expect(ruleBody('.br-md-table thead :where(strong, em, a)')).toContain('color: inherit;'); }); }); @@ -244,3 +297,245 @@ describe('selection', () => { expect(css.slice(start, end)).not.toContain('--selection'); }); }); + +/** + * The panel's own stylesheet (`components/artifacts/preview-panel.css`, spec 3.8): + * two grounds for every kind, the 32px bar that yields its meta before its + * controls, and the fades that keep any part of the panel from popping. + */ +describe('the preview panel stylesheet', () => { + const panelCss = readFileSync('src/components/artifacts/preview-panel.css', 'utf-8'); + const panelFlat = panelCss.replace(/\s+/g, ' '); + function panelRule(selector: string): string | null { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Anchored after a rule or a comment, so `.br-preview-bar` cannot match the + // tail of `.br-preview-panel > .br-preview-bar`. + const match = new RegExp(`(?:^|[}/])\\s*${escaped} \\{([^}]*)\\}`).exec(panelFlat); + return match ? match[1] : null; + } + + it('puts every kind on paper, under a band-ground strip', () => { + expect(panelRule('.br-preview-panel')).toContain('background: var(--background-default);'); + expect(panelRule('.br-preview-body')).toContain('background: var(--background-default);'); + }); + + it('draws the bar at the tab height with one hairline', () => { + const bar = panelRule('.br-preview-bar'); + expect(bar).toContain('height: var(--tab-height);'); + expect(bar).toContain('border-bottom: 1px solid var(--border-subtle);'); + }); + + it('lets the meta give way first and never shrinks the controls', () => { + expect(panelRule('.br-preview-bar__meta')).toContain('flex: 0 100000 auto;'); + expect(panelRule('.br-preview-bar__actions')).toContain('flex: none;'); + }); + + // An animation on the hook's marker, not a transition: the panel is inserted + // and opened before its first style pass, so a transition never ran and the + // strip appeared in one frame (measured in the harness's panel mode). + it('fades the strip and the bar with the body, never moving them', () => { + const fade = panelRule( + ".br-preview-panel[data-preview-motion='open'] > .br-tabstrip, .br-preview-panel[data-preview-motion='open'] > .br-preview-bar" + ); + expect(fade).toContain('animation: br-preview-fade-in var(--dur-med) var(--ease-out) both;'); + const motion = readFileSync('src/components/artifacts/usePreviewMotion.ts', 'utf-8'); + expect(motion).toContain('panel.dataset.previewMotion = change;'); + expect( + panelRule( + ".br-preview-panel[data-panel-open='false'] > .br-tabstrip, .br-preview-panel[data-panel-open='false'] > .br-preview-bar" + ) + ).toContain('opacity: 0;'); + }); + + it('holds a frame transparent until it has loaded', () => { + expect(panelRule('.br-preview-frame')).toContain('opacity: 0;'); + expect(panelRule(".br-preview-frame[data-loaded='true']")).toContain('opacity: 1;'); + }); + + it('animates only opacity and transform, on the tokens', () => { + const code = panelCss.replace(/\/\*[\s\S]*?\*\//g, ''); + for (const [, property] of code.replace(/\s+/g, ' ').matchAll(/transition: ([a-z-]+) /g)) { + expect(['opacity', 'background-color', 'border-left-color']).toContain(property); + } + // The one literal: the skeleton waits 200ms before it shows (spec 3.8), a + // delay rather than a motion tier. + expect(code.replace(/animation-delay: 200ms;/g, '')).not.toMatch(/\d+ms/); + expect(code).not.toMatch(/cubic-bezier/); + }); +}); + +describe('the document front matter and the fold chevron', () => { + it('sets the front-matter title on the title role, at 400', () => { + const title = ruleBody('.br-paper-title'); + expect(title).toContain('font-size: var(--text-title);'); + expect(title).toContain('font-weight: var(--text-title--font-weight);'); + expect(title).not.toMatch(/700|28px/); + expect(ruleBody('.br-paper-subtitle')).toContain('font-size: var(--text-body);'); + expect(ruleBody('.br-paper-byline')).toContain('font-size: var(--text-supporting);'); + }); + + it('turns the fold chevron outside the motion-free rung-2 block', () => { + const resizing = css.indexOf('body.biorouter-window-resizing .biorouter-sidebar-inset-depth'); + const turn = css.indexOf('.br-preview-fold-toggle > svg {'); + expect(resizing).toBeGreaterThan(0); + expect(turn).toBeGreaterThan(resizing); + expect(ruleBody('.br-preview-fold-toggle > svg')).toContain( + 'transition: rotate var(--dur-fast) var(--ease-out);' + ); + }); +}); + +/** + * FILLS BEFORE LINES in the panel (R-27, owner message 13, design.md 9.11). A + * box or a control in the preview is a subtle fill with no resting outline; a + * line stays only where it is structural. The second half is a ratchet: every + * rule in the panel's own families that draws an edge in a border token is named + * here, so a new outline cannot arrive without someone writing down why it is + * structure. + */ +describe('fills before lines in the preview panel', () => { + const panelCss = readFileSync('src/components/artifacts/preview-panel.css', 'utf-8'); + const paperStart = css.indexOf('/* ── Preview paper'); + // The paper block runs to the focus-surface comment that follows the notebook. + const paperEnd = css.indexOf("P5 / D-15's focus surface"); + const paperCss = css.slice(paperStart, paperEnd); + + /** Selectors of every rule that draws an edge in a border token. */ + function edgeSelectors(source: string): string[] { + const code = source.replace(/\/\*[\s\S]*?\*\//g, ''); + const out: string[] = []; + for (const [, selector, body] of code.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const draws = body + .split(';') + .some( + (declaration) => + /^\s*(border(-[a-z-]+)?|box-shadow|outline)\s*:/.test(declaration) && + /var\(--border-/.test(declaration) + ); + if (draws) out.push(selector.replace(/\s+/g, ' ').trim()); + } + return out; + } + + it('draws edges only where they are structure, and says which', () => { + expect(edgeSelectors(panelCss).sort()).toEqual( + [ + // The pane's divider from the conversation. + '.br-preview-panel', + // The 32px bar's bottom edge: the band the file's name sits in. + '.br-preview-bar', + // A directory's rail divider, and its header band's edge (aligned with the bar's). + '.br-preview-rail', + '.br-preview-rail__filter', + // A rendered page's own edge on the white paper (PDF, Word, a slide): the + // document's boundary, drawn outside the page, not a box around UI. + '.br-preview-page', + '.artifact-docx-preview .docx-wrapper > section.docx', + ".artifact-pptx-preview > div > div[style*='box-shadow']", + ].sort() + ); + expect(edgeSelectors(paperCss).sort()).toEqual( + [ + // The title block's divider from the body: a rule in a document. + '.br-paper-frontmatter', + // A table's rule under its header: inside the filled table, structure. + '.br-paper-table.br-md-table th', + ].sort() + ); + }); + + it('fills the CSV table, with a transparent border, instead of framing it', () => { + const table = ruleBody('.br-paper-table.br-md-table'); + expect(table).toContain('border-color: transparent;'); + expect(table).toContain('background-color: var(--background-fill);'); + // The fill stops at the padding box, so the transparent border never rings it. + expect(table).toContain('background-clip: padding-box;'); + expect(ruleBody('.br-paper-table tbody tr:hover td')).toContain( + 'background-color: var(--background-fill-strong);' + ); + }); + + it('gives the notebook one radius for its source, figure and error boxes', () => { + for (const selector of ['.br-paper-well', '.br-paper-nb-error', '.br-paper-nb-figure']) { + expect(ruleBody(selector), selector).toContain('border-radius: var(--radius-container);'); + } + // A figure is a tile of the fill; the ring that used to be its edge is gone. + const figure = ruleBody('.br-paper-nb-figure'); + expect(figure).toContain('background-color: var(--background-fill);'); + expect(ruleBody('.br-paper-nb-figure > img')).not.toContain('box-shadow'); + }); + + it('rests the sheet picker on the fill and steps it to the strong fill', () => { + const flatPanel = panelCss.replace(/\s+/g, ' '); + const rule = (selector: string) => + new RegExp( + `(?:^|[}/])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\{([^}]*)\\}` + ).exec(flatPanel)?.[1] ?? ''; + const picker = rule('.br-preview-select'); + expect(picker).toContain('background: var(--background-fill);'); + expect(picker).toContain('border: 1px solid transparent;'); + expect(rule(".br-preview-select:hover, .br-preview-select[data-state='open']")).toContain( + 'background: var(--background-fill-strong);' + ); + }); + + it('draws the directory filter and the address field as the app’s filled field', () => { + const viewer = readFileSync('src/components/artifacts/ArtifactViewer.tsx', 'utf-8'); + expect(viewer).toContain('className="br-field br-preview-rail__input border"'); + const page = readFileSync('src/components/artifacts/WebPagePreview.tsx', 'utf-8'); + expect(page).toMatch(/className="br-field h-6 w-full min-w-0 truncate rounded-element border /); + // The old bare-input recipe, a ground written by hand, is gone from both. + expect(page).not.toContain('bg-background-muted px-2'); + expect(viewer).not.toContain('bg-transparent text-secondary text-text-default outline-none'); + }); +}); + +/** + * The stylesheet PARSES the way these tests read it. Every other assertion in + * this file reads rule bodies out of the source with a regular expression, and a + * regular expression happily finds a declaration that a real parser would never + * see: commit 0807c69db staged the CSV frame's hunks out of order, so main.css + * carried `padding-left: …; }` and a selector INSIDE a comment, lost the + * `[data-yield]` rule and the `.br-paper-table` rule, and every test here still + * passed. A parse is the check that cannot be fooled that way. + */ +describe('the preview families parse', () => { + const root = postcss.parse(css); + const selectors = new Set(); + root.walkRules((rule) => { + selectors.add(rule.selector.replace(/\s+/g, ' ')); + }); + + it('has every rule the panel relies on as a rule, not as text in a comment', () => { + for (const selector of [ + '.br-paper-table-frame', + '.br-paper-table-frame[data-yield]', + '.br-paper-table', + '.br-paper-table.br-md-table', + '.br-paper-table.br-md-table th', + '.br-paper-table .br-paper-fill::after', + '.br-paper-nb', + '.br-paper-nb > .br-paper-nb-cell:first-child', + '.br-paper-well', + '.br-paper-nb-figure', + '.br-paper-nb-figure > img', + ]) { + expect(selectors.has(selector), selector).toBe(true); + } + }); + + it('keeps declarations out of comments', () => { + const paperStart = css.indexOf('/* ── Preview paper'); + const paperEnd = css.indexOf("P5 / D-15's focus surface"); + let seen = 0; + root.walkComments((comment) => { + const at = comment.source?.start?.offset ?? -1; + if (at < paperStart || at > paperEnd) return; + seen += 1; + // A line that is only a closing brace, or opens a block, is CSS that was + // swallowed by a comment. + expect(comment.text, comment.text.slice(0, 60)).not.toMatch(/^\s*\}\s*$|\{\s*$/m); + }); + expect(seen).toBeGreaterThan(20); + }); +}); diff --git a/ui/desktop/src/styles/browserSurfaceGutter.browser.test.ts b/ui/desktop/src/styles/browserSurfaceGutter.browser.test.ts new file mode 100644 index 000000000..fb2215dc7 --- /dev/null +++ b/ui/desktop/src/styles/browserSurfaceGutter.browser.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +/** + * The browser surface keeps the composer on the transcript column's edges (WS-SUMMARY-1). + * + * In a `biorouter serve` browser the body carries `biorouter-headless-browser` and main.css widens + * page gutters and caps the chat column and the composer at `min(760px, 100vw - 32px)`. The + * composer bar's side padding is set inline in BaseChat.tsx to half `CHAT_COLUMN_CHROME`, the + * transcript's own chrome (scroll root `px-1` plus viewport `px-6`), so the two boxes share their + * left and right edges. A `!important` gutter on the bar in this family once beat that inline + * value, and below an 816px cell the composer sat up to 12px off the column. That is geometry, so + * it is measured in Chromium: every top level rule in main.css that names the browser surface, + * over markup shaped like BaseChat's chat cell, at phone, laptop and wide widths with and without + * a preview beside the chat. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CHAT_COLUMN_CHROME, CHAT_MEASURE } from '../components/Layout/yieldLadder'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAIN_CSS = readFileSync(resolve(here, 'main.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** Every top level rule or at-rule in main.css that mentions the browser surface's body class. */ +function browserSurfaceRules(): string { + const chunks: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < MAIN_CSS.length; i += 1) { + const ch = MAIN_CSS[i]; + if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) { + chunks.push(MAIN_CSS.slice(from, i + 1)); + from = i + 1; + } + } else if (ch === ';' && depth === 0) { + from = i + 1; + } + } + return chunks.filter((chunk) => chunk.includes('body.biorouter-headless-browser')).join('\n'); +} + +const SURFACE_CSS = browserSurfaceRules(); + +/** BaseChat's Tailwind classes on these elements, as the layered utilities they are. */ +const UTILITIES = `@layer utilities { + .cell { display: flex; flex-direction: column; height: 600px; } + .transcript { flex: 1 1 0%; padding-left: 4px; padding-right: 4px; } + .viewport { padding-left: 24px; padding-right: 24px; } + .chat-column { width: 100%; max-width: ${CHAT_MEASURE}px; margin: 0 auto; height: 40px; } + .composer-bar { flex-shrink: 0; padding-top: 28px; padding-bottom: 24px; } + .composer { width: 100%; max-width: ${CHAT_MEASURE}px; margin: 0 auto; height: 96px; } +} +body { margin: 0; } +* { box-sizing: border-box; }`; + +const html = (cell: number) => ` +
    +
    +
    +
    +
    +
    +
    +
    `; + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +/** [viewport width, chat cell width]: no sidebar on a phone, a 288px sidebar above 767px. */ +const CASES: [number, number][] = [ + [375, 375], + [767, 767], + [900, 612], + [1000, 712], + [1200, 432], + [1400, 1112], + [1600, 832], + [1920, 1152], +]; + +describe('the browser surface keeps the composer on the transcript column', () => { + let browser: Browser | null = null; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn( + 'No Chromium found, skipping the browser surface gutter tests. ' + + 'Run `npx playwright install chromium` to enable them.' + ); + } + }); + + afterAll(async () => { + await browser?.close(); + }); + + it('reads the browser surface family from main.css', () => { + expect(SURFACE_CSS).toContain('.biorouter-chat-composer {'); + }); + + it.for(CASES)('viewport %ipx, chat cell %ipx', async ([viewport, cell], ctx) => { + if (!browser) return ctx.skip(); + const page = await browser.newPage({ viewport: { width: viewport, height: 800 } }); + try { + await page.setContent(html(cell)); + const box = await page.evaluate(() => { + const edges = (id: string) => { + const r = document.getElementById(id)!.getBoundingClientRect(); + return [r.left, r.right]; + }; + return { column: edges('column'), composer: edges('composer') }; + }); + expect(box.composer, 'composer left and right edges').toEqual(box.column); + const width = box.column[1] - box.column[0]; + expect(width, 'the column keeps the surface cap').toBeLessThanOrEqual(viewport - 24); + expect(width, 'the column keeps the transcript chrome').toBeLessThanOrEqual( + cell - CHAT_COLUMN_CHROME + ); + } finally { + await page.close(); + } + }); +}); diff --git a/ui/desktop/src/styles/chatSurfaceType.test.ts b/ui/desktop/src/styles/chatSurfaceType.test.ts index e99a80c66..e56b10afe 100644 --- a/ui/desktop/src/styles/chatSurfaceType.test.ts +++ b/ui/desktop/src/styles/chatSurfaceType.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it } from 'vitest'; * * `biorouter-message-content` marks a block that renders INSIDE the transcript, * shoulder to shoulder with the assistant's prose. That prose is 14px. The - * document default is 16px, so a card that names no size class does not render - * "at the default" — it renders two pixels larger than everything around it, - * which is what the user saw and described as a banner sitting among the text. + * document default was 16px until the 2026-10 body move, so a card that named + * no size rendered two pixels larger than everything around it, which is what + * the user saw and described as a banner sitting among the text. The default is + * 14px now, and the rule stays: a card that inherits its size changes size + * whenever the default does. * * The bug is invisible to every component test in this repo, and not by * accident: jsdom has no layout engine and never runs Tailwind, so a card @@ -55,8 +57,81 @@ describe('chat-surface cards state their own type size', () => { expect(markerLines().length).toBeGreaterThan(5); }); - it('never ships a card that inherits the 16px document default', () => { + it('never ships a card that inherits the document default', () => { const unsized = markerLines().filter((line) => !SIZE.test(line)); expect(unsized).toEqual([]); }); }); + +/** + * The 16px radius (`rounded-2xl`, `--radius-surface`) belongs to the preview + * sheet alone (implementation spec 2.2: "`rounded-2xl` is banned outside + * `artifacts/`"). Every other surface takes the 12px container radius, so a + * card in the transcript and a card in a view read as one family. The approval, + * elicitation and secret cards and the find bar each carried their own 16px + * corners; this keeps a new one from arriving. + */ +describe('no 16px card radius outside the preview sheet', () => { + /** + * Files still on the old radius, each owned by a workstream that is moving + * it. An entry that no longer needs to be here fails too, so the list only + * shrinks. + */ + const PENDING: Record = {}; + + const offenders = () => + sourceFiles(COMPONENTS) + .map((path) => path.slice(COMPONENTS.length + 1)) + .filter((rel) => !rel.startsWith('artifacts/')) + .filter((rel) => /\brounded-2xl\b/.test(readFileSync(join(COMPONENTS, rel), 'utf8'))); + + it('appears in no component outside artifacts/', () => { + expect(offenders().filter((rel) => !(rel in PENDING))).toEqual([]); + }); + + it('keeps the pending list honest', () => { + const found = new Set(offenders()); + expect(Object.keys(PENDING).filter((rel) => !found.has(rel))).toEqual([]); + }); +}); + +/** + * The transcript's own files take the type roles only (spec 2.1): five sizes, + * each a named role, never an arbitrary `text-[Npx]` or a raw Tailwind step, + * which is how the transcript drifted to eight sizes before the redesign. + */ +describe('the transcript speaks only the type roles', () => { + const FILES = [ + 'BioRouterMessage.tsx', + 'UserMessage.tsx', + 'MessageMeta.tsx', + 'MessageCopyLink.tsx', + 'MessageDivergeLink.tsx', + 'MarkdownContent.tsx', + 'ProgressiveMessageList.tsx', + 'ProvenanceChip.tsx', + 'SessionNamePill.tsx', + 'InlineImage.tsx', + 'WorkflowHeader.tsx', + 'TurnActivityIndicator.tsx', + 'LoadingBioRouter.tsx', + 'conversation/ChatTurnError.tsx', + 'conversation/ChatTurnStopped.tsx', + 'subagent/SubagentTabHeader.tsx', + 'subagent/SubagentComposerSlot.tsx', + 'workflows/WorkflowActivities.tsx', + 'context_management/SystemNotificationInline.tsx', + ]; + const OFF_ROLE = /\btext-(?:\[\d+px\]|(?:xs|sm|base|lg|xl|2xl)\b)/; + + it.each(FILES)('%s', (rel) => { + const offenders = readFileSync(join(COMPONENTS, rel), 'utf8') + .split('\n') + .map((text, index) => ({ text, line: index + 1 })) + // Comments may name the old classes to say why they went. + .filter(({ text }) => !/^\s*(\/\/|\*|\{\/\*)/.test(text)) + .filter(({ text }) => OFF_ROLE.test(text)) + .map(({ text, line }) => `${rel}:${line} — ${text.trim()}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/ui/desktop/src/styles/composerFocus.test.ts b/ui/desktop/src/styles/composerFocus.test.ts index f57c54b0b..4480f9346 100644 --- a/ui/desktop/src/styles/composerFocus.test.ts +++ b/ui/desktop/src/styles/composerFocus.test.ts @@ -34,6 +34,10 @@ import { describe, expect, it } from 'vitest'; */ const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); const CHAT_INPUT = readFileSync(join(__dirname, '../components/ChatInput.tsx'), 'utf8'); +const COMPOSER_CARD = readFileSync( + join(__dirname, '../components/composer/composerCard.ts'), + 'utf8' +); describe('the composer focus edge', () => { it('is declared as authored CSS, not left to a Tailwind variant', () => { @@ -64,6 +68,27 @@ describe('the composer focus edge', () => { * pair can drift apart silently. */ it('has its hook on the composer card in ChatInput', () => { - expect(CHAT_INPUT).toContain('biorouter-composer-card'); + // The card's classes live in one module (`composer/composerCard.ts`), which + // ChatInput's card renders. + expect(COMPOSER_CARD).toContain("'biorouter-composer-card'"); + expect(CHAT_INPUT).toMatch(/className=\{composerCardClassName\(/); + }); + + /** + * The edge the rule turns is the card's own RESTING hairline (owner message + * 17, R-32): the page ground with `border-border-subtle/60`, the pair Crew's + * card writes too, so focus is a colour change on a line that is already + * there, never a line appearing out of a transparent border on a grey fill. + */ + it('turns a resting hairline on the page ground, the same pair Crew writes', () => { + const CREW = readFileSync(join(__dirname, '../components/crew/composer/Composer.tsx'), 'utf8'); + // The class strings only: prose in a comment is about the look, not the look. + const code = COMPOSER_CARD.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const pair = 'bg-background-default border border-border-subtle/60'; + expect(code).toContain(`'${pair}'`); + expect(CREW).toContain(pair); + expect(code).not.toMatch(/\bborder-transparent\b/); + expect(code).not.toMatch(/\bbg-background-fill\b(?!-)/); + expect(code).not.toMatch(/\bbr-fill-surface\b/); }); }); diff --git a/ui/desktop/src/styles/composerWorkingEdge.test.ts b/ui/desktop/src/styles/composerWorkingEdge.test.ts index efdde8018..4f06cf164 100644 --- a/ui/desktop/src/styles/composerWorkingEdge.test.ts +++ b/ui/desktop/src/styles/composerWorkingEdge.test.ts @@ -26,6 +26,10 @@ import { describe, expect, it } from 'vitest'; */ const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); const CHAT_INPUT = readFileSync(join(__dirname, '../components/ChatInput.tsx'), 'utf8'); +const COMPOSER_CARD = readFileSync( + join(__dirname, '../components/composer/composerCard.ts'), + 'utf8' +); const BASE_CHAT = readFileSync(join(__dirname, '../components/BaseChat.tsx'), 'utf8'); /** The `::after` rule body, which carries the sweep itself. */ @@ -141,6 +145,18 @@ describe('the composer working edge', () => { expect(workingAt).toBeGreaterThan(-1); expect(workingAt).toBeGreaterThan(focusAt); }); + + /** + * Working but not focused is the resting hairline with the segment riding + * on it (owner message 17, R-32: the card rests on the page ground with its + * hairline again). No rule may recolour the base border for the working + * state alone, or the unfocused working card loses the edge the segment + * travels along. + */ + it('keeps the resting hairline under the segment when the field is not focused', () => { + expect(CSS).not.toMatch(/\.biorouter-composer-card\[data-working='true'\]\s*\{/); + expect(COMPOSER_CARD).toContain("'bg-background-default border border-border-subtle/60'"); + }); }); describe('reduced motion', () => { @@ -180,7 +196,8 @@ describe('the composer working edge', () => { * drift apart silently. */ it('is set on the composer card in ChatInput', () => { - expect(CHAT_INPUT).toContain('biorouter-composer-card'); + expect(COMPOSER_CARD).toContain("'biorouter-composer-card'"); + expect(CHAT_INPUT).toMatch(/className=\{composerCardClassName\(/); expect(CHAT_INPUT).toMatch(/data-working=\{isWorking \? 'true' : undefined\}/); }); diff --git a/ui/desktop/src/styles/crewParity.test.ts b/ui/desktop/src/styles/crewParity.test.ts new file mode 100644 index 000000000..6fc79fd85 --- /dev/null +++ b/ui/desktop/src/styles/crewParity.test.ts @@ -0,0 +1,340 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Crew is the reference design (owner message 5; redesign spec principle 11): the app promotes + * Crew's recipes under `br-` names and never edits Crew to match. "Refer to Crew" drifts the day + * either side changes, so this guard (spec 3.2, crew-reference §4.2) reads both stylesheets and + * asserts that every declaration a Crew original makes is made, with the same value, by its + * promoted copy. The only exceptions are the values the spec changes on purpose, each listed + * below with its reason; a copy's extra declarations are listed too, so a new one is a decision + * rather than drift. + * + * The CSS reader is the Crew guard's (`crew/crewCss.sourceGuard.test.ts`), copied rather than + * imported because a test file exports nothing. + */ + +const SRC = join(__dirname, '..'); +const read = (path: string) => readFileSync(join(SRC, path), 'utf8'); + +interface Declaration { + property: string; + value: string; +} + +interface StyleRule { + selector: string; + declarations: Declaration[]; + atRules: string[]; +} + +function parseDeclarations(body: string): Declaration[] { + return body + .split(';') + .map((part) => part.trim()) + .filter((part) => part.includes(':')) + .map((part) => { + const colon = part.indexOf(':'); + return { + property: part.slice(0, colon).trim().toLowerCase(), + value: part + .slice(colon + 1) + .trim() + .replace(/\s+/g, ' '), + }; + }); +} + +function parseCss(source: string): StyleRule[] { + const text = source.replace(/\/\*[\s\S]*?\*\//g, ' '); + const rules: StyleRule[] = []; + const stack: { prelude: string; body: string }[] = []; + let buffer = ''; + let quote: string | null = null; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + buffer += char; + if (char === '\\') { + buffer += text[index + 1] ?? ''; + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + buffer += char; + continue; + } + if (char === '{') { + const lastSemicolon = buffer.lastIndexOf(';'); + if (stack.length > 0) stack[stack.length - 1].body += buffer.slice(0, lastSemicolon + 1); + stack.push({ prelude: buffer.slice(lastSemicolon + 1).trim(), body: '' }); + buffer = ''; + } else if (char === '}') { + const frame = stack.pop(); + if (!frame) throw new Error('unbalanced "}" in CSS'); + frame.body += buffer; + buffer = ''; + const ancestors = stack.map((open) => open.prelude); + if ( + !frame.prelude.startsWith('@') && + !ancestors.some((prelude) => prelude.startsWith('@keyframes')) + ) { + rules.push({ + selector: frame.prelude.replace(/\s+/g, ' '), + declarations: parseDeclarations(frame.body), + atRules: ancestors.filter((prelude) => prelude.startsWith('@')), + }); + } + } else { + buffer += char; + } + } + if (stack.length > 0) throw new Error('unbalanced "{" in CSS'); + return rules; +} + +/** The declarations of every rule whose selector list contains `selector`, merged in order. */ +function declarationsOf(rules: StyleRule[], selector: string, media = ''): Map { + const out = new Map(); + const wanted = selector.replace(/\s+/g, ' '); + for (const rule of rules) { + const parts = rule.selector.split(',').map((part) => part.trim()); + if (!parts.includes(wanted)) continue; + const inMedia = rule.atRules.filter((at) => at.startsWith('@media')).join(' '); + if (inMedia !== media) continue; + for (const { property, value } of rule.declarations) out.set(property, value); + } + expect(out.size, `no declarations for ${selector}${media ? ` in ${media}` : ''}`).toBeGreaterThan( + 0 + ); + return out; +} + +/** `padding: 0 8px` and `padding-inline: 8px` say the same thing about a row's sides. */ +function normalise(map: Map): Map { + const out = new Map(map); + const padding = out.get('padding'); + if (padding && !out.has('padding-inline')) { + const [block, inline = block] = padding.split(' '); + out.set('padding-inline', inline); + if (block !== '0') out.set('padding-block', block); + out.delete('padding'); + } + return out; +} + +interface Pair { + name: string; + crew: { file: string; selector: string; media?: string }; + app: { file: string; selector: string; media?: string }; + /** Properties whose VALUE the spec changes on purpose, each with its reason. */ + changed?: Record; + /** Properties the copy adds, each with its reason. */ + added?: Record; + /** `crew-` names inside values (keyframes) that the copy spells `br-`. */ + renames?: boolean; +} + +const MAIN = 'styles/main.css'; +const NAV = 'components/BioRouterSidebar/sidebar.css'; +const CREW_SIDEBAR = 'components/crew/sidebar/crew-sidebar.css'; +const CREW_APP = 'components/crew/crew-app.css'; +const CREW_DIALOGS = 'components/crew/dialogs/dialogs.css'; +const CREW_PANE = 'components/crew/pane/pane.css'; + +const DENSITY = + 'Owner decision 6.1: the app sidebar is 28px rows at 13px; Crew keeps 32px at 14px.'; + +const PAIRS: Pair[] = [ + { + name: 'the nav row', + crew: { file: CREW_SIDEBAR, selector: '.crew-sidebar-row' }, + app: { file: NAV, selector: '.br-nav-row' }, + changed: { + height: DENSITY, + 'font-size': DENSITY, + 'line-height': DENSITY, + transition: + 'The app row also turns its ink to default on hover (spec 3.4), so colour eases with the ground.', + }, + added: { + overflow: 'A 28px row clips its label rather than growing (spec 3.4).', + 'font-weight': 'States the 400 a nav row keeps; Arial draws 500 as 400 anyway (spec 0).', + 'background-color': 'A resting ground the hover and the current row replace.', + }, + }, + { + name: 'the group header', + crew: { file: CREW_SIDEBAR, selector: '.crew-sidebar-team-toggle' }, + app: { file: NAV, selector: '.br-sidebar-folder-toggle' }, + changed: { + height: 'The folder header fills its 28px row (owner decision 6.1).', + 'font-size': DENSITY, + 'line-height': DENSITY, + 'font-weight': + 'Left at 400: Arial draws 500 as 400 (spec 0), and a folder header is a 13px row like the chats under it.', + }, + added: {}, + }, + { + name: 'the row-action reveal', + crew: { file: CREW_PANE, selector: '.crew-member-actions' }, + app: { file: MAIN, selector: '.br-row-actions' }, + added: { + display: 'The shared cluster lays its icon buttons out itself.', + 'align-items': 'The shared cluster lays its icon buttons out itself.', + gap: 'The shared cluster lays its icon buttons out itself.', + }, + }, + { + name: 'the row-action reveal without a hover pointer', + crew: { file: CREW_PANE, selector: '.crew-member-actions', media: '@media (hover: none)' }, + app: { file: MAIN, selector: '.br-row-actions', media: '@media (hover: none)' }, + changed: { + opacity: 'The same 1, made !important so no host rule can hide it on touch.', + }, + }, + { + name: 'the straight hairline', + crew: { file: CREW_DIALOGS, selector: '.crew-settings-panel .crew-settings-row::after' }, + app: { file: MAIN, selector: '.biorouter-list-row::after' }, + }, + { + // The declaration is Crew's; the SELECTOR differs on purpose. Crew's row keeps + // `:focus-within` (Crew is never edited to match), the app's keys the focus half on + // `:has(:focus-visible)` so a mouse click on a switch leaves no firmed line (spec 2.5). + name: 'the straight hairline under focus', + crew: { + file: CREW_DIALOGS, + selector: '.crew-settings-panel .crew-settings-row:focus-within::after', + }, + app: { file: MAIN, selector: '.biorouter-settings-row:has(:focus-visible)::after' }, + }, + { + name: 'the straight hairline on the last row', + crew: { + file: CREW_DIALOGS, + selector: '.crew-settings-panel .crew-settings-row:last-child::after', + }, + app: { file: MAIN, selector: '.biorouter-settings-row:last-child::after' }, + }, + { + name: 'the cross-fade stack', + crew: { file: CREW_APP, selector: '.crew-crossfade' }, + app: { file: MAIN, selector: '.br-crossfade' }, + }, + { + name: 'the cross-fade cell', + crew: { file: CREW_APP, selector: '.crew-crossfade > .crew-crossfade-item' }, + app: { file: MAIN, selector: '.br-crossfade > .br-crossfade-item' }, + }, + { + name: 'the cross-fade entrance', + crew: { file: CREW_APP, selector: ".crew-crossfade-item[data-state='open']" }, + app: { file: MAIN, selector: ".br-crossfade-item[data-state='open']" }, + renames: true, + }, + { + name: 'the cross-fade exit', + crew: { file: CREW_APP, selector: ".crew-crossfade-item[data-state='closed']" }, + app: { file: MAIN, selector: ".br-crossfade-item[data-state='closed']" }, + renames: true, + }, + { + name: 'the highlight wash', + crew: { file: CREW_APP, selector: '.crew-highlight' }, + app: { file: MAIN, selector: '.br-highlight' }, + renames: true, + }, +]; + +const parsed = new Map(); +const rulesOf = (file: string) => { + if (!parsed.has(file)) parsed.set(file, parseCss(read(file))); + return parsed.get(file)!; +}; + +/** The body of `@keyframes name`, whitespace folded. */ +function keyframes(file: string, name: string): string { + const source = read(file).replace(/\/\*[\s\S]*?\*\//g, ' '); + const start = source.indexOf(`@keyframes ${name} {`); + expect(start, `no @keyframes ${name} in ${file}`).toBeGreaterThanOrEqual(0); + let depth = 0; + let index = source.indexOf('{', start); + const open = index; + for (; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + else if (source[index] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + return source.slice(open, index + 1).replace(/\s+/g, ' '); +} + +describe('promoted recipes keep their Crew originals (spec 3.2, crew-reference §4.2)', () => { + it.each(PAIRS.map((pair) => [pair.name, pair] as const))('%s', (_name, pair) => { + const crew = normalise( + declarationsOf(rulesOf(pair.crew.file), pair.crew.selector, pair.crew.media) + ); + const app = normalise( + declarationsOf(rulesOf(pair.app.file), pair.app.selector, pair.app.media) + ); + const rename = (value: string) => (pair.renames ? value.replace(/\bcrew-/g, 'br-') : value); + + const drift: string[] = []; + for (const [property, value] of crew) { + if (!app.has(property)) { + if (!(pair.changed && property in pair.changed)) { + drift.push(`${property}: missing (Crew: ${value})`); + } + continue; + } + const ours = app.get(property)!.replace(/\s*!important$/, ''); + if (ours !== rename(value) && !(pair.changed && property in pair.changed)) { + drift.push(`${property}: ${app.get(property)} (Crew: ${value})`); + } + } + for (const property of app.keys()) { + if (!crew.has(property) && !(pair.added && property in pair.added)) { + drift.push(`${property}: not in Crew's recipe`); + } + } + // A listed exception that no longer differs is stale, and stale exceptions hide drift. + for (const property of Object.keys(pair.changed ?? {})) { + const ours = app.get(property)?.replace(/\s*!important$/, ''); + if (property === 'opacity' && app.get(property)?.endsWith('!important')) continue; + if (ours !== undefined && ours === rename(crew.get(property) ?? '')) { + drift.push(`${property}: listed as changed but now equal to Crew's`); + } + } + expect(drift, `${pair.app.selector} has drifted from ${pair.crew.selector}`).toEqual([]); + }); + + it('keeps the cross-fade, fade-out and highlight keyframes frame for frame', () => { + for (const [crew, app] of [ + ['crew-crossfade-in', 'br-crossfade-in'], + ['crew-fade-in', 'br-fade-in'], + ['crew-fade-out', 'br-fade-out'], + ['crew-highlight-wash', 'br-highlight-wash'], + ['crew-highlight-hold', 'br-highlight-hold'], + ]) { + expect(keyframes(MAIN, app), app).toBe(keyframes(CREW_APP, crew)); + } + }); + + it('names no crew- class in a promoted copy (principle 11)', () => { + for (const file of [MAIN, NAV]) { + const selectors = rulesOf(file).map((rule) => rule.selector); + expect( + selectors.filter((selector) => /\.crew-/.test(selector)), + file + ).toEqual([]); + } + }); +}); diff --git a/ui/desktop/src/styles/dirChipPath.test.ts b/ui/desktop/src/styles/dirChipPath.test.ts index 547a6e72f..50ad9df74 100644 --- a/ui/desktop/src/styles/dirChipPath.test.ts +++ b/ui/desktop/src/styles/dirChipPath.test.ts @@ -3,127 +3,59 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; /** - * The composer's working-directory chip: how wide it is, which end of the path - * gets clipped, and whether the path it shows is the path that exists. + * The composer footer's folder chip (spec 3.7): the folder's NAME in the + * footer's sans, the full path in a mono tooltip. * - * ⚠ **Asserted at the SOURCE, and it has to be.** jsdom has no layout engine - * and does not implement the Unicode bidirectional algorithm, so a component - * test that mounts the chip and reads `textContent` sees the logical string in - * every case — it passes identically whether the box is 112px or 46ch, and - * whether the leading separator is reordered to the far end or not. Both bugs - * this file exists for were invisible to jsdom while they were live. - * `composerFocus.test.ts` is the precedent, for the same reason. + * ⚠ **Asserted at the SOURCE, and it has to be.** jsdom has no layout engine, + * never runs Tailwind and does not implement the bidi algorithm, so a component + * test that mounts the chip reads the same `textContent` whether the name is + * capped or not and whether a right-to-left name reorders the line or not. * - * **Measured in a real browser** (Chromium, 12px `--font-mono`, the real strip - * markup, 141 pane widths from 900px down to 200px in 5px steps). Character - * positions were read back with a `Range` per character and sorted by x, which - * gives the VISUAL glyph order rather than the logical string: - * - * before `/Users/wgu/Downloads` -> `…wgu/Downloads/` (phantom slash) - * after `/Users/wgu/Downloads` -> `/Users/wgu/Downloads` (fits whole) - * before `C:\` -> `\:C` (fully reversed) - * after `C:\` -> `C:\` - * after deep path -> `…trees/frosty-proskuriakova-fe9584/ui/desktop` - * - * And the widening costs the counts across the strip nothing: they were never - * squeezed at any of the 141 widths in either spelling, and the strip first - * overflows at the SAME 200px in both — far below any real pane — because - * `flex-shrink-0` on the counts plus `min-w-0` all the way down this side means - * a bigger cap only takes effect where there is room for it. + * History: the unlocked chip used to print the whole path in mono inside an + * RTL box (`.biorouter-dir-chip-path`) that clipped the path's head. Showing a + * name instead removed the reason for that box, and with it the two bidi bugs + * it needed guarding against (a phantom trailing slash, `C:\` drawn `\:C`): + * a folder name has no separators to reorder. */ -const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); const DIR_SWITCHER = readFileSync( join(__dirname, '../components/bottom_menu/DirSwitcher.tsx'), 'utf8' ); +const PICKERS_CSS = readFileSync(join(__dirname, '../components/bottom_menu/pickers.css'), 'utf8'); -const RULE = /\.biorouter-dir-chip-path\s*\{([^}]*)\}/; -const ISOLATE_RULE = /\.biorouter-dir-chip-path\s*>\s*bdi\s*\{([^}]*)\}/; - -describe('the working-directory chip while the directory is still choosable', () => { - it('caps the path far wider than the locked chip, so an ordinary path fits', () => { - const rule = CSS.match(RULE)?.[1]; - expect(rule).toBeTruthy(); - // `ch` on 12px mono: 46ch is ~332px, measured. `/Users/wgu/Downloads` is 20 - // characters and rendered at 145px — comfortably inside it. - expect(rule).toMatch(/max-width:\s*46ch/); - // Not the locked chip's cap: 112px truncates a 20-character path. - expect(rule).not.toContain('112px'); - }); +/** Code only, so a comment that names a class cannot satisfy or trip a rule. */ +const code = DIR_SWITCHER.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); - /** - * A percentage term (`min(46ch, 40%)`) was considered for the "never push the - * counts off" job and declined on two independent grounds, both measured — - * see the rule's own comment. This pins the decision so it is not quietly - * reintroduced: this div's containing block is a content-sized flex item with - * no definite width, which is exactly where a percentage max-width resolves - * against an indefinite size and behaves as `none`. - */ - it('bounds itself in `ch` and leaves narrow panes to flex, not to a percentage', () => { - const rule = CSS.match(RULE)?.[1] ?? ''; - expect(rule).not.toContain('%'); - // The shrink chain is what actually protects the counts, so the chip must - // keep the `min-w-0` that lets flex reach it. - expect(DIR_SWITCHER).toMatch(/biorouter-dir-chip-path[^"]*min-w-0/); - }); +const NAME_RULE = /\.br-footline__name\s*\{([^}]*)\}/; - it('clips the HEAD of the path, keeping the identifying final segments', () => { - expect(CSS.match(RULE)?.[1]).toMatch(/direction:\s*rtl/); - expect(DIR_SWITCHER).toMatch(/biorouter-dir-chip-path[^"]*truncate/); +describe('the folder chip in the composer footer', () => { + it('prints the folder name, not the path, in both states', () => { + expect(code).toContain('workingDirLabel(workingDir)'); + // The path itself reaches the screen only inside the tooltip. + expect(code.match(/>\{workingDir\}\{workingDir\}<\/span>/); }); - /** - * `/` and `\` are bidi-neutral (Unicode class CS). In an RTL paragraph the - * bidi algorithm resolves a neutral sitting at a paragraph boundary to the - * PARAGRAPH direction, which moved a path's leading separator to the visual - * right end: `/Users/wgu/Downloads` rendered `…wgu/Downloads/` and `C:\` - * rendered `\:C`. A chip showing a path that does not exist is worse than one - * showing a truncated path that does. - */ - it('isolates the path as LTR so its separators are not reordered', () => { - const isolate = CSS.match(ISOLATE_RULE)?.[1]; - expect(isolate).toBeTruthy(); - expect(isolate).toMatch(/unicode-bidi:\s*isolate/); - // `unicode-bidi: isolate` ALONE is not enough — an isolate inherits the RTL - // base direction and reorders the boundary neutral exactly the same way. - expect(isolate).toMatch(/direction:\s*ltr/); - // The rule matches nothing without the element it selects. - expect(DIR_SWITCHER).toMatch(/\{workingDir\}<\/bdi>/); + it('is sans: mono appears only on the tooltip path', () => { + expect(code.match(/font-mono/g)).toHaveLength(1); + expect(code).not.toContain('biorouter-dir-chip-path'); }); - /** - * The RTL box and the LTR isolate are one mechanism: the box exists to clip - * the head, and the isolate exists to stop that box corrupting the path. - * Splitting them across a stylesheet and a Tailwind arbitrary value at the - * call site is how the second half went missing. Authored CSS also cannot - * silently fail to generate the way a newly written utility can (see - * `composerFocus.test.ts`). - */ - it('declares the RTL box and its LTR isolate together, as authored CSS', () => { - expect(DIR_SWITCHER).not.toContain('[direction:rtl]'); - expect(DIR_SWITCHER).not.toContain('[unicode-bidi:'); - expect(CSS.indexOf('.biorouter-dir-chip-path')).toBeGreaterThan(-1); + it('isolates the name so a right-to-left name cannot reorder the line', () => { + expect(code).toMatch(/\{workingDirLabel\(workingDir\)\}<\/bdi>/); }); -}); -describe('the locked chip is left exactly as it was', () => { - /** - * #44: once the chat has messages the directory is immutable, and a decided - * directory needs only its folder name. That is intended behaviour, not an - * oversight the widening should have swept up — the two chips answer - * different questions and are allowed to be different sizes. - */ - it('still shows the basename at the narrow cap', () => { - const locked = DIR_SWITCHER.match(/data-testid="dir-switcher-locked"[\s\S]*?<\/span>/)?.[0]; - expect(locked).toBeTruthy(); - expect(locked).toContain('max-w-[112px]'); - expect(locked).toContain('workingDirLabel(workingDir)'); - // The locked chip stays LTR: a basename has no leading separator to - // reorder, and nothing here clips the head. - expect(locked).not.toContain('biorouter-dir-chip-path'); + it('caps the name in authored CSS and ends it with an ellipsis', () => { + const rule = PICKERS_CSS.match(NAME_RULE)?.[1]; + expect(rule).toBeTruthy(); + expect(rule).toMatch(/max-width:\s*\d+px/); + expect(rule).toMatch(/text-overflow:\s*ellipsis/); + // `min-width: 0` is what lets flex shrink the chip before the readouts. + expect(rule).toMatch(/min-width:\s*0/); }); - it('no longer shares one cap with the unlocked chip', () => { - expect(DIR_SWITCHER.match(/max-w-\[112px\]/g)).toHaveLength(1); + it('uses the footer item recipe in both states', () => { + expect(code.match(/br-footline__item/g)?.length).toBeGreaterThanOrEqual(2); + expect(code).toContain('data-testid="dir-switcher-locked"'); }); }); diff --git a/ui/desktop/src/styles/fillTokens.browser.test.ts b/ui/desktop/src/styles/fillTokens.browser.test.ts new file mode 100644 index 000000000..139243f8e --- /dev/null +++ b/ui/desktop/src/styles/fillTokens.browser.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node +/** + * Fills before lines keep their shape in forced colours (owner message 13, R-27; design.md 9.5). + * + * A filled card, button, chip or field has no resting outline: the fill alone says "there is a + * box here". Forced colours (Windows High Contrast) repaint every background as Canvas, so the + * fill vanishes there. The rule in `main.css` and design.md is that a filled control KEEPS its + * 1px border and makes it transparent (`border border-transparent`), and forced colours draw that + * border in the system ink. That is a claim about the engine, so it is measured in Chromium with + * forced colours emulated, against the real token values read from `main.css`: + * + * - at rest the transparent border paints nothing and the box is its fill, a visible step off + * the page in both modes; + * - in forced colours the fill becomes Canvas and the transparent border is drawn in a visible + * system colour, while a box that dropped its border (`border-0`) is left with no edge at all; + * - the box is the same size in both, so switching the look costs no geometry. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAIN_CSS = readFileSync(resolve(here, 'main.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** The value of `--name` inside the first block that `opener` starts (`:root {` or `.dark {`). */ +function baseToken(opener: string, name: string): string { + const start = MAIN_CSS.indexOf(`\n${opener}`); + if (start < 0) throw new Error(`main.css has no ${opener} block`); + const body = MAIN_CSS.slice(start, MAIN_CSS.indexOf('\n}', start)); + const match = body.match(new RegExp(`\\s${name}:\\s*([^;]+);`)); + if (!match) throw new Error(`${opener} does not declare ${name}`); + return match[1].trim(); +} + +const MODES = { + light: { + page: baseToken(':root {', '--background-default'), + fill: baseToken(':root {', '--background-fill'), + }, + dark: { + page: baseToken('.dark {', '--background-default'), + fill: baseToken('.dark {', '--background-fill'), + }, +}; + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +const html = (mode: keyof typeof MODES) => `
    `; + +type Measure = { + canvas: string; + page: string; + keptBackground: string; + keptEdge: string; + keptEdgeWidth: string; + keptSize: [number, number]; + droppedEdgeWidth: string; +}; + +const measure = (page: Page): Promise => + page.evaluate(() => { + const cs = (el: Element) => getComputedStyle(el); + const probe = document.createElement('div'); + probe.style.backgroundColor = 'Canvas'; + document.body.append(probe); + const canvas = cs(probe).backgroundColor; + probe.remove(); + const kept = document.getElementById('kept')!; + const dropped = document.getElementById('dropped')!; + const box = kept.getBoundingClientRect(); + return { + canvas, + page: cs(document.body).backgroundColor, + keptBackground: cs(kept).backgroundColor, + keptEdge: cs(kept).borderTopColor, + keptEdgeWidth: cs(kept).borderTopWidth, + keptSize: [box.width, box.height] as [number, number], + droppedEdgeWidth: cs(dropped).borderTopWidth, + }; + }); + +/** True when a computed colour paints nothing (`transparent` or any zero alpha). */ +const isClear = (colour: string) => + colour === 'transparent' || /^rgba\(.*,\s*0\)$/.test(colour.replace(/\s+/g, ' ')); + +describe('a filled box keeps its shape in forced colours', () => { + let browser: Browser | null = null; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn( + 'No Chromium found — skipping the fill token tests. ' + + 'Run `npx playwright install chromium` to enable them.' + ); + } + }); + + afterAll(async () => { + await browser?.close(); + }); + + it.for(Object.keys(MODES) as (keyof typeof MODES)[])('%s', async (mode, ctx) => { + if (!browser) return ctx.skip(); + const page = await browser.newPage(); + try { + await page.setContent(html(mode)); + const rest = await measure(page); + expect(isClear(rest.keptEdge), `${mode}: the resting border paints nothing`).toBe(true); + expect(rest.keptBackground, `${mode}: the box is its fill, not the page`).not.toBe(rest.page); + expect(rest.keptEdgeWidth).toBe('1px'); + + await page.emulateMedia({ forcedColors: 'active' }); + const forced = await measure(page); + expect(forced.keptBackground, `${mode}: forced colours repaint the fill`).toBe(forced.canvas); + expect(isClear(forced.keptEdge), `${mode}: the kept border is drawn`).toBe(false); + expect(forced.keptEdge, `${mode}: the kept border is not Canvas`).not.toBe(forced.canvas); + expect(forced.keptEdgeWidth).toBe('1px'); + expect(forced.droppedEdgeWidth, `${mode}: a dropped border leaves no edge`).toBe('0px'); + expect(forced.keptSize, `${mode}: no geometry moves`).toEqual(rest.keptSize); + } finally { + await page.close(); + } + }); +}); diff --git a/ui/desktop/src/styles/focusFallback.test.ts b/ui/desktop/src/styles/focusFallback.test.ts index 0d9a41b20..6d4cb1b51 100644 --- a/ui/desktop/src/styles/focusFallback.test.ts +++ b/ui/desktop/src/styles/focusFallback.test.ts @@ -172,10 +172,15 @@ describe('the OS-requested focus ring (P0-5)', () => { }); it('comes after every unlayered rule that suppresses a focus outline', () => { + // The one deliberate exception sits INSIDE the OS block: a Radix tab trigger keeps its + // label-sized `::before` ring in the OS colour instead of the outline (vision V4-16), and + // that rule is pinned by the tab ring test below. + const tabException = "[role='tab'][data-orientation]:not(.br-tab):focus-visible"; const suppressors = RULES.filter( (r) => !isLayered(r) && r !== ringRules[0] && + !(r.context.join(' ') === OS_MEDIA && r.selector === tabException) && /:focus/.test(r.selector) && /(^|;|\s)outline:\s*(none|0)\b/.test(r.body) ); @@ -351,23 +356,68 @@ describe('the default focus indicator is an edge, not only a fill (T-16)', () => expect(body).toContain('border-radius: var(--radius-element)'); expect(body).toContain('position: absolute'); expect(body).toMatch(/inset-block: calc\(50% - 14px\)/); + // The ring keeps clear of the glyphs: a Radix trigger has no side padding, so the ring's inner + // edge is the inset minus its 2px border from the label. At 4px past the trigger that was 2px, + // and antialiasing made "Chat" touch it (vision V4-16); 4px clear is the floor. Two + // neighbours' rings still cannot meet inside the tablist's gap-5 (20px). + const inset = Number(body.match(/inset-inline: -(\d+)px/)?.[1]); + expect(inset, 'the ring is inset by a negative pixel value').toBeGreaterThan(0); + expect( + inset - 2, + 'the ring’s inner edge is at least 4px clear of the label' + ).toBeGreaterThanOrEqual(4); + expect(inset * 2, 'two neighbours’ rings do not meet in gap-5').toBeLessThan(20); // The trigger this relies on is positioned, and `::after` is its selection bar. const tabs = readFileSync(join(HERE, '..', 'components', 'ui', 'tabs.tsx'), 'utf8'); expect(tabs).toMatch(/"relative inline-flex/); expect(tabs).not.toMatch(/before:/); }); - it('insets a document tab’s ring inside its pill', () => { + // The pill's own ring (tab family, WS-MOTION) wins for every strip in the app; this inset + // outline is the fallback for a `.br-tab` whose focusable is not a direct child. + it('insets the fallback ring of a document tab whose focusable is nested', () => { expect(onlyRule(":where(.br-tab [role='tab']):focus-visible").body).toMatch( /outline-offset:\s*-2px/ ); }); - /** One ring when the OS asked for one: the outline, not the pseudo ring as well. */ - it('stands the pseudo ring down under the OS-requested ring', () => { - const off = onlyRule("[role='tab'][data-orientation]:focus-visible::before"); - expect(off.context.join(' ')).toBe(OS_MEDIA); - expect(off.body).toMatch(/content:\s*none/); + /** + * One ring when the OS asked for one, and it is the label-sized pseudo ring, not the outline. + * An outline is drawn round the 36px box with no side padding: at a 2px offset it hugged the + * label ("Chat" touched it) and the settings band cut its top off (vision V4-16, forced + * colours). The pseudo ring keeps the normal-colour geometry and takes the OS colour. + */ + it('keeps the label-sized ring under the OS-requested ring, in the OS colour', () => { + const trigger = "[role='tab'][data-orientation]:not(.br-tab):focus-visible"; + const inContext = (selector: string, context: string) => { + const found = RULES.filter((r) => r.selector === selector && r.context.join(' ') === context); + expect(found, `expected exactly one ${context} rule for ${selector}`).toHaveLength(1); + return found[0]; + }; + + // The outline stands down, after (and above the specificity of) the ring rule it overrides. + const outline = inContext(trigger, OS_MEDIA); + expect(isLayered(outline)).toBe(false); + expect(outline.body).toMatch(/outline:\s*none\s*!important/); + const ring = RULES.filter( + (r) => r.context.join(' ') === OS_MEDIA && /outline:\s*2px solid var\(--ring\)/.test(r.body) + ); + expect(ring).toHaveLength(1); + expect(outline.index).toBeGreaterThan(ring[0].index); + + // The pseudo ring is not switched off anywhere: it keeps the base geometry. + const offs = RULES.filter( + (r) => /\[role='tab'\][^,{]*::before/.test(r.selector) && /content:\s*none/.test(r.body) + ); + expect(offs.map((r) => r.selector)).toEqual([]); + + // Its colour: the OS ring's under more contrast, the user's own Highlight under forced + // colours, kept as written. + expect(inContext(`${trigger}::before`, OS_MEDIA).body).toMatch(/border-color:\s*var\(--ring\)/); + const forced = inContext(`${trigger}::before`, '@media (forced-colors: active)'); + expect(forced.body).toMatch(/border-color:\s*Highlight/); + expect(forced.body).toContain('forced-color-adjust: none'); + expect(forced.index).toBeGreaterThan(outline.index); }); it('paints the focused sidebar resize handle, not a hover-grade hairline', () => { @@ -517,9 +567,9 @@ describe('a radio’s ring and a link-style button hold their edges (Q3-58, Q3-5 return found[0]; }; - it('paints the resting ring in --text-muted and the checked one in the accent edge', () => { + it('paints the resting ring in --border-control and the checked one in the accent edge', () => { const rest = top('[data-radio-ring]'); - expect(rest.body.replace(/\s+/g, ' ')).toContain('border-color: var(--text-muted)'); + expect(rest.body.replace(/\s+/g, ' ')).toContain('border-color: var(--border-control)'); expect(isLayered(rest)).toBe(false); const checked = top("input[type='radio']:checked ~ [data-radio-ring]"); expect(checked.body.replace(/\s+/g, ' ')).toContain('border-color: var(--border-accent)'); @@ -557,8 +607,9 @@ describe('a radio’s ring and a link-style button hold their edges (Q3-58, Q3-5 expect(Object.keys(SCOPES)).toHaveLength(6); const shortfalls: string[] = []; for (const [name, scope] of Object.entries(SCOPES)) { - const ring = tokens.resolveHex('--text-muted', scope); - expect(ring, `${name}: --text-muted resolves`).toBeTruthy(); + // The checkbox's own edge (spec 2.6). 3:1 is what WCAG 1.4.11 asks of a control boundary. + const ring = tokens.resolveHex('--border-control', scope); + expect(ring, `${name}: --border-control resolves`).toBeTruthy(); for (const ground of grounds) { const hex = tokens.resolveHex(ground, scope); expect(hex, `${name}: ${ground} resolves`).toBeTruthy(); diff --git a/ui/desktop/src/styles/focusSurface.test.ts b/ui/desktop/src/styles/focusSurface.test.ts index 2d4501f60..88417939e 100644 --- a/ui/desktop/src/styles/focusSurface.test.ts +++ b/ui/desktop/src/styles/focusSurface.test.ts @@ -152,6 +152,21 @@ describe('the focus surface', () => { it('is carried by the Knowledge controls that write their own background', () => { expect(KB_TRIGGER).toContain('biorouter-focus-surface'); expect(MODEL_PICKER).toContain('biorouter-focus-surface'); - expect(PASTE_BOX).toContain('biorouter-focus-surface'); + }); + + /** + * The paste box's textarea is a plain text field: it keeps its resting fill while focused and + * firms its own `--border-focus` edge (`.br-field:focus`), like the Write a skill textarea and + * the title field above it. With this class it turned the grey focus surface the moment the box + * opened and took the caret (V3-R4-4: rgb(224, 224, 220) in light, rgb(53, 52, 47) in dark). + * `knowledgeFill.browser.test.tsx` measures the focused field in Chromium. + */ + it('is NOT carried by the paste box: its text field keeps its fill when focused', () => { + // The whole element, through its self-closing `/>` (an arrow function inside it holds a `>`). + // Not the whole file: the comment above it names the class to say why it is gone. + const textarea = PASTE_BOX.match(//)?.[0] ?? ''; + expect(textarea, 'the paste box has a Textarea').toContain('aria-label="Text to stage"'); + expect(textarea).not.toContain('biorouter-focus-surface'); + expect(textarea).not.toContain('className'); }); }); diff --git a/ui/desktop/src/styles/fontScale.test.ts b/ui/desktop/src/styles/fontScale.test.ts index 8ca965f21..96e9ed398 100644 --- a/ui/desktop/src/styles/fontScale.test.ts +++ b/ui/desktop/src/styles/fontScale.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import postcss from 'postcss'; import { fontScale } from '../../vite-plugins/fontScale.mjs'; @@ -40,6 +42,7 @@ describe('typography scaling in compiled CSS', () => { it('scales shared control tokens once without growing app chrome or panel measures', async () => { const css = await transform(`:root { --control-md: 32px; --row-height: 40px; --row-height-rail: 32px; + --row-height-nav: 28px; --md-code-pad: 14px 16px; --spacing: .25rem; --chrome-height: 44px; --dock-height: 36px; --tab-height: 32px; --measure-chat: 760px; @@ -47,6 +50,9 @@ describe('typography scaling in compiled CSS', () => { .control { height: var(--control-md); padding: var(--md-code-pad); }`); expect(css).toContain('--control-md: calc(32px * var(--app-font-scale, 1))'); expect(css).toContain('--row-height: calc(40px * var(--app-font-scale, 1))'); + expect(css).toContain('--row-height-rail: calc(32px * var(--app-font-scale, 1))'); + // The app sidebar's 28px row grows with Text size like every other row token. + expect(css).toContain('--row-height-nav: calc(28px * var(--app-font-scale, 1))'); expect(css).toContain( '--md-code-pad: calc(14px * var(--app-font-scale, 1)) calc(16px * var(--app-font-scale, 1))' ); @@ -128,4 +134,29 @@ describe('typography scaling in compiled CSS', () => { expect(css).toContain('font-size: 1.2em; line-height: 1.5'); expect(await transform(css)).toBe(css); }); + + /* + * The document default moved from `calc(1rem * scale)` to `var(--text-body)` in + * wave 2. The Text size preference must survive the move: the token is scaled + * where it is defined, and the body's read of it is left alone, so the default + * grows with Text size exactly once. Read through the plugin from the real + * stylesheet, not a fixture, because the property is a fact about main.css. + */ + it('scales the body default once, through --text-body', async () => { + const source = readFileSync(join(__dirname, 'main.css'), 'utf8'); + const compiled = await postcss([fontScale()]).process(source, { from: undefined }); + const bodySizes: string[] = []; + let token = ''; + compiled.root.walkDecls((decl) => { + if ( + decl.prop === 'font-size' && + decl.parent?.type === 'rule' && + decl.parent.selector === 'body' + ) + bodySizes.push(decl.value); + if (decl.prop === '--text-body') token = decl.value; + }); + expect(bodySizes).toEqual(['var(--text-body)']); + expect(token).toBe('calc(14px * var(--app-font-scale, 1))'); + }); }); diff --git a/ui/desktop/src/styles/fontStacks.test.ts b/ui/desktop/src/styles/fontStacks.test.ts index e756fbb0f..94185659e 100644 --- a/ui/desktop/src/styles/fontStacks.test.ts +++ b/ui/desktop/src/styles/fontStacks.test.ts @@ -27,6 +27,11 @@ import { BRAND_SANS } from '../components/icons/brandFont'; */ const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); const TERMINAL_DOCK = readFileSync(join(__dirname, '../components/InAppTerminalDock.tsx'), 'utf8'); +const LANDING_CSS = readFileSync(join(__dirname, '../../../../landing/site.css'), 'utf8'); +const DOCUMENT_PREVIEW = readFileSync( + join(__dirname, '../components/artifacts/DocumentPreview.tsx'), + 'utf8' +); /** A font stack as a comparable list: quoting and whitespace are not identity. */ function families(stack: string): string[] { @@ -65,3 +70,57 @@ describe('font stacks written out more than once', () => { expect(families(BRAND_SANS)[0]).toBe('Inter'); }); }); + +/** + * The interface face (redesign 2026-10, spec 2.1, owner decision of 2026-10-09): + * the app wears the WEBSITE's Arial-first stack, so a screenshot of the app and + * the page that sells it are set in one face. The two stylesheets are in + * different packages and nothing else ties them together, so a change to one + * stack would otherwise go unnoticed until someone compared screenshots. + * + * Arial draws 400 and 700 only, which is why the type scale has two visible + * weights (`typeScale.test.ts`). Inter is the brand face, used for the wordmark + * and the BR mark alone. + */ +describe('the interface face is the website face', () => { + const body = families(cssToken('font-body')); + + it('sets the body stack family for family as landing/site.css --font', () => { + const landing = LANDING_CSS.match(/[\s;{]--font:\s*([^;]+);/); + if (!landing) throw new Error('--font is not declared in landing/site.css'); + expect(body).toEqual(families(landing[1])); + }); + + it('leads with Arial and never names Inter, which is the mark only', () => { + expect(body[0]).toBe('Arial'); + expect(body).not.toContain('Inter'); + expect(body[body.length - 1]).toBe('sans-serif'); + }); + + it('keeps --font-heading and --font-sans as aliases of the body role', () => { + expect(cssToken('font-heading').trim()).toBe('var(--font-body)'); + expect(cssToken('font-sans').trim()).toBe('var(--font-body)'); + }); + + /** + * A sandboxed document cannot read a custom property, so the preview's iframe + * carries the stack as a resolved string. It may drop a family the body names + * (a fallback it never needs) but it may not add one, reorder them, or start + * with anything but Arial: that is how a third UI face got into the app. + */ + it('sets the document preview iframe in the body stack, in order', () => { + const stacks = [...DOCUMENT_PREVIEW.matchAll(/font-family:\s*([^;]+);/g)] + .map((m) => families(m[1])) + .filter((stack) => !stack.some((family) => /mono/i.test(family))); + expect(stacks.length).toBeGreaterThan(0); + for (const stack of stacks) { + expect(stack[0]).toBe('Arial'); + let from = 0; + for (const family of stack) { + const at = body.indexOf(family, from); + expect(at, `${family} is not in the body stack, in order`).toBeGreaterThanOrEqual(0); + from = at + 1; + } + } + }); +}); diff --git a/ui/desktop/src/styles/knowledgeFill.test.ts b/ui/desktop/src/styles/knowledgeFill.test.ts new file mode 100644 index 000000000..99ed94cc2 --- /dev/null +++ b/ui/desktop/src/styles/knowledgeFill.test.ts @@ -0,0 +1,277 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import * as themeTokens from '../../scripts/lib/theme-tokens.mjs'; + +/** + * The Knowledge surfaces under fills before lines (owner message 13, R-27; design.md 9.11). + * + * The Sources drop target, the graph's filter chips, the paste box, the digest model field and the + * tier confirmation's inner box are subtle fills with a transparent 1px edge and no resting + * outline; a recessed ground (a pane header, the graph canvas) rests a filled control on the strong + * step and hovers one fill deeper. jsdom applies no stylesheet, so each claim is read from the + * authored CSS at its source, and the ones that are measurements are read from the stylesheet's own + * tokens in all six scopes, the way `check-contrast.mjs` reads them. `knowledgeFill.browser.test.tsx` + * measures the same surfaces in Chromium. + */ + +type Scope = { decls: Record; theme: Record }; +const tokens = themeTokens as unknown as { + buildScopes(css: string): Record; + resolveHex(name: string, scope: Scope): string | null; + contrast(a: string, b: string): number; + blend(fillHex: string, alpha: number, groundHex: string): string; +}; + +const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); +const scopes = tokens.buildScopes(CSS); +const stripComments = (source: string) => source.replace(/\/\*[\s\S]*?\*\//g, ''); + +/** The declarations of the first rule whose selector list is exactly `selector` (comments dropped). */ +function rule(selector: string): Record { + const source = stripComments(CSS); + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); + const match = source.match(new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^}]*)\\}`)); + expect(match, `main.css has a ${selector} rule`).toBeTruthy(); + return Object.fromEntries( + match![1] + .split(';') + .map((decl) => decl.trim()) + .filter(Boolean) + .map((decl) => { + const at = decl.indexOf(':'); + return [decl.slice(0, at).trim(), decl.slice(at + 1).trim()]; + }) + ); +} + +describe('the Sources drop target is a fill, not an outline', () => { + it('rests on the fill with a transparent edge, and steps to the strong fill under the pointer', () => { + const zone = rule('.br-knowledge-dropzone'); + expect(zone.border).toBe('1px solid transparent'); + expect(zone['background-color']).toBe('var(--background-fill)'); + expect(rule('.br-knowledge-dropzone:hover')['background-color']).toBe( + 'var(--background-fill-strong)' + ); + }); + + it('firms the edge only while a file is held over it, with an inset line, not a border', () => { + const drag = rule(".br-knowledge-dropzone[data-dragging='true']"); + expect(drag['background-color']).toBe('var(--background-fill-strong)'); + expect(drag['box-shadow']).toBe('inset 0 0 0 1px var(--border-strong)'); + expect(drag['border-color']).toBeUndefined(); + }); + + it('draws its glyph plate one ground back, with no edge of its own', () => { + const plate = rule('.br-knowledge-drop-plate'); + expect(plate['background-color']).toBe('var(--background-default)'); + expect(plate.border).toBeUndefined(); + }); +}); + +describe('the graph filters are the filled control', () => { + it('rests on the control ground with a transparent 1px edge, and hovers on the strong fill', () => { + const facet = rule('.br-facet'); + expect(facet.border).toBe('1px solid transparent'); + expect(facet['background-color']).toBe('var(--br-control-ground, var(--background-fill))'); + expect(rule('.br-facet:hover')['background-color']).toBe('var(--background-fill-strong)'); + }); + + it('is engaged by the selected wash over the strong fill, which hover firms', () => { + const engaged = rule(".br-facet[data-engaged='true']"); + expect(engaged['background-color']).toBe('var(--background-fill-strong)'); + expect(engaged['background-image']).toBe( + 'linear-gradient(var(--overlay-selected), var(--overlay-selected))' + ); + expect(rule(".br-facet[data-engaged='true']:hover")['background-image']).toBe( + 'linear-gradient(var(--overlay-selected-hover), var(--overlay-selected-hover))' + ); + }); +}); + +describe('a recessed ground rests a filled control on the strong step', () => { + it('is the shared ground, which sets the control, hover and track grounds', () => { + const ground = rule('.br-ground-recessed'); + expect(ground['--br-control-ground']).toBe('var(--background-fill-strong)'); + expect(ground['--br-control-hover']).toBe('var(--background-strong)'); + expect(ground['--br-track-ground']).toBe('var(--background-fill-strong)'); + }); + + it('is carried by the pane header and the graph canvas, with no rule of Knowledge’s own', () => { + const root = join(__dirname, '../components/knowledge'); + for (const file of ['KnowledgePaneHeader.tsx', 'graph/KnowledgeGraphPanel.tsx']) { + const source = readFileSync(join(root, file), 'utf8'); + expect(source, file).toMatch(/className="[^"]*\bbr-ground-recessed\b/); + } + // The class Knowledge used to write for itself, and the stop-gap keyed on the empty state's + // tile, are gone: the primitives read the control ground themselves now. + expect(CSS).not.toContain('br-knowledge-recessed'); + for (const path of walk(root)) { + expect(readFileSync(path, 'utf8'), path).not.toContain('br-knowledge-recessed'); + } + }); + + const cases = Object.entries(scopes); + it('is measured in all six scopes', () => { + expect(cases).toHaveLength(6); + }); + + it.each(cases)('%s: rest and hover are each a step off the ground they sit on', (name, scope) => { + const hex = (token: string) => { + const value = tokens.resolveHex(token, scope); + expect(value, `${name}: ${token} is a hex`).toBeTruthy(); + return value!; + }; + const strongFill = hex('--background-fill-strong'); + // The rest fill is the muted value there (1.00:1), which is why the strong step is the rest. + expect(tokens.contrast(hex('--background-fill'), hex('--background-muted'))).toBeLessThan(1.02); + for (const ground of ['--sidebar', '--background-muted']) { + expect( + tokens.contrast(strongFill, hex(ground)), + `${name}: rest vs ${ground}` + ).toBeGreaterThanOrEqual(1.08); + } + // The hover has a step to take: the strong background is off the strong fill. + expect(tokens.contrast(hex('--background-strong'), strongFill)).toBeGreaterThanOrEqual(1.08); + // The tier badge's hover (a Public badge rests on the strong fill) is that same step. + expect(tokens.contrast(hex('--background-strong'), strongFill)).toBeGreaterThan( + tokens.contrast(hex('--background-medium'), strongFill) + ); + }); +}); + +describe('an engaged filter stays legible and a step off a resting one', () => { + it('measures the wash the stylesheet declares (14%, and 19% under the pointer)', () => { + const percent = (name: string) => + Number( + CSS.match( + new RegExp(`${name}:\\s*color-mix\\(in oklab, var\\(--text-default\\) (\\d+)%`) + )?.[1] + ); + expect(percent('--overlay-selected')).toBe(14); + expect(percent('--overlay-selected-hover')).toBe(19); + }); + + it.each(Object.entries(scopes))( + '%s: ink on the selected wash, and the wash is a step', + (name, scope) => { + const hex = (token: string) => { + const value = tokens.resolveHex(token, scope); + expect(value, `${name}: ${token} is a hex`).toBeTruthy(); + return value!; + }; + const ink = hex('--text-default'); + const rest = hex('--background-fill'); + const strong = hex('--background-fill-strong'); + // `--overlay-selected` is the default ink at 14% (hover 19%), composited over the strong fill. + const engaged = tokens.blend(ink, 0.14, strong); + const engagedHover = tokens.blend(ink, 0.19, strong); + expect(tokens.contrast(ink, engagedHover)).toBeGreaterThanOrEqual(4.5); + expect(tokens.contrast(engaged, rest)).toBeGreaterThanOrEqual(1.25); + } + ); +}); + +/** + * A chip on a washed row (vision round 2, V3-R2-5). The neutral Badge rests on the strong fill, and + * a hovered list row is the page ink at 5%, so under the pointer the change log's kind chip and the + * manager's Built-in, format, Private and Primary chips lost their boxes. R-27 usage rule 3 (a fill + * never sits on a fill) says where they go: the page ground. The step itself is the shared rows + * block's (`--br-badge-ground` on a washed row, read by `.br-badge-neutral`; WS-PRIMITIVES), and + * `knowledgeFill.browser.test.tsx` measures the real chips in Chromium. What this file keeps is + * the reason for the colour: in both modes the page ground is the more visible box on that wash, + * and no fainter than a resting fill is on the page. + */ +describe('a chip on a washed row steps back to the page ground', () => { + it("names no class of Knowledge's own: the shared rule does the step", () => { + expect(stripComments(CSS)).not.toContain('br-wash-chip'); + const root = join(__dirname, '../components/knowledge'); + for (const file of ['changelog/ChangeKindChip.tsx', 'KBSelector/KBManagerDialog.tsx']) { + expect(readFileSync(join(root, file), 'utf8'), file).not.toContain('br-wash-chip'); + } + }); + + it.each(Object.entries(scopes))( + '%s: the page ground is a more visible box than the strong fill on the wash', + (name, scope) => { + const hex = (token: string) => { + const value = tokens.resolveHex(token, scope); + expect(value, `${name}: ${token} is a hex`).toBeTruthy(); + return value!; + }; + const page = hex('--background-default'); + // `--overlay-hover` is the default ink at 5% over the row's ground (the page, in a dialog or + // a drawer). + const wash = tokens.blend(hex('--text-default'), 0.05, page); + const strong = tokens.contrast(hex('--background-fill-strong'), wash); + const chip = tokens.contrast(page, wash); + expect(chip, `${name}: page ground vs wash`).toBeGreaterThan(strong); + // As visible a box as a resting fill is on the page, the step every fill in the app is + // drawn at (1.09:1 in light), so a chip under the pointer is no fainter than at rest. + expect(chip, `${name}: the same step as a resting fill on the page`).toBeGreaterThanOrEqual( + tokens.contrast(hex('--background-fill'), page) - 0.01 + ); + // The chip's ink is read on the page ground, as it is everywhere else. + expect(tokens.contrast(hex('--text-muted'), page)).toBeGreaterThanOrEqual(4.5); + } + ); +}); + +/** + * Every edge the Knowledge components draw in a colour, by file. A line stays only where it is + * structural (a band's or strip's bottom edge, the footer rule over a scrolling body, a pane + * divider, a menu's separator) or the hairline of a floating surface (the node and edge cards, + * the popovers); an in-page box or control is a fill with a TRANSPARENT edge, which this does not + * count. A new outline fails here, and the fix is a fill, not a longer list. + */ +const EDGES: Record = { + 'KnowledgePaneHeader.tsx': ['border-border-subtle'], // the 44px band's bottom edge + 'IngestPanel/IngestPanel.tsx': ['border-border-subtle'], // the footer rule over a scrolling body + 'IngestPanel/IngestModelPicker.tsx': ['border-border-subtle'], // a rule inside the popover + 'KBSelector/KBManagerDialog.tsx': ['border-border-subtle'], // the pinned header over the list + 'KBSelector/KBSelectorMenu.tsx': ['border-border-subtle'], // a rule inside the popover + 'changelog/ChangeLogDrawer.tsx': ['border-border-subtle'], // the pinned filter row over the list + 'lint/LintDrawer.tsx': ['border-border-subtle'], // the pinned counts row over the list + 'graph/GraphFacetStrip.tsx': ['border-border-subtle'], // the 44px strip's bottom edge + 'graph/GraphLegend.tsx': ['border-border-subtle', 'border-text-muted'], // the rail's divider; a dashed mark + 'graph/NodePreview.tsx': ['border-border-subtle'], // a floating card + 'graph/EdgePreview.tsx': ['border-border-subtle'], // a floating card + 'graph/ForceGraphCanvas.tsx': ['border-border-subtle'], // a hidden probe that reads the token +}; + +function walk(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return walk(path); + return /\.tsx$/.test(entry.name) && !/\.test\./.test(entry.name) ? [path] : []; + }); +} + +describe('the Knowledge components draw no outline of their own', () => { + const root = join(__dirname, '../components/knowledge'); + const edgeRe = /(? { + const found: Record = {}; + for (const path of walk(root)) { + // Class strings, not the comments that explain them. + const source = readFileSync(path, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + const edges = [...new Set(source.match(edgeRe) ?? [])].sort(); + if (edges.length > 0) found[path.slice(root.length + 1)] = edges; + } + const want = Object.fromEntries(Object.entries(EDGES).map(([k, v]) => [k, [...v].sort()])); + expect(found).toEqual(want); + }); + + it('keeps no hover outline and no inset ring on a control', () => { + for (const path of walk(root)) { + const source = readFileSync(path, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + expect(source, path).not.toMatch(/hover:inset-ring|hover:border-|bg-background-medium/); + } + }); +}); diff --git a/ui/desktop/src/styles/knowledgeLadder.test.ts b/ui/desktop/src/styles/knowledgeLadder.test.ts index 9b2042d5d..c82cce42a 100644 --- a/ui/desktop/src/styles/knowledgeLadder.test.ts +++ b/ui/desktop/src/styles/knowledgeLadder.test.ts @@ -47,11 +47,6 @@ const LADDER: { token: string; px: number; query: RegExp }[] = [ px: 1400, query: /@container br-knowledge-pane \(min-width: 1400px\)/, }, - { - token: '--knowledge-pane-short', - px: 620, - query: /@container br-knowledge-pane \(max-height: 620px\)/, - }, ]; describe('the Knowledge pane ladder', () => { @@ -63,6 +58,16 @@ describe('the Knowledge pane ladder', () => { } ); + /** + * The height step is retired with the subtitle it hid (spec 3.12): the view's description is + * the band's InfoTip now, so a short pane has nothing left to yield. A height query coming back + * would be a step the ladder above does not document. + */ + it('has no height step', () => { + expect(CSS).not.toMatch(/@container br-knowledge-pane \(max-height:/); + expect(CSS).not.toContain('.br-knowledge-subtitle'); + }); + it('has no @container step the ladder above does not account for', () => { const found = [ ...CSS.matchAll(/@container br-knowledge-pane \((?:min-width|max-height): (\d+)px\)/g), @@ -130,3 +135,52 @@ describe('the Knowledge pane ladder', () => { expect(menu.sort()).toEqual(extra.sort()); }); }); + +/** + * The filter strip's two states that jsdom cannot see, asserted at the source like the ladder. + * Both were found by driving a real render of the pane, not by a test. + */ +describe('the filter strip', () => { + /** + * ⚠ **The readout and the Clear label wait for the step where the Legend chip retires.** They + * used to appear at 1140 beside all four facets AND the Legend: with a filter engaged the row + * needed ~977px in an 820px column at the common 1152px pane, and "Showing 3 of 16" painted + * over the Legend. Below the step Clear is the bare icon, whose Tooltip carries the count. + */ + it('shows the readout only at the step where the Legend chip gives its room back', () => { + const at = (px: number) => { + const from = CSS.indexOf(`@container br-knowledge-pane (min-width: ${px}px)`); + const next = CSS.indexOf('@container br-knowledge-pane', from + 1); + return CSS.slice(from, next === -1 ? undefined : next); + }; + expect(at(1140)).not.toContain('.br-knowledge-readout'); + expect(at(1400)).toMatch(/\.br-knowledge-readout\s*\{\s*display:\s*flex/); + }); + + /** + * ⚠ **An engaged facet is NEUTRAL now (R-27), and that is what keeps it readable under focus.** + * It was a solid accent fill, which cannot take the grey focus surface (white `--text-on-accent` + * over `--background-focus`) and needed its own restated focus rule. The strong fill under the + * selected wash carries the default ink on every ground, so there is no accent and no on-accent + * ink to protect, and the wash is a background-IMAGE, so the focus ground (a background-colour) + * still reads under it. A rule that painted a facet accent again, or restated its focus ground, + * would bring the old failure back. + */ + it('keeps an engaged facet readable under keyboard focus', () => { + const from = CSS.indexOf('/* @ws WS-KNOWLEDGE begin: filter facet */'); + const to = CSS.indexOf('/* @ws WS-KNOWLEDGE end: filter facet */'); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + const block = CSS.slice(from, to); + const rule = block.match(/\.br-facet\[data-engaged='true'\]\s*\{([^}]*)\}/); + expect(rule, 'an engaged facet needs its own rule').not.toBeNull(); + expect(rule![1]).toContain('background-color: var(--background-fill-strong)'); + expect(rule![1]).toContain('background-image: linear-gradient(var(--overlay-selected)'); + // Neutral: no accent ground and no accent ink anywhere in the facet block. + expect(block.replace(/\/\*[\s\S]*?\*\//g, '')).not.toMatch( + /--background-accent|--text-on-accent/ + ); + // And no focus rule of its own: the global focus surface reads through the wash. + expect(block.replace(/\/\*[\s\S]*?\*\//g, '')).not.toContain(':focus-visible'); + }); +}); diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index e36b71793..d059536c8 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -81,7 +81,8 @@ --font-heading: var(--font-body); /* a family may re-point this; body is the default */ --font-code: var(--font-mono); - /* ── Type: semantic roles on a 14px base, 4px grid (Astryx A-02) ────────── + /* @ws WS-TOKENS begin: type roles */ + /* ── Type: five sizes, two visible weights (redesign 2026-10, spec §2.1) ── The canonical scale was documented in design.md and had NO tokens, so Tailwind's defaults stood in and the two most characteristic steps had no utility at all — which is how the app accumulated 149 × `text-[11px]`, @@ -90,7 +91,20 @@ These are ROLES, not sizes: a row title is `text-label` because it labels a control, and it stays `text-label` if the size ever changes. Each carries its own line-height, weight and tracking, so a call site can no longer pick - 14px and forget the 500 weight that makes it a control label. + 14px and forget the weight that makes it a control label. + + THE SCALE IS 12 · 13 · 14 · 17 · 24, and nothing else renders. Crew already + lived on 12/13/14 with 17 for one title; the 2026-10 redesign brought the + rest of the app onto the same five steps and retired 10, 11, 15, 16, 19, + 20, 28 and 30. `text-caps` moved from 11px to 12px so the caps label is not + a sixth size. `text-display` (was 30/36) is gone: its last two call sites + moved to `text-title`. `text-heading` (was 20/28) is gone too: its last use + moved to `text-subheading`. + + TWO VISIBLE WEIGHTS. The face is Arial (the website's stack), which draws + 400 and 700 only: 500 renders as 400 and 600 as 700 (measured, typography + audit §5). So a state must never be carried by 500 alone; 600 is the one + strong weight, and it is drawn as bold. NAMESPACE HAZARD: Tailwind v4 requires font-size tokens to live under `--text-*`, which is also what this app names its ink COLOURS in `:root` @@ -99,30 +113,36 @@ so `text-text-muted` is ink and `text-supporting` is a size — but never add a colour to this block, or `text-danger` would silently become a font size. - Nothing below 11px exists. Anything smaller is deleted, not migrated. */ - --text-display: 30px; /* metric readouts */ - --text-display--line-height: 36px; - --text-display--font-weight: 400; - --text-display--letter-spacing: -0.01em; + Nothing below 12px exists. Anything smaller is deleted, not migrated. */ - --text-title: 24px; /* page titles */ + /* The one large step: the Home greeting, the document h1, the preview's + front-matter title and stat values (with `tabular-nums`). Weight 400, not + 600 (owner decision 6.2): at 24px the size carries the hierarchy, and a + bold Arial headline is the loudest thing on any screen it sits on. */ + --text-title: 24px; --text-title--line-height: 32px; - --text-title--font-weight: 600; + --text-title--font-weight: 400; --text-title--letter-spacing: -0.01em; - --text-heading: 20px; /* section headings, markdown h1 */ - --text-heading--line-height: 28px; - --text-heading--font-weight: 600; - - --text-subheading: 17px; /* card and panel titles, markdown h2, empty-state titles */ + /* Dialog titles, empty-state titles, markdown h1 and h2 in chat, markdown h2 + in documents. */ + --text-subheading: 17px; --text-subheading--line-height: 24px; --text-subheading--font-weight: 600; - --text-body: 14px; /* message text, prose, table cells */ + --text-body: 14px; /* UI prose and values */ --text-body--line-height: 20px; --text-body--font-weight: 400; - --text-label: 14px; /* EVERY control's text, row titles, markdown h3 */ + /* Reading text: chat markdown, document prose and Crew messages. The same 14px + as `text-body` on a 21px line, the measure Crew's timeline already reads at + (`crew/timeline/timeline.css`), instead of chat's old 1.7 (23.8px). One + reading rhythm for every message in the app. */ + --text-prose: 14px; + --text-prose--line-height: 21px; + --text-prose--font-weight: 400; + + --text-label: 14px; /* control text, row titles, band titles, field labels */ --text-label--line-height: 20px; --text-label--font-weight: 500; @@ -131,43 +151,53 @@ 14px does not fit a 34px strip beside 12px metadata" and each invented its own way out. One exception, written once, beats a new role per section. - The rule: a control's text is `text-label`. The single exception is a - control inside a dense strip (<=36px) whose neighbours are metadata — it - takes `text-secondary`. It does NOT drop to `text-supporting`; a control - that renders at metadata size stops looking like something you can press. - Do not add a `text-control-dense`: Astryx's own rule is that font size does - not change with control height, and this one exception is the whole - concession to a surface that genuinely runs denser. */ - --text-secondary: 13px; /* tab labels, menu rows, dense descriptions, dense controls */ + The rule: a control's text is `text-label`. The exception is a control + inside a dense strip (<=36px) whose neighbours are metadata, and the + redesign's dense lists (sidebar rows, menu rows, tab labels, segmented + labels, tool-call rows, table cells, composer controls) — they take + `text-secondary`. It does NOT drop to `text-supporting`; a control that + renders at metadata size stops looking like something you can press. Do + not add a `text-control-dense`: font size does not change with control + height, and this one exception is the whole concession to a surface that + genuinely runs denser. */ + --text-secondary: 13px; --text-secondary--line-height: 18px; --text-secondary--font-weight: 400; - --text-supporting: 12px; /* timestamps, metadata, captions, counts */ + /* Metadata, times, counts, helper and tooltip text, sidebar bucket and group + labels, menu group labels, table heads, footnotes. */ + --text-supporting: 12px; --text-supporting--line-height: 16px; --text-supporting--font-weight: 400; /* The chip role. 12/500 is what the design of record specifies for a chip and what the role set was missing, which is why `badge.tsx` was still carrying a raw `text-[11px] font-medium` after every other primitive had moved: no role - could serve it. `text-caps` bakes in uppercase and +0.08em tracking, both - wrong on a sentence-case chip; `text-supporting` is 12/400 and would drop - the weight that makes a chip read as a discrete object rather than as - metadata that happens to have a background. + could serve it. `text-caps` bakes in uppercase and tracking, both wrong on a + sentence-case chip; `text-supporting` is 12/400. It shares a size with `text-supporting` and differs only in weight, and that - is the distinction: supporting text is READ, a chip is COUNTED. */ + is the distinction: supporting text is READ, a chip is COUNTED. (Under Arial + the 500 renders as 400, so a chip is told apart by its ground, never by its + weight alone.) */ --text-chip: 12px; --text-chip--line-height: 16px; --text-chip--font-weight: 500; - --text-caps: 11px; /* section labels — the ONE caps style */ + /* The ONE caps style, for page section labels only (Settings sections, view + group labels, Crew). 12px, not 11: a sixth size for one label was the + price of the old value. The tracking halves to +0.04em because 12px caps + need less air than 11px ones did. Sidebar group headers, menu group labels + and bucket labels are sentence case at `text-supporting`, never caps. */ + --text-caps: 12px; --text-caps--line-height: 16px; --text-caps--font-weight: 500; - --text-caps--letter-spacing: 0.08em; + --text-caps--letter-spacing: 0.04em; --text-code: 13px; /* code blocks and terminal — byte-identical to each other */ --text-code--line-height: 20px; --text-code--font-weight: 400; + /* @ws WS-TOKENS end: type roles */ /* ── Motion defaults ────────────────────────────────────────────────────── Tailwind ships its own 150ms / cubic-bezier(0.4, 0, 0.2, 1) and was never @@ -258,6 +288,7 @@ `--radius-container`. */ --radius-full: 9999px; + /* @ws WS-TOKENS begin: motion tokens */ /* ── Duration: three tiers, min / default / max (Astryx §5) ─────────────── The governing rule is legible in the values: THE BIGGER THE ELEMENT, THE SLOWER THE MOTION. A menu item answering the pointer at 450ms feels broken; @@ -265,38 +296,36 @@ by how important it is. Entrances are staged; EXITS ARE FAST OR INSTANT — an element you are - leaving must never delay you. */ - --dur-fast-min: 95ms; /* menu-item hover, row hover, table rows — highest-frequency feedback */ - --dur-fast: 125ms; /* buttons, switches, inputs, chips — control state changes */ - --dur-fast-max: 175ms; /* menus, tooltips, the tab indicator */ - --dur-med-min: 250ms; /* progress fill, small layout settles */ - --dur-med: 300ms; /* sidebar collapse, panel resize, accordion, long-message expand */ - --dur-med-max: 450ms; /* dialogs, the command palette */ + leaving must never delay you. Every exit of a small surface takes + `--dur-fast`, one tier below its entrance, and travels half the distance. + + JavaScript reads these as numbers from `styles/motion.ts` (`DUR`, + `EASE_OUT`, `EASE_SPRING`), because a WAAPI timing object cannot read a + custom property. `styles/motion.test.ts` pins each number to the line here: + change a value in both places or the test fails. */ + --dur-fast-min: 95ms; /* row and menu-item hover, row-action reveal, tooltip exit */ + --dur-fast: 125ms; /* control state changes; every exit of a small surface */ + --dur-fast-max: 175ms; /* entrances of small surfaces, tab enter, crossfade in, chevrons */ + --dur-med-min: 250ms; /* dialog enter, progress fill, side and stack crossfade */ + --dur-med: 300ms; /* panels: preview, summary rail, conversation glide, sidebar, disclosure */ /* The slow tier has exactly one member. It is not "a slower dialog" — it is - the period of an ambient loop that never ends (the spinner), and giving it - min/max variants would invite it to be used for transitions. */ + the period of an ambient loop that never ends (the spinner, the pulses), and + giving it min/max variants would invite it to be used for transitions. Loops + run on multiples of it: `calc(var(--dur-slow) * n)`. */ --dur-slow: 525ms; - /* Deprecated aliases. The three-token scale was correct; it just had no way to - express the difference between a row hover and a tab indicator. Re-pointed - rather than retuned, so existing sites shift by at most 5ms — except - `--motion-slow`, which was 260ms and is now the 300ms medium tier it was - always describing. Sweep call sites to `--dur-*` and delete this block. */ - --motion-fast: var(--dur-fast); - --motion-base: var(--dur-fast-max); - --motion-slow: var(--dur-med); - /* ONE curve, everywhere: a strong decelerate with no overshoot. A system with two general-purpose easings has no easing — it has a coin flip at every call site, which is how this app ended up running four curves for one hover. */ --ease-out: cubic-bezier(0.24, 1, 0.4, 1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); /* DEPRECATED: defined, never used. Deleted in the sweep. */ - /* Gentle overshoot for physical gestures — tab reorder/select settle and the - drag-ghost lift (#37). Reserved for TRANSFORMS: an overshooting colour or - opacity clamps and reads as a flicker, so colour keeps --ease-out. - This is the one sanctioned exception to "one curve"; it survives because the - tab-drag choreography is the best-reasoned motion in the app. */ + /* Gentle overshoot for physical gestures — tab reorder/select settle, the + drag-ghost lift (#37) and the segmented control's sliding thumb. Reserved + for TRANSFORMS: an overshooting colour or opacity clamps and reads as a + flicker, so colour keeps --ease-out. This is the one sanctioned exception + to "one curve"; it survives because the tab-drag choreography is the + best-reasoned motion in the app. */ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + /* @ws WS-TOKENS end: motion tokens */ /* ── Interaction tints (Astryx A-05) ───────────────────────────────────── Hover and press are INK AT AN ALPHA, composited over whatever ground the @@ -335,6 +364,26 @@ and lighter than `--border-strong` (which encloses). */ --border-emphasized: color-mix(in oklab, var(--text-default) 24%, transparent); + /* @ws WS-TOKENS begin: control edges */ + /* The resting EDGE of a control you operate: every input, checkbox, radio, + select trigger and the switch knob's ring (redesign spec §2.3). It replaces + `--border-emphasized` on those controls because ink at 24% measured 1.6:1 on + white, under the 3:1 a control's boundary owes (WCAG 1.4.11), so an empty + field or an unchecked box read as a smudge. + + HEX, NOT A MIX, on purpose: `check-contrast.mjs` resolves hex only, and it + holds this edge to 3:1 on `--background-default` and `--background-card` in + all six scopes. The neutrals are shared by every family, so one light and + one dark value (`.dark` below) serve all three; no family block restates + them. Chosen against the switch, not just the field: the off knob's ring must + also clear 3:1 against `--control-track-off`, which is what keeps the track + a quiet step off the page rather than a mid-grey slab. */ + --border-control: #86847f; + /* The Switch's off track: a visible step off the page (1.19:1) that the knob's + `--border-control` ring still clears at 3:1. */ + --control-track-off: #ebebe8; + /* @ws WS-TOKENS end: control edges */ + /* ── Geometry: the control ladder and the bands (Astryx §2.3) ───────────── Every value below is already written down in the adoption document; what it did not have was a NAME, which is why the app runs four bar heights, four @@ -368,7 +417,9 @@ /* Icon boxes, named for WHERE the glyph sits, because that is the rule §3.8b states and the one the app breaks: never two sizes in one cluster. */ - --icon-chip: 14px; /* inside a chip — nowhere else */ + /* @ws WS-TOKENS begin: icon-chip note */ + --icon-chip: 14px; /* inside a chip, and the inline help glyph (InfoTip) — nowhere else */ + /* @ws WS-TOKENS end: icon-chip note */ --icon-row: 16px; /* a row, a control, a toolbar: the default */ --icon-banner: 20px; /* banners, empty states */ @@ -452,6 +503,13 @@ /* Rows. Two rhythms and no more: the rail (sidebar, nav, menu item, recents, select option) and the content list. They are separate because they answer different questions — a rail row is navigation, a content row is data. */ + /* @ws WS-TOKENS begin: nav row */ + /* The app sidebar's rows, and nothing else: 28px at 13px text (redesign + decision 6.1), the density of the Codex and Claude Code sidebars that the + owner asked for (message 12). Every other part of the row follows Crew's + recipe; Crew's own rail and every menu row keep `--row-height-rail`. */ + --row-height-nav: 28px; + /* @ws WS-TOKENS end: nav row */ --row-height-rail: 32px; --row-height: 40px; /* D-12·B — one row rhythm, no compact mode. A-03 retunes to 36px in phase 8. */ @@ -588,17 +646,6 @@ --knowledge-rail-sources: 300px; --knowledge-rail-detail: 340px; - /* The Knowledge section's two in-page bands (redesign R-02, R-08). Both are - 48px and NEITHER is `--chrome-height`: that 44px value is shared by the - three chrome bands that meet at one continuous top edge and move together - or not at all, and both of these sit inside the page. They are separate - tokens rather than one because they answer to different arguments — the - subject bar is sized by its switcher, the filter bar by a 32px control plus - real padding-block, which the 36px `--dock-height` it replaces did not - have (it left 2px of air above and below a 32px input). */ - --knowledge-subject-height: 48px; - --knowledge-filter-height: 48px; - /* The pane ladder (R-08). Container-query breakpoints, NOT viewport ones — the section's one `md:` media query tests the window while the thing that changes size is the pane, and at the app's own minimum window with the @@ -614,7 +661,6 @@ --knowledge-pane-legend-card: 940px; --knowledge-pane-full-filters: 1140px; --knowledge-pane-three-col: 1400px; - --knowledge-pane-short: 620px; --z-base: 0; --z-sticky: 100; @@ -667,6 +713,39 @@ --background-well: #f5f5f3; --background-medium: #ecece9; --background-strong: #dcdcd8; + /* @ws WS-TOKENS begin: fill tokens */ + /* FILLS BEFORE LINES (owner message 13, R-27; design.md 9.2 and 9.5). A card, + a boxed panel, a secondary button, a chip, a strip tab, the segmented track, + a text field and a select rest on `--background-fill` with NO outline; + hover and press step to `--background-fill-strong`. The chat composer's + card is the one box this does NOT reach (owner message 17, R-32): it rests + on the page ground with a `--border-subtle` hairline at 60%. + + One subtle step, measured as a contrast ratio and as CIELAB lightness: + `#f4f4f2` is 1.10:1 on the white page and card (dL* 3.9, the same step the + dark fill takes off its card), and the strong step is 1.10:1 off the fill. + `check-contrast.mjs` holds the rest fill to 1.08 to 1.35 on the canvas, the + page and the card, the strong step to 1.08 off the fill, ink and control + edges to their usual floors on both, in all six scopes. + + ON A RECESSED GROUND (the sidebar, `--background-muted`), REST ON THE STRONG + STEP: the rest fill IS the muted value (1.00:1 there, 1.03:1 on the + sidebar), and the strong step is 1.10:1 and 1.12:1 off them. + + Keep the 1px border and make it transparent (`border border-transparent`), + never `border-0`: forced colours repaint the fill as Canvas and draw a + transparent border in the system ink, so the box keeps its shape there, and + no geometry moves between the bordered and the filled look. + + STRUCTURAL: hex literals declared once per mode in this base block (the + checker resolves hex only), shared by all three families, never restated + in a family block (`theme-contract.mjs` STRUCTURAL_TOKENS, + `themeNeutrals.test.ts`). Opaque on purpose: a sticky table head, a fade + that ends at the fill and anything that sits over content need a solid + ground, which a translucent ink would not give them. */ + --background-fill: #f4f4f2; + --background-fill-strong: #eaeae6; + /* @ws WS-TOKENS end: fill tokens */ /* The tooltip fill. Warm near-black, NOT `--color-black`. This used to be per family — each set it to its own `--text-default` — but a tooltip is a SURFACE, and surfaces are now shared: all three paint #1f1e1c and place @@ -787,10 +866,9 @@ --heat-2: #dda27a; --heat-3: #c6774c; --heat-4: #a04a27; - /* Grid metrics: 22 weeks x (24 + 6) - 6 = 654px, inside the 760px chat column. */ - --heat-cell: 24px; - --heat-gap: 6px; - --heat-labels: 34px; + /* The heatmap's gap. Its columns are fluid (one per week of the window, sharing + the usage card's width), so there is no cell size or label gutter to declare. */ + --heat-gap: 3px; /* Two-tone canvas: the sidebar is a distinct, slightly deeper neutral surface sitting against the white main canvas (Codex-style layering, kept @@ -798,12 +876,13 @@ are scaffolding; what varies per family is the ink and icon on them. */ --sidebar: #f7f7f5; --sidebar-foreground: var(--text-default); - /* Nav-icon colour. PER FAMILY — one of the two axes a family owns. Parchment - keeps icons the same ink as their label, so this is a pass-through; Alma - Mater and Roche Limit override it to carry their accent. Defined here (and - in .dark) so every theme resolves it and check-contrast.mjs can assert it - across all six scopes. */ - --sidebar-icon: var(--sidebar-foreground); + /* Nav-icon colour. PER FAMILY, so a family could tint it; none does. Every + family points it at its muted ink, the ink a nav row's label and a chat + row's glyph wear at rest, so the sidebar's icon column is one ink (vision + V1-R2-5; the accent is held for the active rail and the lock badge, V1-M3). + Defined here (and in .dark) so every theme resolves it and + check-contrast.mjs can assert it across all six scopes. */ + --sidebar-icon: var(--text-muted); --sidebar-hover: #efefec; --sidebar-active: #eaeae6; --sidebar-accent: var(--sidebar-hover); @@ -906,6 +985,20 @@ --background-well: #232320; --background-medium: var(--color-neutral-800); --background-strong: #3a3a36; + /* @ws WS-TOKENS begin: fill tokens (dark) */ + /* See the light pair in `:root`. One step UP from the card (1.09:1, dL* 3.9, + the light step's size), 1.18:1 on the canvas the transcript paints and + 1.14:1 on the sidebar; the strong step is 1.13:1 off the fill and 1.13:1 + off `--background-muted`. Ratios compress toward black, so the same ratio + is a smaller visible step here than in light: the lightness step is what + was matched. ⚠ A dark popover re-points `--background-default` to + `--background-medium` (#2c2c29), which equals the strong step: a field in + a dark popover rests on the fill as a quiet well (1.13:1 below it), and + `.dark .biorouter-popover-surface` ("popover fill", below) re-points the + strong step one step DEEPER so its hover is still a change. */ + --background-fill: #232320; + --background-fill-strong: #2c2c29; + /* @ws WS-TOKENS end: fill tokens (dark) */ --background-inverse: #ededea; --background-danger: var(--color-red-100); --background-success: var(--color-green-100); @@ -918,6 +1011,12 @@ --border-subtle: #302f2c; --border-strong: var(--color-neutral-700); --border-input: #4a4945; + /* @ws WS-TOKENS begin: control edges (dark) */ + /* See the light pair in `:root`. 4.31:1 on the page and the card; the off + track sits 1.36:1 off the page and the knob ring clears 3:1 against it. */ + --border-control: #817f7a; + --control-track-off: #33332f; + /* @ws WS-TOKENS end: control edges (dark) */ --border-default: var(--border-subtle); --border-danger: var(--color-red-100); --border-success: var(--color-green-100); @@ -946,8 +1045,15 @@ --background-focus: #35342f; --border-focus: #9c9a93; - --heat-0: #1e1d1b; - --heat-1: #4a3524; + /* @ws WS-TOKENS begin: dark heat-0 */ + /* One step up from #1e1d1b, which measured 1.02:1 on the card and left a + zero day invisible. 1.33:1 on `--background-card`, held at 1.3 by + `check-contrast.mjs`. Each family file sets the same value. */ + --heat-0: #32312e; + /* Parchment's first step moved up with it: #4a3524 measured 1.13:1 on the lifted zero + (1.46:1 on the old one), so a quiet day and no day read as one. 1.32:1 now. */ + --heat-1: #573e2a; + /* @ws WS-TOKENS end: dark heat-0 */ --heat-2: #7a4d2e; --heat-3: #b0653a; --heat-4: #e8895f; @@ -956,7 +1062,7 @@ main canvas, with its own hover/active steps. Shared, like light. */ --sidebar: #171716; --sidebar-foreground: var(--text-default); - --sidebar-icon: var(--sidebar-foreground); + --sidebar-icon: var(--text-muted); --sidebar-hover: #232320; --sidebar-active: #2e2e2a; --sidebar-accent: var(--sidebar-hover); @@ -1093,7 +1199,7 @@ --heat-4: #0e5258; --sidebar: #f7f7f5; --sidebar-foreground: #052049; - --sidebar-icon: #14828c; + --sidebar-icon: #506380; --sidebar-hover: #efefec; --sidebar-active: #eaeae6; --sidebar-accent: #efefec; @@ -1165,14 +1271,14 @@ --ring: #a5a39d; --background-focus: #35342f; --border-focus: #9c9a93; - --heat-0: #1e1d1b; + --heat-0: #32312e; --heat-1: #0e5258; --heat-2: #14828c; --heat-3: #16a0ac; --heat-4: #60d0da; --sidebar: #171716; --sidebar-foreground: #f2f3f4; - --sidebar-icon: #60d0da; + --sidebar-icon: #b4b9bf; --sidebar-hover: #232320; --sidebar-active: #2e2e2a; --sidebar-accent: #232320; @@ -1283,7 +1389,7 @@ --heat-4: #c4560e; --sidebar: #f7f7f5; --sidebar-foreground: #1f1e1c; - --sidebar-icon: #d95b08; + --sidebar-icon: #5c5a55; --sidebar-hover: #efefec; --sidebar-active: #eaeae6; --sidebar-accent: #efefec; @@ -1355,14 +1461,14 @@ --ring: #a5a39d; --background-focus: #35342f; --border-focus: #9c9a93; - --heat-0: #1e1d1b; - --heat-1: #4a2a0e; + --heat-0: #32312e; + --heat-1: #5c3410; --heat-2: #7a4413; --heat-3: #b45f18; --heat-4: #ee6c1a; --sidebar: #171716; --sidebar-foreground: #ededea; - --sidebar-icon: #ee6c1a; + --sidebar-icon: #a5a39d; --sidebar-hover: #232320; --sidebar-active: #2e2e2a; --sidebar-accent: #232320; @@ -1402,6 +1508,11 @@ --color-background-well: var(--background-well); --color-background-medium: var(--background-medium); --color-background-strong: var(--background-strong); + /* @ws WS-TOKENS begin: fill token mirrors */ + /* `bg-background-fill`, `hover:bg-background-fill-strong` and friends (R-27). */ + --color-background-fill: var(--background-fill); + --color-background-fill-strong: var(--background-fill-strong); + /* @ws WS-TOKENS end: fill token mirrors */ --color-background-inverse: var(--background-inverse); --color-background-danger: var(--background-danger); --color-background-success: var(--background-success); @@ -1452,6 +1563,10 @@ --color-border-default: var(--border-default); --color-border-subtle: var(--border-subtle); --color-border-input: var(--border-input); + /* @ws WS-TOKENS begin: control edge mirrors */ + --color-border-control: var(--border-control); + --color-control-track-off: var(--control-track-off); + /* @ws WS-TOKENS end: control edge mirrors */ --color-border-strong: var(--border-strong); --color-border-danger: var(--border-danger); --color-border-success: var(--border-success); @@ -1561,6 +1676,9 @@ --spacing-row: var(--row-height); --spacing-row-rail: var(--row-height-rail); + /* @ws WS-TOKENS begin: nav row mirror */ + --spacing-row-nav: var(--row-height-nav); + /* @ws WS-TOKENS end: nav row mirror */ --spacing-dialog-sm: var(--dialog-sm); --spacing-dialog-md: var(--dialog-md); @@ -1579,8 +1697,6 @@ Only `grid-cols` keeps the `[var(…)]` form: it has no token shorthand. */ --spacing-knowledge-rail-sources: var(--knowledge-rail-sources); --spacing-knowledge-rail-detail: var(--knowledge-rail-detail); - --spacing-knowledge-subject-height: var(--knowledge-subject-height); - --spacing-knowledge-filter-height: var(--knowledge-filter-height); } @layer base { @@ -1594,11 +1710,26 @@ border-color: var(--border-subtle); } + /* @ws WS-TOKENS begin: body size */ + /* The document default is `text-body`, 14px: the scale's UI-prose step, so + anything that names no size (a dialog's paragraph, a raw input, a menu's + text) lands ON the scale instead of at 16px, which the 2026-10 redesign + retired (spec §2.1, wave 2). It used to be `calc(1rem * scale)`; + the Text size preference still applies, because `vite-plugins/fontScale.mjs` + scales `--text-body` where it is defined, and a token read is never scaled + twice (`fontScale.test.ts` reads this rule through the plugin). + + Only the SIZE moves. The line height stays Tailwind's inherited unitless + 1.5 (21px here): a fixed 20px would be inherited by every rule that sets a + size without a line height, cramping its larger text, and a new ratio would + move Crew's lines, which are the reference. `html` stays 16px, so rem-based + spacing is unchanged. */ body { - font-size: calc(1rem * var(--app-font-scale, 1)); + font-size: var(--text-body); @apply text-text-default; font-family: var(--font-sans); } + /* @ws WS-TOKENS end: body size */ /* P5 / D-15 — focus is a SURFACE SHIFT, never a ring. An outline drawn around the control reads as an alarm on a surface whose @@ -1762,9 +1893,12 @@ box-shadow: none; } - /* A container that holds a focused field (the composer, a search shell) takes - the same quiet shift rather than letting the inner control draw a box. */ - :where(.biorouter-chat-composer, .biorouter-list-row, .biorouter-settings-row):focus-within { + /* The composer, holding a focused field, takes the same quiet shift rather + than letting the inner control draw a box. The list and settings rows used + to be named here too and did nothing: their unlayered `border-bottom` beats + a layered `border-color`, and their focus is the `:has(:focus-visible)` + hairline in the row family. `:where()` keeps this at zero specificity. */ + :where(.biorouter-chat-composer):focus-within { border-color: var(--border-focus); } @@ -1859,18 +1993,27 @@ * so an outset outline stands 4px above the trigger — and Crew's details pane * pins its tablist `sticky top-0` flush against the top of its own scroller, * which clips that top edge off and leaves a U. The pseudo-element is the same - * ring (2px `--border-focus`, 2px clear of the label, `--radius-element`) - * sized to the LABEL: 28px tall and centred, so it sits inside the trigger's - * height and no scroller can reach it, and 4px past each side, where the - * tablist's gap-5 leaves room. The trigger is already `position: relative` - * for its selection bar (`ui/tabs.tsx`), and `::after` stays that bar. + * ring (2px `--border-focus`, `--radius-element`) sized to the LABEL: 28px + * tall and centred, so it sits inside the trigger's height and no scroller can + * reach it, and 6px past each side, so the ring's inner edge is 4px clear of + * the label (at 4px the inner edge was 2px from the glyphs and antialiasing + * made "Chat" touch it, vision V4-16). Two neighbours' rings cannot meet: the + * tablist's gap-5 is 20px and the rings take 12 of it. The trigger is already + * `position: relative` for its selection bar (`ui/tabs.tsx`), and `::after` + * stays that bar. * `[data-orientation]` is what Radix writes on every trigger and nothing else * with the role carries, so a plain `role="tab"` button — which is not * positioned — keeps the outline. * - * A document tab's inner button fills its `.br-tab` pill edge to edge inside a - * scrolling strip, where an outset ring would be clipped and would cross the - * neighbouring pill, so there the outline is drawn INSET. */ + * A document tab draws its keyboard ring on the `.br-tab` pill (tab family, + * WS-MOTION: `.br-tab:has(> [role='tab']:focus-visible)`), because the label + * button does not fill the pill: it starts after the padding and stops before + * the close control, so an inset outline on it ran through the chat glyph and + * the ellipsis. Every strip in the app (chat, preview, dock) puts the button + * directly in the pill, so that rule wins there. This inset outline is the + * fallback for a `.br-tab` whose focusable is not a direct child, inside a + * scrolling strip where an outset ring would be clipped and would cross the + * neighbouring pill. */ :where([role='tab']:not(.br-tab)):focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; @@ -1885,7 +2028,7 @@ :where([role='tab'][data-orientation]:not(.br-tab)):focus-visible::before { content: ''; position: absolute; - inset-inline: -4px; + inset-inline: -6px; inset-block: calc(50% - 14px); border: 2px solid var(--border-focus); border-radius: var(--radius-element); @@ -1978,9 +2121,9 @@ input[type='radio']:focus-visible ~ [data-radio-ring] { /* The ring itself (QA Q3-58). It was `--border-emphasized`, ink at 24%, which * measured 1.6:1 on white — under the 3:1 a control's boundary owes (WCAG * 1.4.11), so an unchecked radio read as a faint smudge beside its label. At - * rest it is now `--text-muted`, which `check-contrast.mjs` holds to 4.5:1 on - * every text ground and `focusFallback.test.ts` measures on every ground a - * dialog paints, in all six scopes; checked, the accent edge as before. + * rest it is now `--border-control`, the checkbox's own edge (spec 2.6), which + * `focusFallback.test.ts` measures at 3:1 on every ground a dialog paints, in + * all six scopes; checked, the accent edge as before. * * ⚠ The rest rule is a BARE `[data-radio-ring]` (0,1,0) on purpose. The * forced-colours block at the end of this file redraws the ring with the same @@ -1988,7 +2131,7 @@ input[type='radio']:focus-visible ~ [data-radio-ring] { * (`input ~ [data-radio-ring]`) would out-rank its `CanvasText` rule and paint * this token under `forced-color-adjust: none` in High Contrast. */ [data-radio-ring] { - border-color: var(--text-muted); + border-color: var(--border-control); } input[type='radio']:checked ~ [data-radio-ring] { @@ -2008,6 +2151,40 @@ input[type='radio']:checked ~ [data-radio-ring] { } } +/* @ws WS-TOKENS begin: resize rule */ +/* ── Layout snaps while geometry is being dragged (spec principle 9) ──────── + `AppLayout` puts `biorouter-window-resizing` on while the window is + resizing (cleared 180ms after the last event) and the sidebar's handle puts + `biorouter-sidebar-resizing` there while it drags. Any element whose geometry + follows layout opts in with `data-motion-layout`, and this one rule stops + BOTH its transitions and its animations for the duration. The older rule in + the preview's resizing block zeroes transitions on four named selectors only, + so a keyframe (a rail fading in, a pane sliding) kept playing against the + drag. JavaScript asks the same question through `isWindowResizing()` in + `styles/motion.ts`. */ +body.biorouter-window-resizing [data-motion-layout], +body.biorouter-sidebar-resizing [data-motion-layout] { + transition-duration: 0ms !important; + animation: none !important; +} +/* @ws WS-TOKENS end: resize rule */ + +/* @ws WS-TOKENS begin: theme switch */ +/* ── A theme change lands in one frame (vision finding V4-15) ────────────── + `suspendTransitionsForThemeChange()` in `styles/motion.ts` puts this class + on just before the light/dark class or `data-theme` flips and takes it + off two frames later. Without it the grounds snapped while every control that + eases its colour (95 to 125ms) walked there from the old theme: a dark button + on a white page, 2:1 sidebar text. `styles/motion.test.ts` pins the rule and + `themeSwitch.browser.test.ts` measures it in Chromium. */ +:root.biorouter-theme-switching, +:root.biorouter-theme-switching *, +:root.biorouter-theme-switching *::before, +:root.biorouter-theme-switching *::after { + transition: none !important; +} +/* @ws WS-TOKENS end: theme switch */ + /* animations */ @keyframes appear { from { @@ -2029,46 +2206,168 @@ input[type='radio']:checked ~ [data-radio-ring] { } } -/* Subtle entrance for tool-call cards appearing mid-stream. Kept short + small - (frequency-of-use rule) and transform/opacity only. */ -@keyframes biorouter-tool-enter { +/* @ws WS-TOKENS begin: shared motion classes */ +/* ── Shared motion classes (redesign spec §2.4) ───────────────────────────── + Crew's motion recipes, promoted under `br-` names so every surface outside + Crew uses the same three gestures (principle 11: never borrow a `crew-*` + class outside `crew/`; Crew keeps its own copies). Tokens only, one curve, + exits a tier faster than entrances, and every rule restates its rest under + reduced motion in the block below. `styles/motion.test.ts` pins all of it. + + MOUNTING IS NOT OPENING. Put these classes on content that ARRIVES while the + person watches (a live row, a step change), never on content that mounts + because of navigation, a reload, a tab switch or a history load: that + content appears at rest. */ + +/* The one entrance for a transcript block or a list row arriving live: rise + 4px and fade in over `--dur-fast-max`. When several arrive together, stagger + them 30ms apart and animate at most five (`MOTION` in `styles/motion.ts`); + the rest appear at rest. */ +.br-enter { + animation: br-enter var(--dur-fast-max) var(--ease-out) both; +} + +@keyframes br-enter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; - transform: translateY(0); + transform: none; } } -@keyframes biorouter-working-ring { - 0% { - opacity: 0.5; - transform: scale(0.5); +/* The cross-fade, for a thing changing state in place (a button label, a + status chip, a card's step): stack the outgoing and incoming items in one + grid cell; the incoming rises 8px and fades in over `--dur-fast-max`, the + outgoing only fades, over `--dur-fast`. Keep the outgoing item mounted with + `data-state='closed'` until its `animationend` to cross-fade rather than + cut. Widths snap; only opacity and transform move. */ +.br-crossfade { + display: grid; + min-width: 0; +} + +.br-crossfade > .br-crossfade-item { + grid-area: 1 / 1; + min-width: 0; +} + +.br-crossfade-item[data-state='open'] { + animation: br-crossfade-in var(--dur-fast-max) var(--ease-out); +} + +.br-crossfade-item[data-state='closed'] { + visibility: hidden; + pointer-events: none; + animation: br-fade-out var(--dur-fast) var(--ease-out); +} + +@keyframes br-crossfade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; } - 70% { +} + +@keyframes br-fade-in { + from { opacity: 0; - transform: scale(1.55); } - 100% { + to { + opacity: 1; + } +} + +/* Ends `visibility: hidden`, so an element leaving with it drops out of the + tab order at the end, never before (it stays visible mid-fade). */ +@keyframes br-fade-out { + from { + visibility: visible; + opacity: 1; + } + to { + visibility: hidden; opacity: 0; - transform: scale(1.55); } } -@keyframes biorouter-working-glow { - 0%, - 100% { - opacity: 0.2; - transform: scale(0.9); +/* The highlight wash, for the thing the person was just sent to (a deep link + into Settings, a freshly installed extension, a new row they asked for): it + takes `--overlay-selected` and lets it go over three `--dur-slow` periods + (1575ms) on the one curve. Add the class, remove it on `animationend`, which + fires under reduced motion too because the rest below is a hold, not + `animation: none`. */ +.br-highlight { + animation: br-highlight-wash calc(var(--dur-slow) * 3) var(--ease-out); +} + +@keyframes br-highlight-wash { + from { + background-color: var(--overlay-selected); } - 50% { - opacity: 0.55; - transform: scale(1.18); + to { + background-color: transparent; + } +} + +@keyframes br-highlight-hold { + from, + to { + background-color: var(--overlay-selected); + } +} + +/* Reduced motion: movement becomes a fade (the global reset above then + shortens it), and the highlight is SHOWN for its full period and then + removed with no transition. The hold is the one place this outranks the + global reset on purpose: at 0.01ms the wash would be lost, not shown, and a + hold moves nothing. */ +@media (prefers-reduced-motion: reduce) { + .br-enter { + animation-name: br-fade-in; + } + + .br-crossfade-item[data-state='open'] { + animation: br-fade-in var(--dur-fast-max) var(--ease-out); + } + + .br-crossfade-item[data-state='closed'] { + animation: br-fade-out var(--dur-fast) var(--ease-out); + } + + .br-highlight { + animation-name: br-highlight-hold; + animation-duration: calc(var(--dur-slow) * 3) !important; + } +} +/* @ws WS-TOKENS end: shared motion classes */ + +/* Subtle entrance for tool-call cards appearing mid-stream. Kept short + small + (frequency-of-use rule) and transform/opacity only. */ +@keyframes biorouter-tool-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); } } +/* @ws WS-TRANSCRIPT begin: working ring */ +/* `biorouter-working-ring` and `biorouter-working-glow` are gone with the ring + and glow they drew on the activity line and the loading line (spec 3.6): the + transcript's one "working" motion is the running row's text pulse beside the + shared spinner. */ +/* @ws WS-TRANSCRIPT end: working ring */ + /* `sidebar-item-in` and `wind` are gone with the staggered nav entrance and the FlyingBird decoration they drove (D-14). `.sidebar-item` itself — the inert `opacity: 1` left behind by D-14, plus the three `!important` pointer-events @@ -2137,10 +2436,81 @@ input[type='radio']:checked ~ [data-radio-ring] { align-items: center; justify-content: center; border-radius: var(--radius-sm); + /* @ws WS-MOTION begin: toast close motion */ transition: - opacity var(--motion-fast) var(--ease-out), - background-color var(--motion-fast) var(--ease-out); + opacity var(--dur-fast) var(--ease-out), + background-color var(--dur-fast) var(--ease-out); + /* @ws WS-MOTION end: toast close motion */ +} + +/* @ws WS-MOTION begin: toast motion */ +/* The toast's entrance and exit (spec 3.16), replacing react-toastify's + default Bounce, which flew in from 3000px with -25/+10/-5px overshoots over + 500ms. `App.tsx` hands these two classes to `cssTransition`: + - in: opacity and an 8px drop over `--dur-fast-max`; + - out: opacity and an 8px slide toward the corner over `--dur-fast`; + then the library collapses the slot over 125ms (`collapseDuration`). + Only opacity and transform move. + + ⚠ THE TRAVEL IS BOUNDED BY THE DRAG BAND. The layer sits at + `--toast-inset-top` (44px), so the entrance starts 8px above it, at 36px, + still under `--titlebar-drag-height` (32px)'s floor. Raise the travel or + lower the inset and a toast ENTERS the drag rect, whose clicks Electron + swallows by DOM order (issue #74): its × and "View details" would be dead for + as long as it animates. `styles/toastLayer.test.ts` holds the sum. + + Under reduced motion both are a fade. The exit must stay an ANIMATION there, + never `animation: none`: the library removes the toast on `animationend`, and + an exit that never ends leaves a toast that never goes. */ +.br-toast-in { + animation: br-toast-in var(--dur-fast-max) var(--ease-out) both; +} + +.br-toast-out { + animation: br-toast-out var(--dur-fast) var(--ease-out) both; +} + +@keyframes br-toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes br-toast-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateX(8px); + } +} + +@media (prefers-reduced-motion: reduce) { + .br-toast-in { + animation-name: br-fade-in; + } + + .br-toast-out { + animation-name: br-toast-fade-out; + } +} + +@keyframes br-toast-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } } +/* @ws WS-MOTION end: toast motion */ /* A toast stays clickable while a modal dialog is open. Radix's modal sets `pointer-events: none` on for as long as the dialog @@ -2157,11 +2527,89 @@ input[type='radio']:checked ~ [data-radio-ring] { pointer-events: auto; } +/* @ws WS-MOTION begin: toast clears the corner */ +/* A toast steps out of the way of what already sits in its corner (vision + V2-R7-4, checklist 15): below find in chat, left of the docked Chat summary + and of its popover. `components/alerts/toastClearance.ts` measures what is + really in the stack's way and writes how far to move on the container; + this turns it into a `translate`, so `top` stays `--toast-inset-top` and the + move is only ever down or left, never into the drag band (issue #74). + + It glides over `--dur-fast-max` only once the container has been placed + (`data-toast-placed`, set a frame after its first placement), so a toast + that arrives while find is open appears below the bar rather than sliding + there. It snaps while the window or the sidebar is being resized, and under + reduced motion. */ +.Toastify__toast-container { + translate: calc(-1 * var(--toast-clear-x, 0px)) var(--toast-clear-y, 0px); +} + +.Toastify__toast-container[data-toast-placed] { + transition: translate var(--dur-fast-max) var(--ease-out); +} + +body.biorouter-window-resizing .Toastify__toast-container, +body.biorouter-sidebar-resizing .Toastify__toast-container { + transition-duration: 0ms; +} + +@media (prefers-reduced-motion: reduce) { + .Toastify__toast-container[data-toast-placed] { + transition: none; + } +} +/* @ws WS-MOTION end: toast clears the corner */ + +/* @ws WS-MOTION begin: toast yields to an open menu */ +/* A menu or popover the person opened stands above a toast (vision V1-R7-1). + Menus sit at `--z-modal-dropdown` (500) and the toast layer at `--z-toast` + (600), so a toast that stays (the extension-load report is sticky) covered + the menus that open into the top-right corner: with it standing, the centre + of the tab strip's ▾ menu filter field, and a tab's "Open in new window" and + "Copy chat ID", hit-tested to the toast and no pointer could reach them. + While one is open the layer drops one step below the menus. It is still + above every dialog (`--z-modal`, 400), it does not move, and it is back on + top the moment the menu closes. The five `data-slot`s are the menu + primitives' own (`ui/dropdown-menu.tsx`, `ui/context-menu.tsx`, + `ui/popover.tsx`, and the shared `ui/Select.tsx`, which names its open + react-select menu as a Radix Select would), and `styles/toastLayer.test.ts` + holds both ends. Tooltips and info tips are left out on purpose: they open + on a hover, and nothing in them is waiting for a click. */ +body:has( + :is( + [data-slot='dropdown-menu-content'], + [data-slot='dropdown-menu-sub-content'], + [data-slot='context-menu-content'], + [data-slot='popover-content'], + [data-slot='select-content'] + )[data-state='open'] + ) + .Toastify__toast-container { + z-index: calc(var(--z-modal-dropdown) - 1); +} + +/* A Select that opens its menu INSIDE a dialog (no portal: the model switcher, + the lead and worker settings, a custom provider's form) is not reached by + the rule above. The dialog box is `fixed`, at `--z-modal` and transformed, so + it is a stacking context: the menu's own 500 counts only inside it, and the + whole dialog competes at 400 in the page, under the yielded toast (499). + While such a menu is open the toast steps behind the dialog instead, and + comes back on top the moment the menu closes (WS-PRIMITIVES-2). */ +body:has(.biorouter-modal-surface [data-slot='select-content'][data-state='open']) + .Toastify__toast-container { + z-index: calc(var(--z-modal) - 1); +} +/* @ws WS-MOTION end: toast yields to an open menu */ + +/* @ws WS-MOTION begin: toast close hover */ +/* The ghost hover, 5% ink: the toast is the floating recipe, and its dark ground + is lifted to `--background-medium`, so a hover in that token vanished there. */ .Toastify__toast-container .Toastify__close-button:hover { opacity: 1; color: var(--text-default); - background-color: var(--background-medium); + background-color: var(--overlay-hover); } +/* @ws WS-MOTION end: toast close hover */ /* Focus is drawn once, globally, in the @layer base block above. */ @@ -2173,17 +2621,28 @@ input[type='radio']:checked ~ [data-radio-ring] { color: var(--text-muted); } +/* @ws WS-TOKENS begin: fade-slide-up utility */ .animate-fade-slide-up { - animation: fade-slide-up var(--motion-base) var(--ease-out) forwards; + animation: fade-slide-up var(--dur-fast-max) var(--ease-out) forwards; } +/* @ws WS-TOKENS end: fade-slide-up utility */ -/* Tool-call cards otherwise pop into the transcript with zero motion when a tool - is invoked mid-stream. A short, small, opacity-forward entrance settles them in - without drawing attention (they appear very frequently). Reduced-motion is - handled globally by the prefers-reduced-motion reset above. */ +/* @ws WS-TOOLS begin: tool row entrance */ +/* A tool row that arrives while the person watches rises 4px and fades in over + `--dur-fast-max` (the same travel and duration as `.br-enter`; the keyframes + keep their name because Crew's timeline uses them too). A row that mounts + because of a history load, a tab switch or a reload appears at rest + (principle 9): the transcript marks such a message `data-motion-still`. + Reduced motion is handled by the global reset. */ .biorouter-message-tool { - animation: biorouter-tool-enter 160ms var(--ease-out) both; + animation: biorouter-tool-enter var(--dur-fast-max) var(--ease-out) both; +} + +[data-motion-still] .biorouter-message-tool, +.biorouter-message-tool[data-motion-still] { + animation: none; } +/* @ws WS-TOOLS end: tool row entrance */ .biorouter-modal-overlay { /* --scrim, not a literal. This used to be a hardcoded warm-brown rgba() with @@ -2193,6 +2652,21 @@ input[type='radio']:checked ~ [data-radio-ring] { backdrop-filter: blur(8px); } +/* @ws WS-PRIMITIVES begin: nested modal stacking */ +/* A dialog opened from a dialog dims the one beneath it (vision round 1, V4-08). The scrim used + to sit on `--z-overlay` (300) under every surface on `--z-modal` (400), so a nested dialog's + scrim went UNDER the first dialog: Settings > Chat's per-extension permissions dialog sat on an + undimmed tool list that ran on below its footer, and the two read as one broken dialog. On the + surfaces' own layer, Radix's portal order decides: a dialog's scrim comes before its surface, + and a dialog opened later is portalled after the one it was opened from, so each scrim covers + everything earlier and nothing later. Nothing else sits between 300 and 400, so no other layer + changes order. Unlayered, so it beats the `z-*` utility on the overlay. + `ui/dialogStacking.browser.test.tsx` hit-tests it in Chromium. */ +.biorouter-modal-overlay { + z-index: var(--z-modal); +} +/* @ws WS-PRIMITIVES end: nested modal stacking */ + .biorouter-modal-surface { background: var(--background-default); @@ -2209,17 +2683,107 @@ input[type='radio']:checked ~ [data-radio-ring] { border-radius: var(--radius-container); box-shadow: var(--shadow-modal); /* Structural safety floor: a modal surface must always stack ABOVE its scrim. - Radix's Dialog renders the overlay (--z-overlay) and the content as - SIBLINGS, so a content that keeps a stale/low z-index (as WorkflowWarningModal + Radix's Dialog renders the overlay and the content as SIBLINGS, the overlay + first, so a content that keeps a stale/low z-index (as WorkflowWarningModal once did with z-50) would sit UNDER the full-screen, pointer-eating overlay and soft-lock the entire app — it reads as "the whole app froze." This - unlayered rule beats Tailwind's z-* utilities, guaranteeing --z-modal - (> --z-overlay) for every surface so that class of bug cannot recur. It is + unlayered rule beats Tailwind's z-* utilities, guaranteeing --z-modal for + every surface; the overlay shares that layer (`nested modal stacking`, + V4-08) and comes first, so the surface paints over it. It is inert on the statically-positioned surfaces of the nested-overlay modals (z-index only affects positioned elements), so those are unaffected. */ z-index: var(--z-modal); } +/* @ws WS-PRIMITIVES begin: dialog motion */ +/* Dialog, sheet and scrim motion (spec 2.6, principle 9). The tw-animate utilities on each + element are the carriers; these unlayered rules set their duration, ease and scale. + - Scrim: fades in over `--dur-med-min`, out over `--dur-fast`. + - Dialog: opacity and scale(.98) to 1 over `--dur-med-min`; out to .98 over `--dur-fast`. + - Sheet: slides in over `--dur-med`, out over `--dur-fast`. + All `--ease-out`. The global reduced-motion reset stills every one of them. */ +.biorouter-modal-overlay, +.br-dialog-motion, +.br-sheet-motion { + --tw-ease: var(--ease-out); +} + +.biorouter-modal-overlay[data-state='open'] { + --tw-duration: var(--dur-med-min); +} + +.br-dialog-motion[data-state='open'] { + --tw-duration: var(--dur-med-min); + --tw-enter-scale: 0.98; +} + +.br-sheet-motion[data-state='open'] { + --tw-duration: var(--dur-med); +} + +.biorouter-modal-overlay[data-state='closed'], +.br-dialog-motion[data-state='closed'], +.br-sheet-motion[data-state='closed'] { + --tw-duration: var(--dur-fast); + animation-name: br-surface-out; +} + +.br-dialog-motion[data-state='closed'] { + --tw-exit-scale: 0.98; +} + +/* The surfaces' own keyframes (vision round 1, V1-L7). tw-animate's `enter` and `exit` always + write `filter: blur(var(--tw-enter-blur, 0))`, so every menu, popover, tooltip, dialog, sheet + and scrim animated `filter` alongside opacity and transform, even at a blur of 0: principle 9 + allows transform and opacity only. These read the same `--tw-*` values the utilities and the + rules here set, and draw nothing else. Unlayered, so `animation-name` here beats the + utility's `animation` shorthand while its duration, ease and fill still apply. The menu and + tooltip rules in the primitives block below name them too. */ +@keyframes br-surface-in { + from { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) + scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)); + } +} + +@keyframes br-surface-out { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) + scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)); + } +} + +.biorouter-modal-overlay[data-state='open'], +.br-dialog-motion[data-state='open'], +.br-sheet-motion[data-state='open'] { + animation-name: br-surface-in; +} + +/* ModalShell `xl`: 800px from the `sm` breakpoint, authored because the utility is new. */ +@media (min-width: 640px) { + .biorouter-modal-surface[data-size='xl'] { + max-inline-size: min(800px, calc(100% - 2rem)); + } +} +/* @ws WS-PRIMITIVES end: dialog motion */ + +/* @ws WS-PRIMITIVES begin: dialog box focus */ +/* A dialog opened by the POINTER (a click, a pick from a menu) or raised by the app puts focus on + its own box instead of its first button, unless that is a text field (`useReturnFocusToOpener`, + V4-04, V4-R5-1; the typed confirmation `DangerousConfirmDialog` does the same itself, V3-R6-2): + after such an open a script focus can match `:focus-visible`, so a Cancel button or a link wore + the inset ring after a mouse click. + The box is where a screen reader reads the title and the message; it is not a control, so it + draws no ring of its own, and the next Tab reaches the first control, which does. Forced colours + and `prefers-contrast: more` keep the ring through their own `[tabindex]` arm, which comes later + in the file. */ +.biorouter-modal-surface:focus { + outline: none; +} +/* @ws WS-PRIMITIVES end: dialog box focus */ + /* D-05 — modal header/footer separators are hairlines, not gradient washes. */ .biorouter-modal-surface > .border-b { border-bottom: 1px solid var(--border-subtle); @@ -2235,17 +2799,18 @@ input[type='radio']:checked ~ [data-radio-ring] { /* The inset white highlight had no dark override — it drew a glare line across the top of every dark panel (DR-12). Flat surfaces need no highlight at all. */ -.biorouter-modal-panel { - background: var(--background-muted); - border: 1px solid var(--border-subtle); - box-shadow: none; -} - +/* @ws WS-PRIMITIVES begin: dialog inner boxes */ +/* A dialog's inner box is a subtle fill, not an outline (R-27, design.md 9.11): `--background-fill` + (the value `--background-muted` had, in both modes) with the 1px border kept TRANSPARENT, so no + geometry moves and forced colours still draw the edge in the system ink. A control inside steps + back to the page ground (`.br-fill-surface`, the primitives block). */ +.biorouter-modal-panel, .biorouter-modal-row { - background: var(--background-muted); - border: 1px solid var(--border-subtle); + background: var(--background-fill); + border: 1px solid transparent; box-shadow: none; } +/* @ws WS-PRIMITIVES end: dialog inner boxes */ /* ============================================================ PROGRESS — ONE primitive (Astryx §10 "Progress"). @@ -2290,11 +2855,29 @@ input[type='radio']:checked ~ [data-radio-ring] { /* A 40%-wide sliver translated −100% → 250% of ITS OWN width, i.e. from fully off the left edge to fully off the right one. */ +/* @ws WS-PRIMITIVES begin: progress */ +/* The period is an ambient loop, so it is a multiple of `--dur-slow` (spec 2.4). */ .br-progress__fill--indeterminate { width: 40%; - animation: br-progress-sweep 1.5s var(--ease-out) infinite; + animation: br-progress-sweep calc(var(--dur-slow) * 3) var(--ease-out) infinite; } +/* `size="thin"`: a 4px track for a gauge that sits inside a row or a card. */ +.br-progress[data-size='thin'] { + height: 4px; +} + +/* The neutral track (every tone but heat): `--background-muted` on a page, which IS the rest fill, + so in a filled box, on a recessed ground or in a hovered row it read 1.00:1 and the bar floated + on nothing. It takes the track ground there, one step deeper (`--br-track-ground`, the grounds + note in the primitives block). Layered, so a caller's own `bg-*` still wins. */ +@layer components { + .br-progress-track { + background-color: var(--br-track-ground, var(--background-muted)); + } +} +/* @ws WS-PRIMITIVES end: progress */ + @keyframes br-progress-sweep { from { transform: translateX(-100%); @@ -2323,41 +2906,46 @@ input[type='radio']:checked ~ [data-radio-ring] { One component, three surfaces. After Safari. The strip is NOT a darker slab: it is painted `--sidebar`, the SAME - surface as the sidebar's titlebar band, so the whole 52px top edge of + surface as the sidebar's titlebar band, so the whole 44px top edge of the window is one continuous ground and no part of the chrome reads heavier than another. That rationale is about the TOP edge, and it inverts at the bottom one — see `.br-tabstrip--sm`, which is the terminal dock and grounds on the surface instead. - Only the ACTIVE tab is painted — a floating pill - in `--background-default`. Inactive tabs are plain text, divided by a - hairline that lives in the GAP (never a border on the tab) and retracts - around whatever the cursor is on. - No tab carries an outline: fills do the work, exactly as D-15 (focus is - a surface shift, not a ring) and D-17 (tool calls are lines, not cards) - already decided everywhere else. + FILLS, NOT LINES (R-27, design.md 9.11). A tab is a sidebar row laid on + its side, and it wears the row's ladder: an inactive tab is plain text on + the band, the pointer gives it the ghost hover (`--overlay-hover`, the 5% + ink every ghost control in the band hovers with), a press and the + selection are the solid `--background-fill-strong` (the sidebar's active + row has that value), and nothing at rest draws an outline. The chat strip + used to box every tab in a 1px `--border-subtle` and the other two divided + them with a hairline in the gap; both lines are gone, because the spacing, + the leading glyph and the hover fill already tell one tab from the next. + Every tab keeps a TRANSPARENT 1px border, never `border: 0`, so forced + colours draw each tab's edge in the system ink and the selected one in + Highlight (the accent strip is a gradient, which forced colours drop). D-07 chose an underline for horizontal tabs — that decision was about NAVIGATION tabs (settings sections). These are document tabs: objects - you switch between, as in Safari and VS Code. The pill is right here; - D-07's underline stands for nav. + you switch between, as in Safari and VS Code. The filled chip is right + here; D-07's underline stands for nav. - SANCTIONED DEPARTURE (#37): the active pill now ALSO carries a 2px + SANCTIONED DEPARTURE (#37): the active tab ALSO carries a 2px --accent-bar strip along its bottom edge — the same token as the sidebar rail and D-07's nav underline, so the "you are here" colour is - one vocabulary across every surface. The pill remains the primary - signal (fill + weight + shadow are unchanged); the strip is drawn as a - bottom-anchored BACKGROUND-IMAGE stripe because both pseudo-elements - are taken (::before is the sibling divider, ::after the drop-insertion - hairline) and an inset box-shadow follows the pill's border-radius — - the strip read as a curved smile hugging the corners, not a line. The - gradient stripe is dead straight, inset 11px each side (≥ the 8px - radius, and matching the tab's content padding) so the rounded corners - never clip it. The unfocused-group dim rule resets the `background` - shorthand, which wipes background-image — so the strip goes out there - for free: two lit strips would be two answers to "where am I typing?". - Applies to the small (--sm, terminal dock) strip too: one tab - language, both sizes. + one vocabulary across every surface. The fill remains the primary + signal (fill + weight); the strip is drawn as a bottom-anchored + BACKGROUND-IMAGE stripe because both pseudo-elements are taken + (::after is the drop-insertion hairline, and ::before was the old gap + divider) and an inset box-shadow follows the tab's border-radius — the + strip read as a curved smile hugging the corners, not a line. The + gradient stripe is dead straight, inset 11px each side (more than the + 8px radius, and matching the tab's content inset) so the rounded + corners never clip it. The unfocused-group dim rule resets the + `background` shorthand, which wipes background-image — so the strip + goes out there for free: two lit strips would be two answers to "where + am I typing?". Applies to the small (--sm, terminal dock) strip too: + one tab language, both sizes. ============================================================ */ .br-tabstrip { display: flex; @@ -2380,10 +2968,16 @@ input[type='radio']:checked ~ [data-radio-ring] { /* The preview panel nests its tablist inside the strip for a11y, so the scroll box is the tablist rather than `.br-tabstrip` itself. Same rule, same look: scroll past the floor, never wrap, and never show a scrollbar in a 52px bar. */ +/* @ws WS-MOTION begin: tab strip scroll padding */ +/* `scroll-padding-inline` keeps a tab that the panel scrolls into view 8px clear + of the strip's edges, so the active preview tab never touches the ▾ overflow + button (spec 3.16, requested by the preview panel). */ .br-tabstrip__scroll { flex-wrap: nowrap; scrollbar-width: none; + scroll-padding-inline: 8px; } +/* @ws WS-MOTION end: tab strip scroll padding */ .br-tabstrip__scroll::-webkit-scrollbar { display: none; @@ -2406,16 +3000,89 @@ input[type='radio']:checked ~ [data-radio-ring] { background: transparent; color: var(--text-muted); cursor: pointer; + /* @ws WS-MOTION begin: overflow button motion */ transition: - background-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out); + background-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); + /* @ws WS-MOTION end: overflow button motion */ +} + +/* @ws WS-MOTION begin: tab overflow menu filter */ +/* The ▾ menu's filter (spec 3.16, "searching tabs"): from eight tabs the menu + opens with this field focused. It is a 28px field on the menu's own ground, + pinned to the top of the menu while the rows under it scroll. Results replace + at once; only the "No tabs match" line fades in, over `--dur-fast-max`. */ +.br-tabstrip__menu-filter { + /* Pulled into the menu's 4px padding by the negative margin, and stuck flush + under the menu's top edge while rows scroll beneath it. Chromium measures a + sticky offset from the scroll box's CONTENT edge (inside its padding), so + the offset that keeps it flush is minus the padding, not 0 (measured: `0` + left it 4px low with rows showing above it). */ + position: sticky; + top: -4px; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + height: 28px; + margin: -4px -4px 4px; + /* 16px: pulled out to the menu's edge by the margin, so the glyph lands on the + 16px edge every row's glyph starts on (the menu's 4px plus the row's 12px) + and the typed text on the rows' label edge, as CommandInput's px-4 does. At + 12px both sat 4px left of the rows (measured in Chromium). */ + padding: 0 16px; + background: var(--background-default); + border-bottom: 1px solid var(--border-subtle); + border-radius: var(--radius-container) var(--radius-container) 0 0; + color: var(--text-muted); +} + +.br-tabstrip__menu-filter-icon { + flex: none; + width: var(--icon-row); + height: var(--icon-row); +} + +.br-tabstrip__menu-filter-field { + flex: 1 1 auto; + min-width: 0; + height: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--text-default); + font-family: var(--font-sans); + font-size: var(--text-secondary); + line-height: var(--text-secondary--line-height); +} + +.br-tabstrip__menu-filter-field::placeholder { + color: var(--text-muted); } +.br-tabstrip__menu-filter-field::-webkit-search-cancel-button { + display: none; +} + +.br-tabstrip__menu-empty { + margin: 0; + padding: 6px 12px 8px; + color: var(--text-muted); + font-size: var(--text-secondary); + line-height: var(--text-secondary--line-height); + animation: br-fade-in var(--dur-fast-max) var(--ease-out) both; +} +/* @ws WS-MOTION end: tab overflow menu filter */ + +/* @ws WS-MOTION begin: overflow button fill */ +/* Hover and open on the band's fill step (R-27), the step the band's other + pressed controls (summary, terminal) use. */ .br-tabstrip__overflow:hover, .br-tabstrip__overflow[data-state='open'] { - background: var(--background-medium); + background: var(--background-fill-strong); color: var(--text-default); } +/* @ws WS-MOTION end: overflow button fill */ /* The chat strip is threaded THROUGH BaseChat's existing 52px header (which already paints the ground and draws the bottom hairline) rather than mounted @@ -2476,61 +3143,17 @@ input[type='radio']:checked ~ [data-radio-ring] { simply turns the brand colour. No second edge, no geometry change, and it follows the family like every other accent in the app. + Both cards, the chat's (Home's too) and Crew's, rest on the page ground with + that 1px border a hairline (`border-border-subtle/60`; owner message 17, + R-32, which takes the card out of R-27's fill rule). Unlayered, so it beats + the card's border utility. + `:has(textarea:focus)`, not `:focus-within`: the composer contains buttons, and focusing Send is not "typing here". */ .biorouter-composer-card:has(textarea:focus) { border-color: var(--border-accent); } -/* ============================================================ - THE WORKING-DIRECTORY CHIP'S PATH, WHILE THE DIRECTORY IS STILL CHOOSABLE - - Two separate things live in one rule because they are one mechanism, and - splitting them is how the bug below shipped. - - 1. THE BOX IS RTL SO THE *HEAD* IS CLIPPED. `text-overflow: ellipsis` cuts - at the line's end edge, which in an RTL box is the LEFT — so a path too - long for the chip loses `/Users/wgu/` and keeps the final segments, which - are the informative end. Do not "fix" this to LTR: clipping a path from - the right hides the only part that identifies it. - - 2. THE TEXT IS AN LTR ISOLATE SO THE PATH IS THE PATH. `/` is bidi-neutral - (Unicode class CS), and so is `\`. In an RTL paragraph the Unicode - bidirectional algorithm resolves a neutral that sits at a paragraph - boundary to the PARAGRAPH direction — so the path's LEADING slash was - reordered to the visual right end and `/Users/wgu/Downloads` rendered as - `...wgu/Downloads/`, a path that does not exist, while the tooltip beside - it read correctly. A chip showing a wrong path is worse than one showing - a truncated right path. `direction: ltr` (not merely `unicode-bidi: - isolate`, which would inherit the RTL base and reorder exactly the same) - pins the path's STRUCTURE left-to-right; an RTL-script folder name inside - it still renders in its own direction, which is what a terminal does too. - - 3. THE CAP IS `ch`, AND THERE IS DELIBERATELY NO PERCENTAGE TERM. The chip - shares the context strip with the extension/skill/KB counts under - `justify-between`, and those counts must never be squeezed or pushed off. - A `min(46ch, 40%)` cap was considered for that job and declined twice - over: the counts group is already `flex-shrink-0` while every box on this - side (strip item -> button -> this div) carries `min-w-0`, so ALL - shrinkage lands here and the guarantee already holds without a - percentage; and the percentage could not do the job anyway, because this - div's containing block is a content-sized flex item with no definite - width, which is the case where a percentage max-width resolves against an - indefinite size and behaves as `none`. 46ch is generous for an ordinary - path (`/Users/wgu/Downloads` is 20 characters) and flex shrinks it - further in a narrow or split pane. The LOCKED chip is a different control - with a different job — a basename, capped at 112px in DirSwitcher.tsx — - and is deliberately not covered by this rule. */ -.biorouter-dir-chip-path { - direction: rtl; - max-width: 46ch; -} - -.biorouter-dir-chip-path > bdi { - direction: ltr; - unicode-bidi: isolate; -} - /* ============================================================ THE COMPOSER'S WORKING EDGE — "Biorouter has the floor" @@ -2669,7 +3292,10 @@ input[type='radio']:checked ~ [data-radio-ring] { position: relative; display: flex; align-items: center; - gap: 7px; + /* @ws WS-MOTION begin: tab gap */ + /* 8px, the one icon-label gap (spec 3.16; it was 7px). */ + gap: 8px; + /* @ws WS-MOTION end: tab gap */ /* ONE TAB, THREE STRIPS. The chat header, the artifact panel and the terminal dock all draw this same object at the same size, and `--tab-height` is what guarantees it — it was authored for exactly this ("chat, artifact and dock @@ -2699,14 +3325,20 @@ input[type='radio']:checked ~ [data-radio-ring] { flex: 0 1 auto; min-width: var(--tab-min-width); max-width: 190px; - padding: 0 11px; - border: 0; + /* @ws WS-MOTION begin: tab edge and radius */ + /* A transparent 1px edge, never `border: 0` (R-27): no tab draws a line at + rest, forced colours draw every tab's edge, and the content still starts + 11px in (10px of padding plus the edge), where the accent strip starts. */ + padding: 0 10px; + border: 1px solid transparent; /* Four corners, not two. The top-only radius was the other half of the band-filling shape: it made sense when the tab's bottom edge WAS the band's hairline and its lower corners were never visible. A centred chip has all - four corners on show, so squaring two of them just looks broken. 4px keeps - the square-ish register. */ - border-radius: var(--radius-inner); + four corners on show. 8px, the element step every row and menu item uses: + the selected tab now wears the sidebar's selected-row fill, so it wears the + row's corner too (it was 4px, the inner step, while it was an outline). */ + border-radius: var(--radius-element); + /* @ws WS-MOTION end: tab edge and radius */ background: transparent; color: var(--text-muted); /* D-31: sans, not mono. These labels shipped in --font-mono, borrowed from @@ -2722,11 +3354,13 @@ input[type='radio']:checked ~ [data-radio-ring] { name. Mono for data, sans for chrome. 13px = §3.2 "Secondary / metadata"; the -0.01em tracking was a mono-only correction and goes with it. */ font-family: var(--font-sans); - font-size: 13px; + /* @ws WS-MOTION begin: tab type and motion */ + font-size: var(--text-secondary); cursor: pointer; transition: - background-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out); + background-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); + /* @ws WS-MOTION end: tab type and motion */ } /* `--sm` NO LONGER RESIZES THE TAB. It used to drop the dock's tabs to 28px on @@ -2740,30 +3374,53 @@ input[type='radio']:checked ~ [data-radio-ring] { want — but if it comes back, it comes back as a token, not as a literal hidden in a descendant selector. */ -/* THE THREE STATES ARE ONE LADDER, and they are all steps of the SAME move: - the tab rising off the band toward the content's own surface. Resting it is - transparent (the band shows through), hovered it is most of the way to - `--background-default`, pressed it is nearly there, and selected it IS - `--background-default` — the same fill as the conversation below, which is - what makes a selected tab read as attached to its content rather than as a - highlighted item in a list. - - Expressing all three as percentages of one destination colour is why they - cannot drift apart: change the destination and the whole ladder follows. The - earlier version had a hover value and nothing else, so "pressed" was - indistinguishable from "hovered" and the control gave no feedback on click. */ +/* @ws WS-MOTION begin: tab fill ladder */ +/* THE STATES ARE ONE LADDER, the sidebar row's (R-27). Resting, a tab is + transparent: the band shows through and the label alone is the tab. Hovered + it takes `--overlay-hover`, the ghost hover of every icon button in the same + band, so it reads on the `--sidebar` band and on the dock's surface alike. + Pressed it takes the selected fill, so a press previews what a release + selects, and selected it IS `--background-fill-strong`, the value of the + sidebar's selected row (light and dark), in all three strips. + + It used to rise toward `--background-default` by percentages (62, 84, 100). + In dark that destination is #1b1b19 on a #171716 band, so the hover and the + press measured 1.02:1 and could not be seen, and the selected tab was told + apart by its strip and weight alone. The fill step is visible in both modes + on both grounds. + + Pressed must sit BELOW the active rule in source order so that pressing an + already-selected tab does not change it: `:active` and `[data-active]` have + the same specificity (0,2,0), so order is the whole separation, exactly as + with the light/dark theme blocks. */ .br-tab:hover { - background: color-mix(in srgb, var(--background-default) 62%, transparent); + background-color: var(--overlay-hover); color: var(--text-default); } -/* Pressed: one rung past hover, and it must sit BELOW the active rule in source - order so that pressing an already-selected tab does not dim it — `:active` and - `[data-active]` have the same specificity (0,2,0), so order is the whole - separation, exactly as with the light/dark theme blocks. */ .br-tab:active { - background: color-mix(in srgb, var(--background-default) 84%, transparent); + background-color: var(--background-fill-strong); +} +/* @ws WS-MOTION end: tab fill ladder */ + +/* @ws WS-MOTION begin: tab focus ring */ +/* Keyboard focus rings the whole tab, 2px inside its 8px corner. The focusable + is the label button INSIDE the pill: it starts after the tab's padding and + stops before its close control, so the base rule's inset outline on it ran + through the chat glyph and the ellipsis (measured in Chromium). The ring + moves to the pill and the button draws none. It sits 4px in, not 2: the + selected tab's accent strip is the bottom 2px of the pill, and a ring on the + pill's edge covered it. Never after a mouse click: the ring hangs on + `:focus-visible`, the browser's keyboard verdict. */ +.br-tab:has(> [role='tab']:focus-visible) { + outline: 2px solid var(--border-focus); + outline-offset: -4px; +} + +.br-tab > [role='tab']:focus-visible { + outline: none; } +/* @ws WS-MOTION end: tab focus ring */ .br-tab[data-active='true'] { /* Longhand color + image, NOT the `background` shorthand: the stripe is the @@ -2773,17 +3430,27 @@ input[type='radio']:checked ~ [data-radio-ring] { 11px side insets keep it clear of the 8px corner radius and align it with the tab's content padding. (A top-edge variant was tried and reverted: it read as a lid on the tab rather than a marker under it.) */ - background-color: var(--background-default); + /* @ws WS-MOTION begin: selected tab fill */ + /* The selected fill (R-27); the strip is placed against the BORDER box, so + the transparent edge does not lift it 1px off the tab's bottom or narrow it + by 2px. */ + background-color: var(--background-fill-strong); background-image: linear-gradient(var(--accent-bar), var(--accent-bar)); background-repeat: no-repeat; + background-origin: border-box; background-size: calc(100% - 22px) 2px; background-position: 50% 100%; + /* @ws WS-MOTION end: selected tab fill */ color: var(--text-default); - font-weight: 500; - /* The drop shadow is the original pill lift — now the system's micro - elevation tier (A-07) rather than a literal, so a family can re-point it - instead of inheriting Parchment's warm ink. Same value, one name. */ - box-shadow: var(--shadow-raised); + /* @ws WS-MOTION begin: active tab is flat and bold */ + /* ONE active weight, 600 (Arial draws it bold): the 500/600 split between + the shared rule and the chat strip's override read as one weight anyway, + because Arial renders 500 as 400. And FLAT: the fill and the 2px accent + underline mark the active tab; the `--shadow-raised` lift is gone (spec + 3.16, shared with the preview strip). Borders before shadows. */ + font-weight: 600; + box-shadow: none; + /* @ws WS-MOTION end: active tab is flat and bold */ /* Selection settles with a small spring (#37). The keyframes animate the STANDALONE `scale` property, never `transform`: when closing the active tab, the successor gains data-active (starting this animation) in the same @@ -2798,7 +3465,9 @@ input[type='radio']:checked ~ [data-radio-ring] { and scale compose cleanly: the slide stays exact while the tab scales. Re-runs whenever data-active lands on a tab; suppressed under prefers-reduced-motion below. */ - animation: br-tab-select var(--motion-base) var(--ease-spring); + /* @ws WS-MOTION begin: tab select motion */ + animation: br-tab-select var(--dur-fast-max) var(--ease-spring); + /* @ws WS-MOTION end: tab select motion */ } /* There is no `.dark .br-tab[data-active]` box-shadow override any more: it @@ -2817,11 +3486,57 @@ input[type='radio']:checked ~ [data-radio-ring] { } } -/* Active group. Only the FOCUSED group's active tab is the painted pill; an - unfocused group's active tab keeps a neutral fill and drops the pill shadow. - Spec: "Focus costs zero extra chrome" — the unfocused group is not decorated - with a marker, it simply stops claiming the emphasis. Two lit pills in one - window would be two answers to "where am I typing?". +/* @ws WS-MOTION begin: tab entrance */ +/* A tab that OPENS grows in from its left edge (spec 3.16): opacity 0 and the + individual `scale` 0.96 to 1 over `--dur-fast-max` on `--ease-out`, not the + spring (the spring belongs to the select settle and the slides). The + individual `scale`, never `transform`, for the reason the select rule above + gives: the FLIP slide rides `translate`, and the two compose cleanly. + + `ChatTabStrip` sets `data-tab-entering` on a tab that was not in the strip a + pass ago, and never on the tabs a strip MOUNTS with (a reload restoring + them, a window opening with them, a tear-off): mounting is not opening. It is + later in the cascade than the active rule with the same specificity, so + enter wins over select on the new tab, which is usually the active one. When + the entrance ends the attribute goes, and an ACTIVE tab takes + `data-tab-entered` in the same tick (the rule after this one), so its + `animation` computes `none` and never changes to `br-tab-select`, which would + settle it a second time. */ +.br-tab[data-tab-entering='true'] { + transform-origin: left center; + /* `backwards`, not `both`: a finished entrance must hold nothing, or its + `opacity: 1` would outrank the dragged tab's 35%. */ + animation: br-tab-enter var(--dur-fast-max) var(--ease-out) backwards; +} + +/* An active tab whose entrance has played. No animation at all, rather than the + select settle (it already grew in) and rather than a finished `br-tab-enter`: + React reorders keyed tabs with `insertBefore`, and re-inserting an element + restarts its CSS animation, so a tab that kept the entrance would fade out and + in again whenever a drag moved it. `ChatTabStrip` clears the attribute when the + tab stops being active, and selecting it again settles as usual. (0,3,0), so it + beats the active rule whatever the source order. */ +.br-tab[data-active='true'][data-tab-entered='true'] { + animation: none; +} + +@keyframes br-tab-enter { + from { + opacity: 0; + scale: 0.96; + } + to { + opacity: 1; + scale: 1; + } +} +/* @ws WS-MOTION end: tab entrance */ + +/* Active group. Only the FOCUSED group's active tab is the painted chip; an + unfocused group's active tab keeps a quiet fill and drops the strip and the + weight. Spec: "Focus costs zero extra chrome" — the unfocused group is not + decorated with a marker, it simply stops claiming the emphasis. Two lit + chips in one window would be two answers to "where am I typing?". The attribute is only ever 'false' on a strip that knows it is unfocused; default (absent) reads as active, so a bare strip — and every strip in the @@ -2830,38 +3545,24 @@ input[type='radio']:checked ~ [data-radio-ring] { The `background` SHORTHAND here is load-bearing: it resets background-image, which is what removes the active tab's accent stripe on an unfocused group. Do not narrow it to background-color. */ +/* @ws WS-MOTION begin: unfocused group tab fill */ +/* At rest it is the hover step, the quiet fill (R-27); under the pointer it + takes the selected fill, so pointing at it previews the group taking focus. */ .br-tabstrip[data-group-active='false'] .br-tab[data-active='true'] { - background: color-mix(in srgb, var(--background-default) 45%, transparent); + background: var(--overlay-hover); color: var(--text-muted); font-weight: 400; box-shadow: none; } -.dark .br-tabstrip[data-group-active='false'] .br-tab[data-active='true'] { - box-shadow: none; -} - -/* Safari's divider: a hairline in the gap, not a border on the tab. */ -.br-tab + .br-tab::before { - content: ''; - position: absolute; - left: -2px; - top: 9px; - bottom: 9px; - width: 1px; - background: var(--border-strong); - opacity: 0.5; - transition: opacity var(--motion-fast) var(--ease-out); +.br-tabstrip[data-group-active='false'] .br-tab[data-active='true']:hover { + background: var(--background-fill-strong); } +/* @ws WS-MOTION end: unfocused group tab fill */ -/* …and it retracts around whatever you are pointing at, so the thing under - the cursor is never boxed in. */ -.br-tab:hover::before, -.br-tab[data-active='true']::before, -.br-tab:hover + .br-tab::before, -.br-tab[data-active='true'] + .br-tab::before { - opacity: 0; -} +/* No divider between tabs (R-27): the hairline Safari hangs in the gap is gone + with the outlines. The spacing, the leading glyph and the hover fill tell one + tab from the next, as they tell sidebar rows apart. */ .br-tab__label { min-width: 0; @@ -2880,14 +3581,14 @@ input[type='radio']:checked ~ [data-radio-ring] { which the artifact panel has been writing into the DOM with NO CSS reading them — its drag visuals were Tailwind at the call site. - The source tab STAYS IN FLOW at 35%. That much is a CSS contract, not a taste - call: `.br-tab`'s divider is adjacent-sibling based (`.br-tab + .br-tab::before`), - so pulling the real tab out of the DOM re-flows every divider mid-drag. + The source tab STAYS IN FLOW at 35%, so nothing in the strip reflows while + it is carried (the gap divider that first made this a CSS contract is gone + with R-27; the no-reflow reason stands on its own). The lifted tab under the cursor is therefore a SEPARATE element (.br-tab-ghost, below) rather than the tab itself moving — which is what lets the spec's "the tab lifts under the cursor at 2deg… the one it left dims to 35%" (card ∇) and - the divider contract both hold at once. An earlier revision of this comment + the in-flow source both hold at once. An earlier revision of this comment said there was no ghost at all; that was the reorder-only scope, where the tab never leaves its own strip and a ghost buys nothing. Once a tab can be carried to another group, dimming-in-place alone leaves the user dragging an invisible @@ -2922,13 +3623,17 @@ input[type='radio']:checked ~ [data-radio-ring] { /* Pick-up spring (#37): mount-only, transform-only (rotate/scale — NEVER left/top, which are set per-pointermove in JS and must not lag the cursor). The ghost springs from flat to its 2deg lift as it comes up. */ - animation: br-tab-ghost-lift var(--motion-base) var(--ease-spring); + /* @ws WS-MOTION begin: tab ghost motion */ + animation: br-tab-ghost-lift var(--dur-fast-max) var(--ease-spring); + /* @ws WS-MOTION end: tab ghost motion */ /* Detach is a state change on a MOUNTED element, so it is a transition, not an animation — the lift above is mount-only and would re-run. Same prohibition as the lift: never `left`/`top`. */ + /* @ws WS-MOTION begin: tab ghost detach motion */ transition: - transform var(--motion-fast) var(--ease-spring), - outline-color var(--motion-fast) var(--ease-out); + transform var(--dur-fast) var(--ease-spring), + outline-color var(--dur-fast) var(--ease-out); + /* @ws WS-MOTION end: tab ghost detach motion */ } /* Outside the window, where the drop would make a new one (tear-off D7). @@ -2972,7 +3677,11 @@ input[type='radio']:checked ~ [data-radio-ring] { height: 7px; border-radius: 50%; background: var(--text-accent); - animation: br-tab-pulse 1.8s var(--ease-out) infinite; + /* @ws WS-MOTION begin: running dot loop */ + /* An ambient loop runs on a multiple of the slow tier (spec 2.4), the same + period as the tool row's pulse. */ + animation: br-tab-pulse calc(var(--dur-slow) * 4) var(--ease-out) infinite; + /* @ws WS-MOTION end: running dot loop */ } /* Private: a still dot beside the tab's leading glyph (issue #56, R10). @@ -2989,6 +3698,7 @@ input[type='radio']:checked ~ [data-radio-ring] { animation: none; } +/* @ws WS-ICONS begin: chat kind glyph and lock badge */ /* A chat glyph whose privacy tier no source has read yet (`ChatKindIcon`, issue #56). It keeps the kind's unmarked shape — a padlock would be a claim — and is dimmed so it does not read as the settled Public glyph either: the @@ -3004,6 +3714,11 @@ input[type='radio']:checked ~ [data-radio-ring] { `ChatKindIcon.test.tsx` asserts the rule at the source, because jsdom never loads this file. */ .br-chat-kind-icon { + /* One slot for every chat glyph (spec 3.3): a row, a tab, a menu item and a + History line all draw it at --icon-row, whatever size class the caller + passes, so it never sits a size below the nav icons beside it. */ + width: var(--icon-row); + height: var(--icon-row); transition: opacity var(--dur-fast) var(--ease-out); } @@ -3011,6 +3726,51 @@ input[type='radio']:checked ~ [data-radio-ring] { opacity: 0.4; } +/* The lock badge on a private chat glyph (`withPrivateBadge` in + icons/app-icons.tsx). Privacy is a SHAPE on every chat kind; the body takes + the row's ink (muted at rest, default on the active row) and only the badge + wears the family accent, on every row including the active one. That keeps + a spot of Biorouter's colour on private rows without turning an all-private + list into a wall of coral glyphs. Authored and unlayered for the same reason + as the rule above. `ChatKindIcon.test.tsx` asserts it at the source. */ +.br-icon-lock-badge { + color: var(--text-accent); +} + +/* The active document tab is the strip's "active row": its chat glyph takes + the tab's own ink, exactly as the active sidebar row's glyph takes default + ink, instead of staying muted beside a default-ink title. `inherit` rather + than `--text-default` because the tab already decides its ink: default when + active, muted again on an unfocused group's selected tab (`data-group-active` + in the tab family above), and the glyph must follow it in both. The badge + keeps the accent (its own rule above wins on the badge ``). Authored and + unlayered, like the rules above, so it outranks the glyph's `text-text-muted` + utility without the tab strip passing `isActive`. */ +.br-tab[data-active='true'] .br-chat-kind-icon { + color: inherit; +} +/* @ws WS-ICONS end: chat kind glyph and lock badge */ + +/* @ws WS-ICONS begin: wordmark forced colours */ +/* THE WORDMARK IS ONE INK IN FORCED COLOURS (principle 12, vision round 2, V4-R2-7). + `BioRouterWordmark` draws its two words and the two halves of the bar with SVG `fill` + attributes, and Chromium does not force a `fill` it was given as a colour: under + `forced-colors: active` on a black canvas the navy "Bio" and its half of the bar kept + their authored ink (about 1.3:1) and nearly vanished, with the coral beside them + unchanged. `CanvasText` is the one colour the person's own palette guarantees against + their canvas, so the whole lockup takes it; a logo may be a single ink, and two brand + colours cannot be promised legible on a ground the person chose. Every part carries + `.br-wordmark-ink` (a presentation attribute has no specificity, so this beats it, and + the lockup's inks and its light/dark flip are untouched outside the media query). + Authored here, never a Tailwind class at the call site, for the reason in the rules + above. `BioRouterWordmark.browser.test.tsx` measures it in a real engine. */ +@media (forced-colors: active) { + .br-wordmark-ink { + fill: CanvasText; + } +} +/* @ws WS-ICONS end: wordmark forced colours */ + @keyframes br-tab-pulse { 0%, 100% { @@ -3037,7 +3797,32 @@ input[type='radio']:checked ~ [data-radio-ring] { .br-tab-ghost { transition: none; } + + /* @ws WS-MOTION begin: tab entrance rest */ + /* Reduced motion: the new tab fades in, it does not grow. */ + .br-tab[data-tab-entering='true'] { + animation: br-fade-in var(--dur-fast) var(--ease-out) backwards; + } + /* @ws WS-MOTION end: tab entrance rest */ +} + +/* @ws WS-MOTION begin: tab forced colours */ +/* Forced colours repaint a tab's fill as Canvas and drop the accent strip (a + gradient), so the selected tab would be told apart by its weight alone. Its + transparent edge, which the system draws in its ink on every tab, is drawn in + Highlight on the selected one. A system colour written by the author is kept + in forced colours, so no `forced-color-adjust` is needed. */ +@media (forced-colors: active) { + .br-tab[data-active='true'] { + border-color: Highlight; + } + + /* The pill's keyboard ring, in the colour every other focus ring takes there. */ + .br-tab:has(> [role='tab']:focus-visible) { + outline-color: Highlight; + } } +/* @ws WS-MOTION end: tab forced colours */ /* ============================================================ CHAT GROUPS · the split @@ -3082,7 +3867,9 @@ input[type='radio']:checked ~ [data-radio-ring] { flex: 0 0 1px; background: var(--border-subtle); cursor: row-resize; - transition: background-color var(--motion-fast) var(--ease-out); + /* @ws WS-MOTION begin: splitter motion */ + transition: background-color var(--dur-fast) var(--ease-out); + /* @ws WS-MOTION end: splitter motion */ } .br-group-splitter--row { @@ -3126,6 +3913,52 @@ input[type='radio']:checked ~ [data-radio-ring] { box-shadow: var(--shadow-popover); } +/* @ws WS-PRIMITIVES begin: popover elevation */ +/* Dark: a floating surface steps UP one ground and takes the stronger edge (vision round 1, + V1-M5; checklist 13). The neutrals step up from the page in dark, and the shadow is black on + near-black, so a menu on `--background-default` (#1b1b19) over the sidebar (#171716) or a card + (#1b1b19) was separated by its 1px `--border-subtle` alone. `--background-medium` is the step + (1.28:1 on the sidebar, 1.23:1 on a card) and `--border-strong` the edge (1.65:1 and 1.59:1), + the raised-thumb rule of the segmented control. The token is re-pointed INSIDE the surface, so + a field or list in a popover that paints `--background-default` rides the same ground instead + of reading as a dark well. `ui/popoverSurface.test.ts` measures every family. + The hairlines INSIDE (a menu's separator, a command palette's input rule) step up with the ground: + `--border-subtle` (#302f2c) measured 1.29:1 on the old #1b1b19 ground and 1.05:1 on the lifted one, + so a separator vanished (polish round, R-27). `--border-strong` on the lifted ground is the same + 1.29:1 step it had, and light's 1.26:1. `--border-default` aliases `--border-subtle` at the root, + which an inherited value does not follow, so it is re-pointed too. */ +.dark .biorouter-popover-surface { + --background-default: var(--background-medium); + --border-subtle: var(--border-strong); + --border-default: var(--border-strong); + border-color: var(--border-strong); + background-color: var(--background-default); +} +/* @ws WS-PRIMITIVES end: popover elevation */ + +/* @ws WS-TOKENS begin: popover fill */ +/* A field, chip or track inside a dark popover sits on a LIFTED ground (the rule above re-points + `--background-default` to `--background-medium`, #2c2c29), and the dark `--background-fill-strong` + IS that value: a hovered field would have vanished into the popover. The hover step goes DEEPER + instead, to the page's own #1b1b19, so rest (#232320, 1.13:1 under the ground) and hover read as a + well that deepens, 1.10:1 apart, 1.23:1 under the ground. The step does NOT go lighter, though + that is the page's direction in dark (and the first thing proposed): the placeholder ink + (`--text-subtle`) is 4.15, 4.44 and 4.50:1 on the smallest useful lift (#333330) and 3.74 to 4.06 + on `--background-strong`, below the 4.5:1 a field owes it, while on the well it only gains. + The off Switch's track sinks to the same well (WS-PICKERS-2). The page's #33332f is a raised + step on #1b1b19, but on the lifted ground it measured 1.10:1, and on a highlighted row (the + `--overlay-hover` wash, which a command list opens with) 1.05:1: a bare knob. At #1b1b19 it is + 1.23:1 under the ground and 1.42:1 under the highlighted row, nearest light's 1.19:1 in + lightness (dL* 8.2 against light's 7.0; `--background-fill` gave 4.3), and still 1.09:1 off a + field's fill. The knob ring only gains on a deeper track (4.31:1). The Effort slider's rail + paints the same token. `check-contrast.mjs` measures this scope in all three families (the + "dark popover" rows). */ +.dark .biorouter-popover-surface { + --background-fill-strong: var(--color-neutral-900); + --control-track-off: var(--color-neutral-900); +} +/* @ws WS-TOKENS end: popover fill */ + /* D-05 / P1 — a flat header separated by a hairline. The gradient wash and the chrome shadow that used to replace the border are gone. */ .biorouter-page-header { @@ -3134,6 +3967,172 @@ input[type='radio']:checked ~ [data-radio-ring] { box-shadow: none; } +/* @ws WS-VIEWS-A begin: page band */ +/* THE PAGE BAND (`Layout/PageHeader.tsx`, spec 3.10). Crew's channel header + geometry for every view: 44px (`--chrome-height`, one of the bands that meet + at y=44 with the sidebar's titlebar band and the chat header), the sidebar + ground, the hairline above, a 14px title, actions at the trailing edge. + + ⚠ THE HEADER DECLARES NO APP-REGION; ITS BAR DOES. The band replaces the + 32px drag strip on its route (`routeOwnsTopBand`), so the bar is the window + drag rect. Electron folds drag rects in TREE order and ignores z-index, and + the floating titlebar controls are mounted EARLIER than the route, so a + drag rect that reached x` (the grounds + note, "THE GROUNDS"), so this block holds no ground of its own. The band is the sidebar's own + paint, and the rest fill (`--background-fill`) is 1.03:1 off it in light, so a filter field + resting there was a white-on-white box nobody could find. A control on the band rests one step + deeper, on the strong fill (1.12:1 off the band in light, 1.28:1 in dark; `--text-subtle` on it + is 4.58:1 or better in all three families and both modes, measured by + `Layout/viewsFill.browser.test.tsx`), so the field reads as a quiet well. There is no deeper + neutral that still clears 4.5:1 for the placeholder, so a field does not step further under the + pointer: its hover equals its rest, and the focus edge is what answers a click. A filled BUTTON + has no placeholder to protect, so it hovers one fill deeper (`--background-strong`), which the + same class carries as `--br-control-hover`. Every band's controls, whoever owns the view, rest + this way. (Until the class was adopted the band set the ground by hand and re-stated the button + hover in an unlayered `.br-fill-hover:hover` rule of its own, a second copy of what the class + does; WS-PRIMITIVES-6.) */ + +.biorouter-page-header-bar { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: center; + gap: 8px; + padding: 0 12px 0 16px; + -webkit-app-region: drag; +} + +[data-slot='sidebar'][data-state='collapsed'] + ~ [data-slot='sidebar-inset'] + .biorouter-page-header[data-band] + .biorouter-page-header-bar { + margin-left: var(--biorouter-titlebar-control-reserve, 172px); + padding-left: 0; +} + +.biorouter-page-header-bar + :is( + button, + a[href], + input, + select, + textarea, + [role='button'], + [role='tab'], + [role='switch'], + [role='combobox'], + [tabindex]:not([tabindex='-1']) + ) { + -webkit-app-region: no-drag; +} + +/* The title and its help: the InfoTip sits 4px after the label. The title + gives way first (`min-width: 0` here and `truncate` on the h1). */ +.biorouter-page-header-title { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; +} + +.biorouter-page-header-adornment { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; +} + +/* TABS FILL THE BAND, SO THE LABEL IS ON THE TITLE'S LINE AND THE UNDERLINE ON THE HAIRLINE + (vision V3-R5-2). The wrapper stretches to the bar (43px: the 44px band less its 1px + hairline), the list stretches to the wrapper, and each trigger stretches to the list. The + trigger's own 36px height (`h-control-lg`, a utility in `@layer utilities`) used to be + bottom-aligned here instead, which put its text at y=25 against the title's centre at 21.5: + 3.5px low, so "Knowledge / Sources Graph" read as two lines that had slipped. A trigger that + fills the bar centres its label (`inline-flex items-center`) on the bar's own centre line, the + one the h1 is centred on, and the underline, which hangs from the list's bottom edge (the + Tabs indicator sits 1px below it, as the trigger's `::after` does before the indicator is + placed), has not moved: it still covers the hairline row. Unlayered, so `height: auto` beats + the layered utility whatever the specificity. Settings and the provider catalog drew the same + fix per view + before the shared rule had it (`settings.css`, `providers.css`); theirs now say the same thing + twice. A list the view wraps in its own host element (the catalog's) is not a direct child and + keeps its own rule. Guarded in Chromium by `Layout/pageBandGeometry.browser.test.tsx` (the + Tabs primitive, indicator placed) and `knowledge/knowledgeBand.browser.test.tsx`. */ +.biorouter-page-header-tabs { + display: flex; + align-self: stretch; + min-width: 0; + align-items: stretch; +} + +.biorouter-page-header-tabs > [role='tablist'] > [role='tab'] { + align-self: stretch; + height: auto; +} + +.biorouter-page-header-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* The deprecated `children` row under the band (Chat history's filter, until + its owner moves it into the band). */ +.biorouter-page-subband { + flex-shrink: 0; +} + +/* The main panel's top reserve (`Layout/MainPanelLayout.tsx`). A route that + keeps the 32px drag strip keeps a reserve the strip's height; a panel that + holds a page band does not, because the band starts at y=0 and IS the drag + rect there (`routeOwnsTopBand`). `:has()` lets a view that has moved onto the + band drop the reserve before its owner passes `removeTopPadding`. */ +.biorouter-main-panel[data-top-reserve] { + padding-top: var(--titlebar-drag-height); +} + +.biorouter-main-panel[data-top-reserve]:has(.biorouter-page-header[data-band]) { + padding-top: 0; +} +/* @ws WS-VIEWS-A end: page band */ + +/* @ws WS-VIEWS-A begin: busy action */ +/* AN ACTION WHOSE REQUEST IS OUT (`PageHeaderAction busy`, and the primary `IconAction` of a + Scheduler or Built apps row). It dims and shows the progress cursor, and it STAYS ENABLED: + `disabled` would dim it the same way, but Chromium hands focus to the moment a focused + button turns `disabled`, so a keyboard user who pressed Pause, Resume, Stop, Refresh or Launch + lost their place after every press (vision round 2, V3-R2-1). The press is ignored in the + component, not by the browser. Authored here, never a Tailwind `opacity-50` at the call site, + because a newly written utility can silently fail to generate under BIOROUTER_NO_HMR. The + button's own `transition-[opacity]` eases the dim. */ +.br-action-busy { + opacity: 0.5; + cursor: progress; +} +/* @ws WS-VIEWS-A end: busy action */ + .biorouter-page-block { background: var(--background-default); border: 1px solid var(--border-subtle); @@ -3146,25 +4145,142 @@ input[type='radio']:checked ~ [data-radio-ring] { flex-direction: column; } -.biorouter-list-row { - background: transparent; - border-bottom: 1px solid var(--border-subtle); +/* @ws WS-PRIMITIVES begin: rows */ +/* The content row and the settings row share ONE recipe (spec 2.6, Crew's + `dialogs/dialogs.css` Q4-25): 40px minimum, radius 8, a hover of `--overlay-hover` over + `--dur-fast-min`, and a STRAIGHT hairline. A bottom border on a rounded box curls up at + both ends (plainly so in forced colours, which paint every border in the text colour), so the + border stays as 1px of transparent room (no row moves) and the divider is a line drawn under + the row, inset by the corner radius so it ends where the hover wash starts to curve. It is a + border, not a background, so forced colours still draw it. The last row of a list has none, + and a row holding focus firms its line. + + Position and the list row's 8px 12px padding are defaults a caller's utility may override, + hence the layer; the rest is unlayered so it holds. */ +@layer components { + .biorouter-list-row, + .biorouter-settings-row { + position: relative; + } + + .biorouter-list-row { + padding: 8px 12px; + } +} + +.biorouter-list-row, +.biorouter-settings-row { + border-bottom: 1px solid transparent; border-radius: var(--radius-md); min-height: var(--row-height); /* D-12·B — 40px, one rhythm, no compact mode */ box-shadow: none; + /* `--tint-ink` is in the list for the reason the note above `tint-interactive` (end of this + file) records: Chrome does not interpolate `background-image`, so a selection wash composed + with `tint-selected` is carried by that registered custom property instead. A row whose own + transition list omits it REPLACES the utility's fallback and gets a selection that snaps + beside a hover that eases. */ transition: - background-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out); + background-color var(--dur-fast-min) var(--ease-out), + color var(--dur-fast-min) var(--ease-out), + --tint-ink var(--dur-fast-min) var(--ease-out); +} + +.biorouter-list-row { + background: transparent; +} + +.biorouter-list-row::after, +.biorouter-settings-row::after { + content: ''; + position: absolute; + inset-inline: var(--radius-md); + bottom: -1px; + height: 1px; + border-top: 1px solid var(--border-subtle); + pointer-events: none; +} + +/* The focus half is keyed on `:focus-visible`, never `:focus-within` (spec 2.5; QA, 2026-10-10). + `:focus-within` is true for a control the person has just CLICKED, so a mouse click on a + switch or button inside a row left the row washed and its line firmed until focus moved away, + the same residue as the disclosure and summary-trigger defects one level up. Chromium withholds + `:focus-visible` from a clicked button but gives it to a text field and to any key press, so a + Tab into the row, an arrow key and a click into a field still show the treatment. The row + itself can hold focus (a `tabindex` row), hence the bare `:focus-visible` beside `:has()`. + Crew's own settings row keeps `:focus-within`; it is never edited to match. */ +.biorouter-list-row:focus-visible::after, +.biorouter-list-row:has(:focus-visible)::after, +.biorouter-settings-row:focus-visible::after, +.biorouter-settings-row:has(:focus-visible)::after { + border-top-color: var(--border-focus); +} + +/* FLAT ROWS (owner message 13, R-27: "row lists drop inter-row hairlines where spacing and the + hover fill already separate rows"; WS-VIEWS-A-5). The hairline above stays the default, the + promoted Crew recipe (`crewParity.test.ts`); a list that has decided its rows read apart by air + and the hover wash puts `.br-rows-flat` on its shell (or any ancestor) and loses only the + `::after`. The row keeps its 1px transparent bottom edge, so nothing moves, and a keyboard row + keeps its wash and its own focus edge. Forced colours repaint every fill as Canvas, so the wash + that told a row from the next is gone there: the line comes back (a border, which forced colours + draw), and the `:last-child` rule after this, at the same specificity, still leaves the last + row without one. One definition: Workflows, the Scheduler and its runs, Built apps and the + Settings dialogs named this class before it lived here. */ +.br-rows-flat :is(.biorouter-list-row, .biorouter-settings-row)::after { + content: none; +} + +@media (forced-colors: active) { + .br-rows-flat :is(.biorouter-list-row, .biorouter-settings-row)::after { + content: ''; + } } -.biorouter-list-row:last-child { - border-bottom-color: transparent; +.biorouter-list-row:last-child::after, +.biorouter-settings-row:last-child::after { + content: none; +} + +/* A WASHED ROW IS A FILL, SO A CONTROL IN IT STEPS BACK ONE GROUND (design.md 9.11 rule 3: a fill + never sits on a fill; WS-SETTINGS-M-3, WS-SKILLS' marketplace rows). The hover wash is the page + ink at 5%, which composites to the rest fill's own value in light (a secondary button or a + Select trigger resting on #f4f4f2 in a row washed to about #f5f5f4: 1.00:1), so the moment the + pointer reached a row its button lost its box, and found it again on reaching the button. While + the row is washed (pointer, keyboard focus on or in it) its controls rest on the page ground + and hover on the strong step, as in a filled card, and a neutral track in it steps one in. The + wash itself is unchanged, so it still adapts to the ground the list sits on. Custom properties + only: no row rule here paints anything new. + A neutral BADGE steps back too (`--br-badge-ground`, read by `.br-badge-neutral`; vision round + 2, V3-R2-5, WS-KNOWLEDGE-3). It rests on the strong step, and the wash lands 1.10:1 off it in + light and 1.08:1 in dark, so Built-in, a base's format, Primary and a change's kind read as bare + ink under the pointer. On the page ground it is a hole in the wash, the way the row's button is + (1.10:1 light, 1.14:1 dark; `ui/groundsAndRows.browser.test.tsx` paints both). */ +.biorouter-list-row:is(:hover, :focus-visible, :has(:focus-visible)), +.biorouter-settings-row:is(:hover, :focus-visible, :has(:focus-visible)) { + --br-control-ground: var(--background-default); + --br-control-hover: var(--background-fill-strong); + --br-track-ground: var(--background-fill-strong); + --br-badge-ground: var(--background-default); +} + +/* The privacy pills (`PrivacyBadge`, `AffiliationBadge`: `.br-privacy-pill`) rest on + `--background-muted`, the rest fill's own value, and the wash composites to it, so on a washed + list row the Public or Private pill lost its box and read as bare text (WS-SKILLS-1, measured + 1.00:1 on the Browse extensions row in light and dark; the Extensions page and the knowledge + base manager are the same rows). There it takes the neutral Badge's ground beside it, the page + ground, so one row is one ground (WS-KNOWLEDGE-3); both inks (`--text-default`, + `--text-muted`) only gain on the page ground over the muted rest `check-contrast.mjs` holds to + 4.5:1. Only here, and only on a list row: Crew draws "🔒 Private · UCSF" as ONE chip whose own + box paints the muted token under the pill and the institution, and a pill that changed its + fill everywhere would split that chip in two. */ +.biorouter-list-row:is(:hover, :focus-visible, :has(:focus-visible)) .br-privacy-pill { + background-color: var(--br-badge-ground); } .biorouter-list-row:hover, -.biorouter-list-row:focus-within { - background: color-mix(in srgb, var(--background-medium) 42%, transparent); +.biorouter-list-row:has(:focus-visible) { + background-color: var(--overlay-hover); } +/* @ws WS-PRIMITIVES end: rows */ /* The swatch ring (knowledge-base/okf-migration/ui-spec.md §6.3). Every DOM palette swatch — the knowledge-base dot, and later the node-type @@ -3287,28 +4403,22 @@ input[type='radio']:checked ~ [data-radio-ring] { align-items: baseline; } -/* The graph pane's two docked strips (ui-spec §4.5): the facet strip above the - canvas and the legend below it. `--dock-height` is reused deliberately — - this is the same object it already names, a horizontal control strip docked - to a pane edge, one step down from a chrome band. - - AUTHORED for the same reason `.br-swatch-ring` is: `h-dock` appears nowhere - else in `src/`, so it is a *newly written* utility, and a newly written - utility can silently fail to reach the stylesheet under `BIOROUTER_NO_HMR` - (`watch: { ignored: ['**'] }` is the same signal Tailwind's class scanner - uses). A facet strip that collapses to zero height is not a degraded strip, - it is an absent one — and nothing in jsdom would ever report it. */ -.br-graph-dock { - height: var(--dock-height); +/* @ws WS-KNOWLEDGE begin: inspector label case */ +/* A row's label is the page's own key ("effect size", "identifier"), set in sentence case in both + inspectors: the node card's rows were the one place in the pane that drew them in caps, which is + the style of a PAGE section label (principle 3), and the edge card's rows beside them were not. + Only the first letter is raised, so a label that is already capitalized or an acronym is left as + it is. Authored, because a grid item is blockified and `::first-letter` needs a block. */ +.br-inspector-row > :first-child::first-letter { + text-transform: uppercase; } +/* @ws WS-KNOWLEDGE end: inspector label case */ -/* Expanded, the legend becomes the chip grid and takes up to 40% of the pane. - Same reasoning: an arbitrary `max-h-[40%]` is exactly the freshly-invented - class that can fail to generate, and without the cap the grid would push the - canvas out of the pane entirely. */ -.br-graph-legend-expanded { - max-height: 40%; -} +/* @ws WS-KNOWLEDGE begin: retired docks */ +/* `.br-graph-dock` and `.br-graph-legend-expanded` are gone: the legend dock + was retired for the rail and the popover (R-03), and the facet strip is a + 44px band (`h-chrome`, spec 3.12), so neither rule had a reader. */ +/* @ws WS-KNOWLEDGE end: retired docks */ /* ═══════════════════════════════════════════════════════════════════════════ KNOWLEDGE PANE — the @container ladder (redesign R-08) @@ -3329,7 +4439,7 @@ input[type='radio']:checked ~ [data-radio-ring] { Measured in the design studio, not theorised. ⚠ AUTHORED HERE rather than as Tailwind utilities, for the reason - `.br-swatch-ring` and `.br-graph-dock` already are: a newly written utility + `.br-swatch-ring` already is: a newly written utility can silently fail to reach the stylesheet under `BIOROUTER_NO_HMR` (`watch: { ignored: ['**'] }` is the same signal Tailwind's scanner uses), and a layout ladder that does not generate is an absent one, not a degraded @@ -3337,9 +4447,11 @@ input[type='radio']:checked ~ [data-radio-ring] { ═══════════════════════════════════════════════════════════════════════════ */ .br-knowledge-pane { container-name: br-knowledge-pane; - /* `size`, not `inline-size`: the height step below is load-bearing at the - app's 600px minimum window, where the pane is 568px tall. Size containment - needs a definite height, which the workspace grid gives it. */ + /* `size`, not `inline-size`. It was `size` for the height step, which went + with the subtitle it hid (spec 3.12), so the ladder reads width only now. + It stays, because switching containment changes how the pane takes its + height from the shell: that is a layout change, not a tidy-up, and wants a + browser sweep of pane widths and heights first. */ container-type: size; } @@ -3355,6 +4467,22 @@ input[type='radio']:checked ~ [data-radio-ring] { min-width: 0; min-height: 0; } +/* @ws WS-KNOWLEDGE begin: tab panels */ +/* A panel fades in only when a PERSON picks its tab (`data-entering`, which + `KnowledgeView` sets on that pick and clears on the panel's `animationend`). + The Tabs primitive gives every panel `animate-in fade-in`, and a panel that + was `display: none` restarts that animation whenever its `display` changes + back, which the layout does on its own: the 860px step forces both panels on + and the narrow step hides one again. Dragging the window across the step + replayed the Sources fade (V3-01, 18 frames sampled mid-resize). + `data-motion-layout` would not cure it: it only holds the animation off while + the body says the window is resizing, and the animation named by the utility + starts as soon as that class goes, so the fade would play right after the + drag instead of during it. Unlayered, so it beats the utility layer. */ +.br-knowledge-body > .br-tabs-content:not([data-entering]) { + animation: none; +} +/* @ws WS-KNOWLEDGE end: tab panels */ /* ⚠ THE SOURCES PANEL'S NARROW VISIBILITY IS REACT'S, NOT THIS FILE'S. Below the two-column step the Sources/Graph tab pair decides which panel shows, and `KnowledgeView` writes `flex`/`hidden` for that. These rules are UNLAYERED and @@ -3366,9 +4494,6 @@ input[type='radio']:checked ~ [data-radio-ring] { .br-knowledge-detail { display: none; } -.br-knowledge-legend-card { - display: none; -} .br-knowledge-tabs { display: flex; } @@ -3385,6 +4510,199 @@ input[type='radio']:checked ~ [data-radio-ring] { display: none; } +/* @ws WS-KNOWLEDGE begin: band */ +/* The Knowledge band is the app's one `PageHeader` band (spec 3.12). Its + adornment carries the subject (the base's name, which is the switcher, its + format, its tier and its counts), so here, unlike every other view, the + adornment is the part that gives way, and inside it only the name does: + the title, the tabs, the tier badge and the actions keep their size. + Measured in `knowledge/knowledgeBand.browser.test.tsx`: with the band's + default shrink, a 760px pane truncated "Knowledge" and the tabs as well. */ +.br-knowledge-pane .biorouter-page-header-adornment { + flex-shrink: 1; + min-width: 0; +} + +.br-knowledge-pane .biorouter-page-header-title, +.br-knowledge-pane .biorouter-page-header-tabs { + flex-shrink: 0; +} + +/* A GROUP BREAK, not decoration. The band separates its children by 8px, so the + Sources/Graph pair began 8px after "38 links" and the row read as one run, + "43 pages · 38 links Sources Graph", which files a mode switcher among the + base's attributes (measured at a 760px pane before the band was merged, when + it took a rule and a wider gap). The counts describe the base; the pair + chooses which half of it shows. The break sits on the list itself, which + hides at the two-column step, so no dead space is left behind. */ +.br-knowledge-tabs { + margin-left: 24px; +} + +/* The tier badge-button (Crew's ClassificationBadge). The chip inside is + `PrivacyBadge`, the app's one tier mark; the button around it is + transparent, so the focus is an outline outside the chip, as the InfoTip's + `asChild` form draws it: the badge's opaque fill would cover the D-15 focus + surface. + ⚠ **The chip rests on the strong fill, like every other chip on the band** (V3-R4-2, R-27, + design.md 9.11 rule 2). `PrivacyBadge` paints `--background-muted` through a utility, the rest + fill's own value, and the band is the sidebar's paint: 1.03:1 in light (`#f4f4f2` on `#f7f7f5`), + so the one chip that is also a button read as plain text beside the format chip. This is the + neutral Badge's own recipe, said once here because the pill's utility out-ranks the layered + `.br-badge-neutral`; unlayered, it wins over the utility. Hover and an open menu take the strong + ground (R-27): the old medium step equalled the rest in dark (1.0:1), so a hover on it could not + be seen. Measured in `knowledge/knowledgeTierBadge.browser.test.tsx`. */ +.br-knowledge-tier { + display: inline-flex; + flex-shrink: 0; + align-items: center; + border-radius: var(--radius-inner); + background: transparent; + cursor: pointer; +} + +.br-knowledge-tier:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + background-color: transparent; + box-shadow: none; +} + +.br-knowledge-tier [data-testid='privacy-badge'] { + background-color: var(--background-fill-strong); + transition: background-color var(--dur-fast-min) var(--ease-out); +} + +.br-knowledge-tier:hover [data-testid='privacy-badge'], +.br-knowledge-tier[data-state='open'] [data-testid='privacy-badge'] { + background-color: var(--background-strong); +} + +.br-knowledge-tier:disabled { + cursor: not-allowed; + opacity: 0.5; +} +/* @ws WS-KNOWLEDGE end: band */ + +/* @ws WS-KNOWLEDGE begin: dropzone */ +/* The Sources rail's drop target (spec 3.12): one filled field holding its label button, a one-line + hint with an InfoTip and "Paste text", the recipe Workflows' Import and the .brxt install draw + (`workflows.css`, `brxt-install.css`), with no resting outline (owner message 13, R-27, design.md + 9.11). The rail is the page ground, so the field rests on the subtle fill and steps to the + strong fill under the pointer. The 1px border is kept and transparent, so forced colours draw the + edge and nothing moves between states. A file held over it firms the edge and the ground (the + "this will act" state). Authored, because the drag state is load-bearing feedback and must not + depend on class scanning. + ⚠ **It fills the rail's free height, like a side panel** (owner message 17, R-33, which + supersedes V3-12's content-height box for the height only). It grows into whatever the rail's + scroller has left, and yields that room to the paste box, the staged list and the digest + progress as they appear, down to a 240px floor; below the floor the scroller scrolls instead, so + the target never shrinks to a strip. Basis 0, so its own content never decides its height. + Measured on the real rail in `knowledge/sourcesRail.browser.test.tsx`. */ +.br-knowledge-dropzone { + flex: 1 1 0%; + min-height: 240px; + border: 1px solid transparent; + background-color: var(--background-fill); + transition: background-color var(--dur-fast-min) var(--ease-out); +} + +.br-knowledge-dropzone:hover { + background-color: var(--background-fill-strong); +} + +.br-knowledge-dropzone[data-dragging='true'] { + background-color: var(--background-fill-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +/* The 32px glyph plate rests one ground back (the page's), so it is a plate without being an + outlined box: a fill never sits on a fill. */ +.br-knowledge-drop-plate { + background-color: var(--background-default); + color: var(--text-muted); + transition: + background-color var(--dur-fast-min) var(--ease-out), + color var(--dur-fast-min) var(--ease-out); +} + +.br-knowledge-dropzone[data-dragging='true'] .br-knowledge-drop-plate { + color: var(--text-default); +} + +/* The rail is the drop target (V3-12), so the rail answers a held file as well as the box. The box + yields its room to the staged list and the paste box (R-33), and a long list scrolls it out of + view; a file held over the rail then lit nothing on screen. While a file is held anywhere on the + rail, the rail draws a 1px inset accent edge around itself: the composer card's drag-over edge + (`composerCard.ts`). The box keeps its own neutral answer (the strong fill and the + `--border-strong` line), which works because the fill changes with it; that line alone, around + a whole column with no fill under it, barely showed in the review shots, so the rail does not + borrow it. A border on a pseudo-element laid over the rail's children, not an inset shadow on + the rail: the footer paints its own ground and would cover a shadow on its parent, and forced + colours keep a border where they drop a shadow. It takes no pointer, so the drop lands on what + is under it. `isolation` keeps its z-index inside the rail. Measured in + `knowledge/sourcesRail.browser.test.tsx` (the edge, 3:1 or more on the ground, light and dark). */ +.br-knowledge-rail { + position: relative; + isolation: isolate; +} + +.br-knowledge-rail[data-dragging='true']::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + border: 1px solid var(--border-accent); + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .br-knowledge-dropzone, + .br-knowledge-drop-plate { + transition: none; + } +} +/* The model picker and Digest, one column under the rail (V3-12, kept by R-33). At the two-column + step the rail is 264px and the cap does nothing; on the narrow Sources tab the rail is the whole + pane and an uncapped Digest was an 820px coral bar. Left-aligned with the dropzone's own edge. + Authored, because the cap is load-bearing and must not depend on class scanning. */ +.br-knowledge-ingest-actions { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 22.5rem; +} +/* @ws WS-KNOWLEDGE end: dropzone */ + +/* @ws WS-KNOWLEDGE begin: pane header */ +/* The previews' and drawers' header (`KnowledgePaneHeader`), Crew's pane + header: 44px, 8px of band padding with the title's own 8px inside it, so + the title text starts 16px in and the close ends 8px from the edge. */ +.br-knowledge-pane-header { + gap: 4px; + padding-inline: 8px; +} + +/* A drawer draws its own close in that header (`SheetClose` over the same ghost + round button a preview has). The Sheet primitive still renders its corner + close, always; it would sit a second, rounded square close over the band, so + it is hidden. `data-slot='sheet-close'` is the primitive's own name for it + (WS-PRIMITIVES 3ea796185), and the `>` is what spares the drawer's own close, + which sits inside the header and not directly in the content. */ +.br-knowledge-drawer > [data-slot='sheet-close'] { + display: none; +} +/* The drawers' header keeps its ground (V3-11). It is a direct child of the Sheet's modal surface, + and `.biorouter-modal-surface > .border-b` paints every such child transparent (a modal + header is a hairline on the dialog's own ground), which beat the header's `bg-sidebar`: the + drawer's band read white against the page band's `--sidebar`. Crew's pane header is that + band; the extra class keeps this one over the modal rule whatever the source order. */ +.biorouter-modal-surface.br-knowledge-drawer > .br-knowledge-pane-header { + background-color: var(--sidebar); +} +/* @ws WS-KNOWLEDGE end: pane header */ + @container br-knowledge-pane (min-width: 860px) { .br-knowledge-tabs { display: none; @@ -3398,6 +4716,14 @@ input[type='radio']:checked ~ [data-radio-ring] { .br-knowledge-graph { display: flex; } + /* @ws WS-KNOWLEDGE begin: column edge */ + /* Two flush columns (spec 3.12): the Sources column ends in Crew's inset + hairline rather than a card's border, so nothing is a box inside a box. + Only at this step, because below it the column is alone and pane-wide. */ + .br-knowledge-sources { + box-shadow: inset -1px 0 0 var(--border-subtle); + } + /* @ws WS-KNOWLEDGE end: column edge */ .br-facet-all { display: none; } @@ -3420,8 +4746,8 @@ input[type='radio']:checked ~ [data-radio-ring] { /* ⚠ **This step promotes THREE chips at once, which is why it sits at 1140 and not at the 1060 a first measurement suggested.** `Predicate` moved into `More` to stop the row clipping in the band below (see `GraphFacetStrip`), so - crossing this line now restores Predicate, Source AND Status together, plus - the readout. Swept in 10px steps across 720–1760: at 1060 the row overflowed + crossing this line now restores Predicate, Source AND Status together. + Swept in 10px steps across 720–1760: at 1060 the row overflowed its column by 44px and did not clear until 1110. The lesson is the sweep, not the number — both overflow seams this ladder has had were invisible at the four canonical pane sizes and showed up only against every width in between, @@ -3434,9 +4760,6 @@ input[type='radio']:checked ~ [data-radio-ring] { .br-facet-extra { display: inline-flex; } - .br-knowledge-readout { - display: flex; - } } @container br-knowledge-pane (min-width: 1400px) { @@ -3458,46 +4781,66 @@ input[type='radio']:checked ~ [data-radio-ring] { .br-facet-legend { display: none; } -} - -/* The height step. At a 600px window the pane is 568px, so this is the DEFAULT - state at the app's minimum, not an edge case: the header yields its - description line so the canvas keeps a usable box. */ -@container br-knowledge-pane (max-height: 620px) { - .br-knowledge-subtitle { - display: none; + /* @ws WS-KNOWLEDGE begin: filter readout */ + /* The readout ("Showing 3 of 16") and the Clear button's label wait for the + width the Legend control gives back. They used to appear at 1140, beside + all four facets AND the Legend, and with a filter engaged the row needed + ~977px in an 820px column at the common 1152 pane: the readout painted over + the Legend. Swept in 20px steps from 700 to 1500, idle and engaged: it + overlapped from 1140 to 1220, and at no width once this moved. Below this + step Clear is the bare icon, and its Tooltip carries the count. */ + .br-knowledge-readout { + display: flex; } + /* @ws WS-KNOWLEDGE end: filter readout */ } -/* ── the filter control (R-02) ────────────────────────────────────────────── +/* @ws WS-KNOWLEDGE begin: no height step */ +/* There is no height step any more. It hid the header's subtitle below a 620px + pane; the subtitle is now the band's InfoTip, so there is nothing left to + yield (spec 3.12). */ +/* @ws WS-KNOWLEDGE end: no height step */ + +/* @ws WS-KNOWLEDGE begin: filter facet */ +/* ── the filter control (R-02, R-27) ──────────────────────────────────────── A filter is NOT a button and NOT a pill. It sits on `--radius-element`, the - same step as every button, input, select and tab — a control differentiated + same step as every button, input, select and tab: a control differentiated by being a different SHAPE from the rest of the app is a control that reads - as foreign. What separates it is edge and ground: a 1.5px border on a - transparent fill reads as a field that holds a value, where a secondary - button is a filled slab with no edge at all. Engaged is a solid accent fill - rather than a tint, because across a 1600px bar a tint is a guess. - - This rule is authored rather than composed from utilities because the - `:has()`-free resting/engaged pair has to survive the class scanner, and - because the design system's D-15 focus surface must read THROUGH it. */ + as foreign. It is the app's one filled control now (owner message 13, R-27, + design.md 9.11): it rests on the subtle fill (the strip is the page ground, + or the control ground a filled box set for it), steps to the strong fill under + the pointer, and keeps a 1px TRANSPARENT border, so forced colours draw its + edge and nothing moves between states. It used to be a 1.5px outline on a + transparent fill, which read as a field that holds a value: the facet's own + count and caret say that now. + + ENGAGED is a neutral selected state, not coral. It was a solid accent fill, + which put a second coral bar beside the Sources rail's Digest, one per + engaged facet across a 1600px strip, and the accent is not allowed there + (principle 7 names its places and a filter is not one). The strong fill under + the selected wash is the darkest neutral on the strip and the label stays the + default ink, so an engaged facet is still the loudest thing on it, and + the count beside the label and the `Clear filters` control say which. + + Authored rather than composed from utilities because the resting/engaged pair + has to survive the class scanner, and because the design system's D-15 focus + surface must read THROUGH it (the wash is a background-image, so the focus + ground, a background-colour, shows under it). */ .br-facet { - border: 1.5px solid var(--border-subtle); - background: transparent; + border: 1px solid transparent; + background-color: var(--br-control-ground, var(--background-fill)); } .br-facet:hover { - border-color: var(--border-strong); - background: var(--background-muted); + background-color: var(--background-fill-strong); } .br-facet[data-engaged='true'] { - border-color: var(--background-accent); - background: var(--background-accent); - color: var(--text-on-accent); + background-color: var(--background-fill-strong); + background-image: linear-gradient(var(--overlay-selected), var(--overlay-selected)); } .br-facet[data-engaged='true']:hover { - border-color: var(--background-accent); - background: var(--background-accent); + background-image: linear-gradient(var(--overlay-selected-hover), var(--overlay-selected-hover)); } +/* @ws WS-KNOWLEDGE end: filter facet */ /* ⚠ **THERE IS NO CANVAS-ANCHORED LEGEND CARD, AND THAT IS DELIBERATE.** An earlier revision floated the legend over the canvas between 940 and 1400, @@ -3522,173 +4865,1545 @@ input[type='radio']:checked ~ [data-radio-ring] { point; keeping it working would have preserved a fix for a defect that no longer exists. */ -.biorouter-home-content { - container-name: biorouter-home-content; - container-type: size; -} +/* @ws WS-USAGE begin: home */ +.biorouter-home-content { + container-name: biorouter-home-content; + container-type: size; +} + +/* Home (spec 3.9): the greeting, then the usage card, as one group centred in + the space above the composer. `margin: auto` never clips: when the window is + too short the margins collapse to 0 and the content scrolls. The rails are + the composer wrapper's (Hub.tsx: 16px, 24px from 640px), and the column is + the chat measure, so the card's edges are the composer card's edges. */ +.biorouter-home-insights { + display: flex; + min-block-size: 100%; + flex-direction: column; + padding-inline: 16px; +} + +@media (min-width: 640px) { + .biorouter-home-insights { + padding-inline: 24px; + } +} + +.biorouter-home-column { + inline-size: 100%; + max-inline-size: var(--measure-chat); + margin: auto; + padding-block-end: 24px; +} + +/* 17px = the composer card's 1px edge plus its 16px text inset, so the + greeting, the usage card's content and the composer's text share one left + edge. */ +.biorouter-home-hero { + padding: 64px 17px 20px; +} + +.biorouter-home-greeting { + text-wrap: balance; +} + +/* The card is never folded away. As the window gets short only the hero + compacts, then yields, and below that Home scrolls. */ +@container biorouter-home-content (max-height: 720px) { + .biorouter-home-hero { + padding-block: 56px 16px; + } +} + +@container biorouter-home-content (max-height: 560px) { + .biorouter-home-hero { + /* Keep clear of the floating titlebar and sidebar controls at top-left. */ + padding-block: 44px 12px; + } +} + +@container biorouter-home-content (max-height: 340px) { + .biorouter-home-hero { + display: none; + } + + /* With the hero gone the card is the first content: keep it clear of the + floating titlebar and sidebar controls at top-left. */ + .biorouter-home-column { + padding-block-start: 44px; + } +} +/* @ws WS-USAGE end: home */ + +.biorouter-settings-tabs { + background: transparent; + box-shadow: none; +} + +/* A scroller whose top edge is FLUSH against another rule, faded so a clipped + row cannot masquerade as one. + * + * ⚠ Written for a specific defect, not as decoration. In Settings the tab + * strip's bottom edge and the scroll viewport's top edge are the same y — the + * strip carries `border-b-0` on purpose (§4.2: its own hairline landed a few + * pixels under the header's and read as a double rule), so there is nothing + * between them. Scroll the App tab to 663px at 1440x900 and the last 1px of the + * "Configuration guide" link survives the clip as an accent-coloured hairline + * sitting beside the active tab's underline, at the same y and in the same + * colour: a second, thinner tab indicator. A ~3px window of scroll positions + * produces it (660 shows 4px of the link, 666 shows none). + * + * ⚠ The mask is applied to the VIEWPORT, not the Root, and only while the + * scroller is actually scrolled. Masking the Root would fade the scrollbar with + * the content; fading unconditionally would dim the first row of a page nobody + * has scrolled, which is the one place a page has nothing to hide. The hook is + * `data-scrolled`, which `ui/scroll-area.tsx` has always written from + * `scrollTop > 0` and which nothing consumed until now. + * + * ⚠ Authored CSS, not a Tailwind arbitrary variant. `mask-image: + * linear-gradient(...)` inside a `data-[scrolled=true]:[...]` utility is a + * newly written class string, and a newly written class can silently fail to + * generate — see `styles/composerFocus.test.ts`, where three spellings were + * each measured in the running app with the class on the element and no rule + * anywhere in the stylesheet. + * + * It is a mask rather than an overlay gradient for one reason: an overlay has + * to be painted in the ground's colour, and the ground differs per theme + * family and per mode. A mask removes alpha, so it is correct in light and dark + * of all three families without a single colour being named. + * + * `scroll-padding-top` is the other half: without it, tabbing to a control that + * is scrolled just off the top lands it at y=0, inside the fade. With it the + * browser stops the scroll a fade's height short, so a focus ring is never the + * thing being faded out. */ +.biorouter-scroll-fade-top > [data-radix-scroll-area-viewport] { + scroll-padding-top: var(--scroll-fade-top); +} + +.biorouter-scroll-fade-top[data-scrolled='true'] > [data-radix-scroll-area-viewport] { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + transparent var(--scroll-fade-clear), + #000 var(--scroll-fade-top) + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + transparent var(--scroll-fade-clear), + #000 var(--scroll-fade-top) + ); +} + +.biorouter-settings-section { + padding-block: 14px; +} + +.biorouter-settings-section + .biorouter-settings-section { + margin-top: 10px; +} + +.biorouter-settings-section-header { + margin-bottom: 8px; +} + +.biorouter-settings-list { + display: flex; + flex-direction: column; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.biorouter-settings-control-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; +} + +/* @ws WS-PRIMITIVES begin: settings row */ +/* The settings row's box, divider and transition are the shared row recipe above + (`@ws WS-PRIMITIVES begin: rows`); only its hover, below, is its own. */ +/* @ws WS-PRIMITIVES end: settings row */ + +/* ⚠ `background-color`, NOT the `background` shorthand, and it skips a tinted + row. Both halves are load-bearing and both were bugs. + This rule is UNLAYERED, so it beats anything inside `@layer utilities` + regardless of specificity — including `tint-selected`, whose wash is a + `background-image`. The shorthand reset that image to `none`, so pointing at + a selected row erased its selection: the same inversion recorded above + `.tint-selected.tint-interactive`, arriving by a different route. Narrowing + the property leaves the tint alone, and `:not(.tint-selected)` then hands a + selected row's hover to that composed pair, which answers the pointer by + DARKENING rather than by dropping a step. */ +/* @ws WS-PRIMITIVES begin: settings row hover */ +.biorouter-settings-row:not(.tint-selected):hover, +.biorouter-settings-row:not(.tint-selected):has(:focus-visible) { + background-color: var(--overlay-hover); +} +/* @ws WS-PRIMITIVES end: settings row hover */ + +/* @ws WS-PRIMITIVES begin: row focus */ +/* A row that is ITSELF the focus target (a full-width button: a provider, a permission rule) + takes D-15's row treatment, `--background-focus` and an inset 2px `--border-focus` edge (spec + 2.5). The row recipe's `box-shadow: none` and its hover wash are unlayered, so they beat the + base layer's `:focus-visible` rule and left such a row with a 5% wash, the same as a hover, and + a 1px firmed line: measured in Chromium with Tab, 2026-10-10. A row that merely CONTAINS the + focused control (the usual case: a switch, a name button) keeps the wash and the line above. + The third selector outranks the settings row's `:not(.tint-selected):hover` (0,3,0), so a + focused row under the pointer still reads as focused. */ +.biorouter-list-row:focus-visible, +.biorouter-settings-row:focus-visible, +.biorouter-settings-row:not(.tint-selected):focus-visible { + background-color: var(--background-focus); + box-shadow: inset 0 0 0 2px var(--border-focus); +} +/* @ws WS-PRIMITIVES end: row focus */ + +/* No dark-mode override needed: the block is flat and token-driven in both themes. */ + +/* @ws WS-PRIMITIVES begin: primitives */ +/* InfoTip, SegmentedControl, SettingRow, RowActions, Spinner, FilterInput, Textarea and the + tooltip motion. Authored, not utilities: every rule here is load-bearing for a shared primitive, and a newly + written Tailwind class can silently fail to generate under `BIOROUTER_NO_HMR` (CLAUDE.md, + "Desktop shell geometry"). Spec 2.6. */ + +/* Tooltip and InfoTip motion: in over `--dur-fast`, out over `--dur-fast-min`, `--ease-out`. + `animate-in`/`animate-out` (tw-animate-css) read `--tw-duration` and `--tw-ease`; the frames are + the surfaces' own (`br-surface-in/out`, the dialog motion block), which draw no `filter` + (V1-L7). A tooltip is open in `delayed-open`, `instant-open` or with no state at all + (`AppTooltipLayer`), so the closed state is the one named. */ +.br-tooltip-motion { + --tw-duration: var(--dur-fast); + --tw-ease: var(--ease-out); + animation-name: br-surface-in; +} + +.br-tooltip-motion[data-state='closed'] { + --tw-duration: var(--dur-fast-min); + animation-name: br-surface-out; +} + +/* Menus and popovers (`MENU_SURFACE_CLASS_NAME`, `PopoverContent`): open with opacity, + scale(.97) and 4px toward the trigger from Radix's transform origin over `--dur-fast-max`; + close with opacity alone over `--dur-fast`; `--ease-out`. The tw-animate utilities on the + element are the carriers; these values win because this rule is unlayered. */ +.br-menu-motion { + --tw-ease: var(--ease-out); + transform-origin: var( + --radix-dropdown-menu-content-transform-origin, + var( + --radix-context-menu-content-transform-origin, + var(--radix-popover-content-transform-origin, center) + ) + ); +} + +.br-menu-motion[data-state='open'] { + --tw-duration: var(--dur-fast-max); + --tw-enter-scale: 0.97; + /* The surfaces' own keyframes, which draw no `filter` (V1-L7, the dialog motion block). */ + animation-name: br-surface-in; +} + +.br-menu-motion[data-state='open'][data-side='bottom'] { + --tw-enter-translate-y: -4px; +} + +.br-menu-motion[data-state='open'][data-side='top'] { + --tw-enter-translate-y: 4px; +} + +.br-menu-motion[data-state='open'][data-side='left'] { + --tw-enter-translate-x: 4px; +} + +.br-menu-motion[data-state='open'][data-side='right'] { + --tw-enter-translate-x: -4px; +} + +.br-menu-motion[data-state='closed'] { + --tw-duration: var(--dur-fast); + --tw-exit-scale: 1; + --tw-exit-translate-x: 0; + --tw-exit-translate-y: 0; + animation-name: br-surface-out; +} + +/* Defaults a caller's own width utility may override, hence the layer. */ +@layer components { + .br-menu-surface { + min-inline-size: 180px; + max-inline-size: 320px; + } +} + +/* The react-select menu (`ui/Select.tsx`) is not a Radix surface, so it has no `data-state` for + `.br-menu-motion` to key on. It opens with the same values as a keyframe: opacity, scale(.97) + and 4px toward the side it hangs from, from that side's origin, over `--dur-fast-max`, + `--ease-out`. react-select unmounts it on close, so there is no exit. The global + reduced-motion reset cuts the keyframe to one instant frame. */ +@keyframes br-select-menu-in { + from { + opacity: 0; + transform: translateY(var(--br-select-menu-from)) scale(0.97); + } +} + +.br-select-menu { + --br-select-menu-from: -4px; + transform-origin: top center; + animation: br-select-menu-in var(--dur-fast-max) var(--ease-out) both; +} + +.br-select-menu--top { + --br-select-menu-from: 4px; + transform-origin: bottom center; +} + +/* The one menu row (`MENU_ROW_CLASS_NAME`): its highlight eases over `--dur-fast-min`. */ +.br-menu-row { + transition-duration: var(--dur-fast-min); +} + +/* A menu opened with the pointer rings no row (WS-SIDEBAR-2). A DropdownMenu trigger opens on a + cancelled `pointerdown`, so Radix's script focus on each row the pointer crosses reads as + keyboard focus to Chromium, and the hovered row wore D-15's 2px inset ring (measured on the + sidebar's View options and a chat row's menu). The surface says how it is being driven + (`data-menu-input`, set from `ui/inputTrail.ts` by `dropdown-menu.tsx`): in a `pointer` menu + the ring goes and the row keeps its `--overlay-hover` highlight, the hover look; the first key + pressed in the menu turns it to `key`, and the ring is back on the focused row. Unlayered, so + it beats the layered D-15 rule; the `prefers-contrast: more` and forced-colours outlines on a + highlighted row are `!important` and untouched. */ +[data-menu-input='pointer'] + :is([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio']):focus-visible { + box-shadow: none; +} + +/* Its geometry, authored as well as written in the row's utilities (the same values, pinned + together in `menuRow.test.tsx`): a 32px floor, 6px 12px padding, an 8px gap, radius 8, 13/18 + text. Layered, so a caller's own utility still wins; it is what a stylesheet without + Tailwind (the geometry browser test, a scanner that missed a string) still draws. */ +@layer components { + .br-menu-row { + position: relative; + display: flex; + align-items: center; + gap: 8px; + min-block-size: var(--control-md); + padding: 6px 12px; + border-radius: var(--radius-element); + font-size: var(--text-secondary); + line-height: var(--text-secondary--line-height); + } + + .br-menu-row-trailing { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + margin-inline-start: auto; + } +} + +/* InfoTip trigger: a 14px glyph in a 24×24 hit box drawn with a −5px margin, so it adds 14px + to the line; the start margin is −1px so the glyph lands 4px after the label. */ +.br-info-tip { + position: relative; + isolation: isolate; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + inline-size: 24px; + block-size: 24px; + margin-block: -5px; + margin-inline: -1px -5px; + padding: 0; + border: 0; + border-radius: var(--radius-inner); + background: transparent; + color: var(--text-subtle); + vertical-align: middle; + cursor: help; + -webkit-app-region: no-drag; + transition: + color var(--dur-fast-min) var(--ease-out), + background-color var(--dur-fast-min) var(--ease-out); +} + +.br-info-tip > svg { + inline-size: 14px; + block-size: 14px; + flex: none; +} + +/* The focus mark's box: 20px, 2px inside the hit box on every side (vision V4-R3-1). The hit box + starts 1px before the label's end (the −1px start margin above), so an edge drawn on the hit + box itself lay over the label's last letter ("running", "DEFAULT MODEL"). On this box the edge + starts 1px after the label's advance and stays 1.7px clear of the glyph's circle, and the hit + box, the glyph's place and the 14px the tip adds to the line are unchanged. */ +.br-info-tip::before { + content: ''; + position: absolute; + /* Under the glyph and under the OS ring's outline below; the button's `isolation` keeps it + above whatever the button sits on. */ + z-index: -1; + inset: 2px; + border-radius: var(--radius-inner); + pointer-events: none; +} + +.br-info-tip:hover, +.br-info-tip[data-state='open'] { + color: var(--text-default); +} + +/* D-15: focus is a surface shift plus an inset edge, never a ring. Both are drawn on the 20px + mark, and the button itself paints neither (the base `[tabindex]`/button rule would put them on + the hit box). */ +.br-info-tip:focus-visible { + outline: none; + color: var(--text-default); + background-color: transparent; + box-shadow: none; +} + +.br-info-tip:focus-visible::before { + background-color: var(--background-focus); + box-shadow: inset 0 0 0 2px var(--border-focus); +} + +/* The OS-requested ring (the block at the end of this file) is a 2px outline 2px OUTSIDE the hit + box, which covered the label's last two letters. Here it is drawn 2 to 4px inside the hit box, + on the same 20px edge as the quiet mark. `button.` outranks that block's selector, which ties + with `.br-info-tip:focus-visible` and comes later. */ +@media (prefers-contrast: more), (forced-colors: active) { + button.br-info-tip:focus-visible { + outline-offset: -4px !important; + } +} + +/* The `asChild` form (a badge as the trigger): Crew's ClassificationBadge ring, an outline + outside the chip, so the chip's own fill is untouched. */ +.br-info-tip-target { + cursor: help; +} + +.br-info-tip-target:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + /* InfoTip gives the target `tabindex="0"`, so the base D-15 rule (`[tabindex]` arm) would also + match it and draw its inset edge and focus fill: a second ring inside this one (WS-HISTORY-2, + measured on a Tab-focused badge). The target carries `.biorouter-focus-region`, the base + rule's documented opt-out, which keeps the fill and the edge off; this `box-shadow: none` + takes back the region's own 1px inset edge. The outline is the whole indicator and the + chip's ground stays as it was. */ + box-shadow: none; +} + +.br-info-tip-content { + max-inline-size: min(280px, calc(100vw - 16px)); + font-weight: 400; + text-align: start; + white-space: normal; +} + +/* SegmentedControl: a 28px track (2px padding) of 24px segments; one thumb slides under the + selected segment. The thumb's `translate` and `width` are a named exception to "layout + snaps" (principle 9): it is absolutely positioned and moves no other box. */ +.br-segmented { + position: relative; + isolation: isolate; + display: inline-flex; + align-items: center; + block-size: var(--control-sm); + max-inline-size: 100%; + padding: 2px; + border-radius: var(--radius-element); + /* The track is a field-like fill (R-27): the same `--background-fill` a field and a secondary + button rest on (it has the value `--background-muted` had, in both modes, so no pixel moves). + The thumb stays the raised surface above it. In a filled box and on a recessed ground the + track steps one deeper (`--br-track-ground`, the grounds note), where the fill would equal its + ground and leave a lone thumb. */ + background: var(--br-track-ground, var(--background-fill)); + -webkit-app-region: no-drag; +} + +.br-segmented[data-fill='true'] { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + inline-size: 100%; +} + +.br-segmented-item { + position: relative; + z-index: 1; + display: inline-flex; + min-inline-size: 0; + align-items: center; + justify-content: center; + gap: 6px; + block-size: 24px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: var(--text-secondary); + font-weight: 400; + line-height: var(--text-secondary--line-height); + white-space: nowrap; + cursor: pointer; + transition: color var(--dur-fast) var(--ease-out); +} + +.br-segmented-item:hover, +.br-segmented-item[data-state='checked'] { + color: var(--text-default); +} + +.br-segmented-item:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--border-focus); +} + +.br-segmented-item:disabled, +.br-segmented-item[data-disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +.br-segmented-label { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.br-segmented-icon { + inline-size: 14px; + block-size: 14px; + flex: none; +} + +.br-segmented-swatch { + inline-size: 10px; + block-size: 10px; + flex: none; + border-radius: var(--radius-full); +} + +/* Before the first measurement the selected segment paints its own ground, so nothing slides + in from x=0 and nothing is missing in jsdom or a hidden ancestor. */ +.br-segmented[data-placed='false'] .br-segmented-item[data-state='checked'] { + background: var(--background-default); + box-shadow: var(--shadow-raised), var(--inset-hairline); +} + +.br-segmented-thumb { + position: absolute; + z-index: 0; + inset-block: 2px; + inset-inline-start: 0; + border-radius: 6px; + background: var(--background-default); + box-shadow: var(--shadow-raised), var(--inset-hairline); + pointer-events: none; + transition: + translate var(--dur-fast-max) var(--ease-spring), + width var(--dur-fast-max) var(--ease-out); +} + +.br-segmented-thumb[data-placed='false'] { + opacity: 0; +} + +/* Dark: the neutrals step UP from the page, so `--background-default` (#1b1b19) is DARKER than the + track (`--background-muted`, #232320) and the chosen segment read as sunken with a hairline. A + raised thumb is a step lighter than its track in both modes, so dark takes `--background-strong`. + `segmented-control.test.tsx` measures the order in every family. */ +.dark .br-segmented-thumb, +.dark .br-segmented[data-placed='false'] .br-segmented-item[data-state='checked'] { + background: var(--background-strong); +} + +/* `fill`: equal columns, so the thumb is computed rather than measured. */ +.br-segmented[data-fill='true'] > .br-segmented-thumb { + inset-inline-start: 2px; + width: calc((100% - 4px) / var(--br-segmented-count, 1)); + translate: calc(var(--br-segmented-index, 0) * 100%) 0; +} + +.br-segmented-thumb[data-motion-still] { + transition: none; +} + +/* Slider (`ui/slider.tsx`, owner ruling R-26): discrete stops on a 4px track. A 24px hit target; + the rail is inset 8px each side, so the 16px thumb never overhangs the box. The track and the + fill are two 6px layers of the same box: each draws its 4px bar and a 6px mark at every stop, + and the fill (accent bar, accent marks) is clipped to the value. The thumb rides a slot as + wide as the rail, translated by the value's share of its own width, so only `transform` and + `clip-path` move (principle 9), on one curve, and the fill ends under the thumb's centre. */ +.br-slider { + --br-slider-sign: 1; + position: relative; + display: block; + inline-size: 100%; + min-inline-size: 64px; + block-size: 24px; + cursor: pointer; + touch-action: none; + user-select: none; + -webkit-user-select: none; + -webkit-app-region: no-drag; +} + +.br-slider[dir='rtl'], +[dir='rtl'] .br-slider:not([dir='ltr']) { + --br-slider-sign: -1; +} + +.br-slider[data-dragging] { + cursor: grabbing; +} + +.br-slider[data-disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +.br-slider-rail { + position: absolute; + inset-block: 0; + inset-inline: 8px; +} + +.br-slider-track, +.br-slider-fill { + position: absolute; + inset-inline: 0; + inset-block-start: 50%; + block-size: 6px; + margin-block-start: -3px; + pointer-events: none; +} + +.br-slider-track::before, +.br-slider-fill::before { + content: ''; + position: absolute; + inset-inline: 0; + inset-block-start: 1px; + block-size: 4px; + border-radius: var(--radius-full); + background-color: var(--control-track-off); +} + +.br-slider-mark { + position: absolute; + inset-block-start: 0; + inline-size: 6px; + block-size: 6px; + margin-inline-start: -3px; + border-radius: var(--radius-full); + background-color: var(--border-control); +} + +.br-slider-fill::before, +.br-slider-fill > .br-slider-mark { + background-color: var(--background-accent); +} + +/* Revealed from the start edge to the value; 4px of slack above, below and behind the start so + the end marks keep their round caps. */ +.br-slider-fill { + clip-path: inset(-4px calc((1 - var(--br-slider-fraction, 0)) * 100%) -4px -4px); + transition: clip-path var(--dur-fast-max) var(--ease-out); +} + +.br-slider[dir='rtl'] .br-slider-fill, +[dir='rtl'] .br-slider:not([dir='ltr']) .br-slider-fill { + clip-path: inset(-4px -4px -4px calc((1 - var(--br-slider-fraction, 0)) * 100%)); +} + +.br-slider-thumb-slot { + position: absolute; + inset: 0; + pointer-events: none; + transform: translateX(calc(var(--br-slider-fraction, 0) * var(--br-slider-sign) * 100%)); + transition: transform var(--dur-fast-max) var(--ease-out); +} + +/* A 16px accent disc, cut out of the track by a 2px ring of the ground it sits on (inside a + dark popover that ground is re-pointed to the surface's own). */ +.br-slider-thumb { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 0; + inline-size: 16px; + block-size: 16px; + margin-block-start: -8px; + margin-inline-start: -8px; + border-radius: var(--radius-full); + background-color: var(--background-accent); + box-shadow: 0 0 0 2px var(--background-default); + outline: none; + cursor: grab; + pointer-events: auto; +} + +.br-slider[data-dragging] .br-slider-thumb { + cursor: grabbing; +} + +.br-slider[data-disabled] .br-slider-thumb { + cursor: not-allowed; +} + +/* D-15's edge for a disc with no surface to shift: `--border-focus` around the ground ring, + where it contrasts with the ground. Not after a press (`data-pointer-focus`, until a key). */ +.br-slider-thumb:focus-visible:not([data-pointer-focus]) { + box-shadow: + 0 0 0 2px var(--background-default), + 0 0 0 4px var(--border-focus); +} + +/* A value that arrived without the person lands at rest (`ui/slider.tsx`). */ +.br-slider-fill[data-motion-still], +.br-slider-thumb-slot[data-motion-still] { + transition: none; +} + +/* Switch (`ui/switch.tsx`): a 32×20 track, a 16px knob inset 2px in both states, travel 12px, + no growth. The off knob is the page ground ringed by `--border-control`, so it reads at 3:1 on + the off track in every family, the same in dark: a lighter `--text-muted` dark knob read as the + brighter, "on" state (coordinator ruling 8), so the accent track alone carries "on". A 2px + invisible extension above and below makes the hit target 24px. */ +.br-switch { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + inline-size: 32px; + block-size: 20px; + padding: 0; + border: 0; + border-radius: var(--radius-full); + background-color: var(--control-track-off); + cursor: pointer; + vertical-align: middle; + transition: background-color var(--dur-fast) var(--ease-out); +} + +.br-switch::after { + content: ''; + position: absolute; + inset: -2px 0; +} + +.br-switch[data-state='checked'] { + background-color: var(--background-accent); +} + +.br-switch:disabled, +.br-switch[data-disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +/* D-15: the track's colour IS the state, so focus keeps it and draws the inset edge only. */ +.br-switch:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--border-focus); +} + +.br-switch-thumb { + display: block; + inline-size: 16px; + block-size: 16px; + border-radius: var(--radius-full); + background-color: var(--background-default); + box-shadow: + 0 0 0 1px var(--border-control), + 0 1px 2px rgb(0 0 0 / 0.12); + transform: translateX(2px); + pointer-events: none; + /* The knob moves; its fill and ring snap (principle 9: the track's colour is the one + animated paint, spec 2.6; vision round 1, V4-18). */ + transition: transform var(--dur-fast) var(--ease-out); +} + +.br-switch-thumb[data-state='checked'] { + background-color: var(--text-on-accent); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.12); + transform: translateX(14px); +} + +/* A value that arrived without the person's toggle lands at rest (`ui/switch.tsx`). */ +.br-switch[data-motion-still], +.br-switch[data-motion-still] > .br-switch-thumb { + transition-duration: 0ms; +} + +/* SettingRow and SettingSection (`ui/setting-row.tsx`). The row box itself is + `.biorouter-settings-row`; these place what is inside it. */ +.br-setting-row { + display: flex; + min-inline-size: 0; + align-items: center; + gap: 12px; + /* 10px above and below the line, counting the row's 1px of transparent border room (the + straight-hairline recipe) as part of the bottom 10, so a one-line row is 40px exactly and + its content sits on the row's centre. */ + padding: 10px 12px 9px; +} + +/* A disabled row dims what it SAYS, not its control. The control carries its own disabled look (a + switch is already at 50%), and dimming both compounded to 25%: a locked row's switch fell under + the 3:1 a control owes (seen in the real stylesheet, light, 2026-10-10). */ +.br-setting-row[data-disabled='true'] > .br-setting-row-text, +.br-setting-row[data-disabled='true'] > .br-setting-row-value { + opacity: 0.5; +} + +.br-setting-row-text { + display: flex; + min-inline-size: 0; + flex: 1 1 auto; + flex-direction: column; +} + +.br-setting-row-head { + display: flex; + min-inline-size: 0; + align-items: center; +} + +.br-setting-row-label { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +.br-setting-row-value { + min-inline-size: 0; + max-inline-size: 40%; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.br-setting-row-control { + display: flex; + flex: none; + align-items: center; + /* A 28px button, segmented track or select and a 32px field fit the 40px row: the control may + reach 6px into the row's 10px block padding, so every one-line row is 40px (spec 3.13, + WS-SETTINGS-A-1). The hit box is unchanged; only the row stops growing around it. */ + margin-block: -6px; +} + +/* A SettingRow's value and control sit on the LABEL'S line, not on the row's middle (vision + V4-R3-5, principle 9). A status line ("Restart to apply", "Checking for updates…") grows the row + by one line; centred on the grown row, the switch the person had just clicked slid 8px down + under the pointer. Anchored to the first line, nothing in the row moves and only the space + below grows. A one-line row draws exactly what it drew: the control's box is the label line + plus the 6px it may reach into the padding on each side, its control centred in it, which is + the row's centre; a value's line is centred on the label's. Scoped to the component + (`data-slot`), so a hand-built row with a permanent second line keeps its centred control. */ +.br-setting-row[data-slot='setting-row'] > .br-setting-row-control { + align-self: flex-start; + min-block-size: calc(var(--text-label--line-height) + 12px); +} + +.br-setting-row[data-slot='setting-row'] > .br-setting-row-value { + align-self: flex-start; + margin-block-start: calc( + (var(--text-label--line-height) - var(--text-secondary--line-height)) / 2 + ); +} + +.br-setting-section-header { + display: flex; + min-block-size: 24px; + min-inline-size: 0; + align-items: center; + padding-inline: 12px; +} + +.br-setting-section-title { + margin: 0; +} + +.br-setting-section-action { + display: flex; + flex: none; + align-items: center; + margin-inline-start: auto; +} + +/* RowActions: hidden until the row is hovered or focused or its menu is open; always shown + without a hover pointer. Opacity only, so every action stays in the tab order (Crew's + `.crew-member-actions`). `meta` holds the cell at rest and gives way to the actions. */ +.br-row-actions-slot { + display: inline-grid; + flex: none; + align-items: center; + justify-items: end; +} + +.br-row-actions-slot > .br-row-actions, +.br-row-actions-slot > .br-row-actions-meta { + grid-area: 1 / 1; +} + +.br-row-actions { + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity var(--dur-fast-min) var(--ease-out); +} + +.br-row-actions-meta { + white-space: nowrap; + font-variant-numeric: tabular-nums; + transition: opacity var(--dur-fast-min) var(--ease-out); + /* Text with no behaviour of its own, and it shares the cluster's grid cell. Once the row is + hovered it fades to opacity 0, and an element below opacity 1 is a stacking context that + paints ABOVE the opacity-1 cluster, so the invisible time sat on top of the right half of + the ⋯ button and ate the click (WS-VIEWS-A-3, measured in Chromium with real mouse input). */ + pointer-events: none; +} + +:is(.biorouter-list-row, .biorouter-settings-row, [data-row-actions-host]):is( + :hover, + :focus-within, + [data-state='open'] + ) + .br-row-actions, +.br-row-actions:focus-within, +.br-row-actions[data-state='open'] { + opacity: 1; +} + +:is(.biorouter-list-row, .biorouter-settings-row, [data-row-actions-host]):is( + :hover, + :focus-within, + [data-state='open'] + ) + .br-row-actions-meta, +.br-row-actions-slot[data-state='open'] > .br-row-actions-meta, +.br-row-actions-slot:focus-within > .br-row-actions-meta { + opacity: 0; +} + +@media (hover: none) { + .br-row-actions-slot { + display: inline-flex; + gap: 8px; + } + + .br-row-actions-slot > .br-row-actions, + .br-row-actions-slot > .br-row-actions-meta { + grid-area: auto; + } + + .br-row-actions, + .br-row-actions-meta { + opacity: 1 !important; + } +} + +/* Spinner: one glyph, one period (`--dur-slow × 2`), linear; still under reduced motion. */ +@keyframes br-spin { + to { + transform: rotate(360deg); + } +} + +.br-spinner { + flex: none; + animation: br-spin calc(var(--dur-slow) * 2) linear infinite; +} + +.br-spinner[data-size='14'] { + inline-size: 14px; + block-size: 14px; +} + +.br-spinner[data-size='16'] { + inline-size: 16px; + block-size: 16px; +} + +.br-spinner-status { + display: inline-flex; + flex: none; + align-items: center; +} + +/* A Note's action (Retry, Reconnect, a dismiss mark) is centred on the note's FIRST line rather than + hung from the top of the box, where a 28px button sat 6px below the words beside it. The slot is a + `--control-sm` row pulled in by half its difference from the 12/16 line on both sides, so a 28px + button lines up with a one-line note's words, the note stays as tall as one with no action (36px), + a smaller control is centred in the same row, and a wrapping group of buttons still grows it. */ +.br-note-action { + display: flex; + flex: none; + align-items: center; + min-block-size: var(--control-sm); + margin-block: calc((var(--text-supporting--line-height) - var(--control-sm)) / 2); +} + +/* FilterInput: a 28px field with a leading 16px search glyph. */ +.br-filter-input { + position: relative; + display: inline-flex; + min-inline-size: 0; + align-items: center; + -webkit-app-region: no-drag; +} + +.br-filter-input-icon { + position: absolute; + inset-inline-start: 8px; + inline-size: 16px; + block-size: 16px; + color: var(--text-muted); + pointer-events: none; +} + +.br-filter-input-field { + block-size: var(--control-sm); + padding-inline-start: 28px; +} + +.br-filter-input-field::-webkit-search-cancel-button, +.br-filter-input-field::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +/* THE CONTROL GROUND. A filled control (a field, the Select trigger, a secondary or outline button) + rests on `--br-control-ground`, which is `--background-fill` everywhere except + inside a filled box: there A FILL NEVER SITS ON A FILL (design.md 9.11), so the controls step one + ground back toward the page and rest on `--background-default`, still hovering on + `--background-fill-strong`. A Note (every tone), a Card, a dialog's inner panel and row, and any + caller's filled card that adds `.br-fill-surface` set it for their CHILDREN (custom properties + inherit), so the box keeps its own fill and the controls need no class of their own. A private + variable rather than a re-pointed `--background-fill`: the shared fill tokens are declared once + per mode and nowhere else (`themeNeutrals.test.ts`). */ +.br-fill-surface > *, +.biorouter-modal-panel > *, +.biorouter-modal-row > * { + --br-control-ground: var(--background-default); + --br-control-hover: var(--background-fill-strong); + --br-track-ground: var(--background-fill-strong); +} + +/* THE GROUNDS, said once (WS-SETTINGS-M-3, WS-KNOWLEDGE-2). Three private variables carry what a + ground asks of the primitives on it, and every primitive reads them with today's value as the + fallback, so a plain page draws exactly what it drew: + + - `--br-control-ground`: where a filled control rests (fields, the Select trigger, secondary and + outline buttons, a CopyField, an EmptyState's mark tile). + - `--br-control-hover`: where a filled BUTTON goes under the pointer (`.br-fill-hover`). A field's + hover stays on the strong step: on a recessed ground that equals its rest, because no deeper + neutral keeps the placeholder at 4.5:1 (the band's note, `@ws WS-VIEWS-A begin: page band`). + - `--br-track-ground`: the ground of a TRACK a raised or coloured mark rides (the segmented + track under its thumb, a neutral progress track under its bar). A track is one step deeper + than the ground it sits on, never one step back: a white track would hide a white thumb. + + A filled box (above) steps controls back to the page and tracks one step in. A RECESSED ground, + `.br-ground-recessed`, steps everything one step in: the sidebar, `--background-muted` (first + run, the Knowledge graph canvas, the terminal dock) and the page band, where the rest fill IS the + ground (1.00:1 on muted, 1.03:1 on the sidebar) and the strong step is 1.10:1 and 1.12:1 off + them (design.md 9.11 rule 2). Put the class on the ground itself; a filled box inside it still + sets its own grounds for its children. It comes after the filled-box rule, so an element that is + both takes the class it names. */ +.br-ground-recessed { + --br-control-ground: var(--background-fill-strong); + --br-control-hover: var(--background-strong); + --br-track-ground: var(--background-fill-strong); +} + +/* An EmptyState's 48px mark tile (`ui/empty-state.tsx`) is a filled control's ground: the rest fill + on a page, the page ground in a filled box, the strong step on a recessed ground, where the rest + fill drew nothing and the error and empty states showed a bare icon (WS-KNOWLEDGE-2, measured on + the graph canvas). The transparent 1px border on the element keeps its size and gives forced + colours an edge. */ +.br-empty-tile { + background-color: var(--br-control-ground, var(--background-fill)); +} + +/* Text fields (`Input`, `Textarea`, the schema form's field; the Select trigger draws the same look + through Emotion): FILLS BEFORE LINES (owner message 13, R-27, design.md 9.11). A field rests on + the control ground with its 1px border kept but TRANSPARENT, steps to `--background-fill-strong` + under the pointer, and draws an edge only when it means something: `--border-focus` while + focused (D-15: a field firms an edge, it never takes a ring) and `--border-danger` while invalid, + which wins over focus. The border is never `border-0`: nothing moves between rest and focus, and + forced colours repaint the fill as Canvas and draw the transparent border in the system ink, so + the field keeps its outline there with no rule of its own (`styles/fillTokens.browser.test.ts`). + The fill is not the only cue: a field is also found by its label or placeholder + (`--text-muted`, 4.5:1 or more on both fills) and its place in the form (design.md 9.11). + Hover leaves a focused or disabled field alone, so the box a person is typing in does not change + under the pointer. + + The filled BUTTON (`secondary`, `outline`) takes the same ground from `.br-fill-rest`, and its + hover and press step from `.br-fill-hover`. A neutral badge is NOT on the control ground: it + rests on the strong step (`.br-badge-neutral`, below), a step off the page and a filled card, + and reads its own ground variable, which only a washed row sets (the rows block; badge.tsx). + + Layered (`components`), so a caller's own utility still wins (the forms pass + `border-border-danger` for an invalid field, a toolbar its own `bg-*`); and because `components` + out-ranks the `@layer base` text-field focus rule, the focus and invalid edges are restated + here. */ +@layer components { + .br-field, + .br-fill-rest { + background-color: var(--br-control-ground, var(--background-fill)); + } + + .br-field { + border-color: transparent; + } + + .br-field:hover:not(:focus, :disabled) { + background-color: var(--background-fill-strong); + } + + .br-fill-hover:hover, + .br-fill-hover:active { + background-color: var(--br-control-hover, var(--background-fill-strong)); + } + + /* The neutral Badge (badge.tsx): the strong step, unless the ground names another. Only a + washed list or settings row does, with the page ground (the rows block, V3-R2-5). Layered + with the rest so a caller's own `bg-*` (the privacy pills' muted rest) still wins. */ + .br-badge-neutral { + background-color: var(--br-badge-ground, var(--background-fill-strong)); + } + + .br-field:focus, + .br-field:focus-visible { + border-color: var(--border-focus); + } + + .br-field[aria-invalid='true'] { + border-color: var(--border-danger); + } + + /* Textarea's defaults, layered with the edge above for the same reason: a caller's own + `min-h-*`, `h-*` or `resize-none` must win. Unlayered, this rule outranked every utility on + the element and a `resize-none` was ignored (the workflow editors measured 74px in an 800px + dialog; MessageQueue and the hints editor kept their resize handle). */ + .br-textarea { + min-block-size: calc(3 * var(--text-body--line-height, 20px) + 14px); + resize: vertical; + } +} + +/* Tabs (`ui/tabs.tsx`): one underline that travels under the active trigger. Until the list + has measured it (`data-placed='false'`: jsdom, a hidden ancestor, the first paint), each + trigger's own `::after` bar is the underline; afterwards the bars stand down and the + indicator moves by `translate` and `width` over `--dur-fast-max` (a named exception in + principle 9). Keyboard focus firms it from 2px to 3px, as it firmed the trigger's bar. */ +/* A default a caller's `sticky` (Crew's details pane) must be able to replace, hence the layer. */ +@layer components { + .br-tabs-list { + position: relative; + } +} + +.br-tabs-list[data-placed='true'] > [role='tab']::after { + background-color: transparent; +} + +.br-tabs-indicator { + position: absolute; + inset-inline-start: 0; + bottom: -1px; + block-size: 2px; + background-color: var(--accent-bar); + pointer-events: none; + transition: + translate var(--dur-fast-max) var(--ease-out), + width var(--dur-fast-max) var(--ease-out), + block-size var(--dur-fast) var(--ease-out); +} + +.br-tabs-indicator[data-placed='false'] { + display: none; +} + +.br-tabs-indicator[data-motion-still] { + transition: none; +} + +.br-tabs-list:has(> [role='tab']:focus-visible) > .br-tabs-indicator { + block-size: 3px; +} + +/* A panel appears at rest when its tab is picked (principle 9, V4-17); only a panel its surface + marks `data-entering` (the person's own pick, cleared on `animationend`) fades in. */ +.br-tabs-content[data-entering] { + animation: br-fade-in var(--dur-fast-max) var(--ease-out) both; +} + +/* ScrollArea (`ui/scroll-area.tsx`): the viewport's native scrollbar rests hidden and shows + while the pointer is over the area or it is scrolling (`data-scrolling`, set by the + component). `scrollbar-color` for Chromium's standard scrollbar, the thumb pseudo-element for + the WebKit one; both beat the global `*` rule by specificity. */ +.br-scroll-viewport { + scrollbar-color: transparent transparent; +} + +.br-scroll-viewport::-webkit-scrollbar-thumb { + background-color: transparent; +} + +.br-scroll-root:is(:hover, [data-scrolling]) > .br-scroll-viewport { + scrollbar-color: var(--border-strong) transparent; +} + +.br-scroll-root:is(:hover, [data-scrolling]) > .br-scroll-viewport::-webkit-scrollbar-thumb { + background-color: var(--border-strong); +} + +/* Button `size="xs"` (24px): a 14px glyph unless the call site sized it. */ +.br-button-xs svg:not([class*='size-']) { + inline-size: 14px; + block-size: 14px; +} + +/* A primary that cannot act is neutral (vision V4-R7-4, coordinator ruling 22; design.md 9.7). + `default` (coral) and `destructive` (danger) buttons carry `.br-button-inert` (button.tsx); + disabled, they draw what a disabled `secondary` draws: the control ground, the default ink, + and the 50% fade `Button` already applies to every disabled variant. Without it the app had + two looks for "not yet": the catalog dialogs drew a secondary until their fields were filled + (FR-R3-3), while Save changes, Create and run and Reset drew a pale coral or a faded danger + wash, so colour did not mean "this will do something". + + The ground is `--br-control-ground`, the variable a secondary rests on, so the inert button + steps back to the page inside a filled card and forward on a recessed ground exactly as its + neighbour Cancel does (a fill never sits on a fill). Unlayered, because the variant's own + `bg-*` and `text-*` utilities live in the `utilities` layer and would win a layered rule. A + button that is WORKING (`aria-busy`) keeps its colour: the composer's Send is disabled while a + chat starts and must stay the accent with its spinner. `aria-disabled` is named too, for the + buttons that fade without the attribute (the Copilot permission check). Forced colours repaint + the fill and the ink, so the system button edge is all this leaves to them. */ +.br-button-inert:is(:disabled, [aria-disabled='true']):not([aria-busy='true']) { + background-color: var(--br-control-ground, var(--background-fill)); + color: var(--text-default); +} + +/* Checkbox (`ui/Checkbox.tsx`): a 16px box (radius 4, a 1.5px `--border-control` edge) inside a + 24px hit target; checked or indeterminate, an accent fill carrying a 10px check or an 8×2 bar + in `--text-on-accent`. The fill eases over `--dur-fast-min`. The transparent input on top is + the target; the box draws its state and its focus. */ +.br-checkbox { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + inline-size: 24px; + block-size: 24px; +} + +.br-checkbox[data-disabled] { + opacity: 0.5; +} + +.br-checkbox-input { + position: absolute; + inset: 0; + margin: 0; + inline-size: 100%; + block-size: 100%; + appearance: none; + opacity: 0; +} + +.br-checkbox-box { + position: absolute; + inset: 4px; + border: 1.5px solid var(--border-control); + border-radius: var(--radius-inner); + background-color: transparent; + transition: + background-color var(--dur-fast-min) var(--ease-out), + border-color var(--dur-fast-min) var(--ease-out); +} + +.br-checkbox-input:checked ~ .br-checkbox-box, +.br-checkbox-input:indeterminate ~ .br-checkbox-box { + border-color: var(--background-accent); + background-color: var(--background-accent); +} + +/* The input is transparent, so its focus is drawn on the box, as the radio's is on its ring. */ +.br-checkbox-input:focus-visible ~ .br-checkbox-box { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.br-checkbox-check { + position: relative; + inline-size: 10px; + block-size: 10px; + color: var(--text-on-accent); + opacity: 0; + transition: opacity var(--dur-fast-min) var(--ease-out); +} + +.br-checkbox-bar { + position: absolute; + inline-size: 8px; + block-size: 2px; + border-radius: var(--radius-full); + background-color: var(--text-on-accent); + opacity: 0; + transition: opacity var(--dur-fast-min) var(--ease-out); +} + +.br-checkbox-input:checked:not(:indeterminate) ~ .br-checkbox-check, +.br-checkbox-input:indeterminate ~ .br-checkbox-bar { + opacity: 1; +} + +/* Radio (`ui/CustomRadio.tsx`): a 16px ring in the checkbox's box, a 6px dot that scales from + 0.6 to 1 over `--dur-fast`. The ring's COLOUR is the base rule on `[data-radio-ring]` + (WS-TOKENS, measured in `focusFallback.test.ts`); only its size is here. */ +.br-radio-ring { + inset: 4px; + transition: border-color var(--dur-fast-min) var(--ease-out); +} + +.br-radio-dot { + inline-size: 6px; + block-size: 6px; + background-color: var(--background-accent); + opacity: 0; + transform: scale(0.6); + transition: + opacity var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} + +input[type='radio']:checked ~ .br-radio-dot { + opacity: 1; + transform: none; +} + +@media (forced-colors: active) { + .br-checkbox-box { + forced-color-adjust: none; + border-color: CanvasText; + } + + .br-checkbox-input:checked ~ .br-checkbox-box, + .br-checkbox-input:indeterminate ~ .br-checkbox-box { + border-color: Highlight; + background-color: Highlight; + } + + .br-checkbox-input:disabled ~ .br-checkbox-box { + border-color: GrayText; + } + + .br-checkbox-check, + .br-checkbox-bar { + forced-color-adjust: none; + color: HighlightText; + } + + .br-checkbox-bar { + background-color: HighlightText; + } + + .br-checkbox-input:focus-visible ~ .br-checkbox-box { + outline-color: Highlight; + } +} + +/* Forced colours (principle 12). They replace every background colour with Canvas and drop box + shadows, so anything whose shape or state was a fill disappears: the switch (a fill and a + shadow ring), the segmented thumb, the tab underline, the progress bar. Each is given back in + a system colour, which is kept as written. Measured with Playwright's `forcedColors: 'active'` + (`ui/controlsGeometry.browser.test.tsx`). The focus ring is the global OS-requested block's. */ +@media (forced-colors: active) { + /* A row keeps 1px of transparent border room for its straight hairline. Forced colours repaint + that border in the text colour, and on a rounded box it curled up at both ends. Canvas is a + system colour, so it stays as written and the border shows as nothing; the `::after` line + is the divider. */ + .biorouter-list-row, + .biorouter-settings-row { + border-bottom-color: Canvas; + } + + /* Switch: an outlined track, a filled one when on; the knob is the text colour, or the + on-ink. The 1px edge takes 1px of the inner box, so the knob travels 1px less each way and + sits 2px from the outer edge in both states. */ + .br-switch { + forced-color-adjust: none; + border: 1px solid CanvasText; + background-color: Canvas; + } + + .br-switch[data-state='checked'] { + border-color: Highlight; + background-color: Highlight; + } + + .br-switch:disabled, + .br-switch[data-disabled] { + border-color: GrayText; + background-color: Canvas; + opacity: 1; + } + + .br-switch-thumb { + forced-color-adjust: none; + background-color: CanvasText; + box-shadow: none; + transform: translateX(1px); + } -/* The heatmap is never folded away (it scales its cells instead — see - UsageHeatmap). As the window gets short, only the hero compacts, then - yields entirely so the heatmap keeps a legible box. */ -@container biorouter-home-content (max-height: 720px) { - .biorouter-home-hero { - padding-top: 56px; - padding-bottom: 16px; + .br-switch-thumb[data-state='checked'] { + background-color: HighlightText; + transform: translateX(13px); } - .biorouter-home-activity { - padding-bottom: 16px; + .br-switch:disabled .br-switch-thumb, + .br-switch[data-disabled] .br-switch-thumb { + background-color: GrayText; } -} -@container biorouter-home-content (max-height: 560px) { - .biorouter-home-hero { - /* Keep clear of the floating titlebar/sidebar controls at top-left. */ - padding-top: 44px; - padding-bottom: 12px; + /* Segmented control: the chosen segment is outlined inside the track, in Highlight. The thumb + is a fill and a shadow, so it stands down. */ + .br-segmented { + outline: 1px solid CanvasText; + outline-offset: -1px; } -} -@container biorouter-home-content (max-height: 340px) { - .biorouter-home-hero { + .br-segmented-thumb { display: none; } - /* With the hero gone the heatmap is the first content: keep its header - clear of the floating titlebar/sidebar controls at top-left. */ - .biorouter-home-activity { - padding-top: 44px; + .br-segmented-item[data-state='checked'] { + outline: 2px solid Highlight; + outline-offset: -2px; } -} -.biorouter-settings-tabs { - background: transparent; - box-shadow: none; -} + /* The tab underline is one accent fill that travels. */ + .br-tabs-indicator { + forced-color-adjust: none; + background-color: Highlight; + } -/* A scroller whose top edge is FLUSH against another rule, faded so a clipped - row cannot masquerade as one. - * - * ⚠ Written for a specific defect, not as decoration. In Settings the tab - * strip's bottom edge and the scroll viewport's top edge are the same y — the - * strip carries `border-b-0` on purpose (§4.2: its own hairline landed a few - * pixels under the header's and read as a double rule), so there is nothing - * between them. Scroll the App tab to 663px at 1440x900 and the last 1px of the - * "Configuration guide" link survives the clip as an accent-coloured hairline - * sitting beside the active tab's underline, at the same y and in the same - * colour: a second, thinner tab indicator. A ~3px window of scroll positions - * produces it (660 shows 4px of the link, 666 shows none). - * - * ⚠ The mask is applied to the VIEWPORT, not the Root, and only while the - * scroller is actually scrolled. Masking the Root would fade the scrollbar with - * the content; fading unconditionally would dim the first row of a page nobody - * has scrolled, which is the one place a page has nothing to hide. The hook is - * `data-scrolled`, which `ui/scroll-area.tsx` has always written from - * `scrollTop > 0` and which nothing consumed until now. - * - * ⚠ Authored CSS, not a Tailwind arbitrary variant. `mask-image: - * linear-gradient(...)` inside a `data-[scrolled=true]:[...]` utility is a - * newly written class string, and a newly written class can silently fail to - * generate — see `styles/composerFocus.test.ts`, where three spellings were - * each measured in the running app with the class on the element and no rule - * anywhere in the stylesheet. - * - * It is a mask rather than an overlay gradient for one reason: an overlay has - * to be painted in the ground's colour, and the ground differs per theme - * family and per mode. A mask removes alpha, so it is correct in light and dark - * of all three families without a single colour being named. - * - * `scroll-padding-top` is the other half: without it, tabbing to a control that - * is scrolled just off the top lands it at y=0, inside the fade. With it the - * browser stops the scroll a fade's height short, so a focus ring is never the - * thing being faded out. */ -.biorouter-scroll-fade-top > [data-radix-scroll-area-viewport] { - scroll-padding-top: var(--scroll-fade-top); -} + /* Slider: the track and its marks in the text colour, the fill and the thumb in Highlight, the + thumb's ring in Canvas; keyboard focus outlines the thumb. */ + .br-slider-track::before, + .br-slider-track > .br-slider-mark, + .br-slider-fill::before, + .br-slider-fill > .br-slider-mark, + .br-slider-thumb { + forced-color-adjust: none; + } -.biorouter-scroll-fade-top[data-scrolled='true'] > [data-radix-scroll-area-viewport] { - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0, - transparent var(--scroll-fade-clear), - #000 var(--scroll-fade-top) - ); - mask-image: linear-gradient( - to bottom, - transparent 0, - transparent var(--scroll-fade-clear), - #000 var(--scroll-fade-top) - ); -} + .br-slider-track::before, + .br-slider-track > .br-slider-mark { + background-color: CanvasText; + } -.biorouter-settings-section { - padding-block: 14px; -} + .br-slider-fill::before, + .br-slider-fill > .br-slider-mark, + .br-slider-thumb { + background-color: Highlight; + } -.biorouter-settings-section + .biorouter-settings-section { - margin-top: 10px; -} + .br-slider-thumb { + box-shadow: 0 0 0 2px Canvas; + } -.biorouter-settings-section-header { - margin-bottom: 8px; -} + .br-slider-thumb:focus-visible:not([data-pointer-focus]) { + box-shadow: 0 0 0 2px Canvas; + outline: 2px solid CanvasText; + outline-offset: 2px; + } -.biorouter-settings-list { - display: flex; - flex-direction: column; - border-radius: 0; - background: transparent; - box-shadow: none; -} + .br-slider[data-disabled] { + opacity: 1; + } -.biorouter-settings-control-strip { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - border-radius: 0; - background: transparent; - padding: 0; - box-shadow: none; -} + .br-slider[data-disabled] .br-slider-fill::before, + .br-slider[data-disabled] .br-slider-fill > .br-slider-mark, + .br-slider[data-disabled] .br-slider-thumb { + background-color: GrayText; + } -.biorouter-settings-row { - border-bottom: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - min-height: var(--row-height); /* D-12·B */ - /* `--tint-ink` is in the list for the reason the note above - `tint-interactive` (end of this file) records: Chrome does not interpolate - `background-image`, so a selection wash composed with `tint-selected` is - carried by that registered custom property instead. A row whose own - transition list omits it REPLACES the utility's fallback and gets a - selection that snaps beside a hover that eases — two behaviours in one - control. */ - transition: - background-color var(--motion-fast) var(--ease-out), - color var(--motion-fast) var(--ease-out), - --tint-ink var(--motion-fast) var(--ease-out); -} + /* Progress: an outlined track and a Highlight fill. */ + .br-progress { + forced-color-adjust: none; + border: 1px solid CanvasText; + background-color: Canvas; + } -.biorouter-settings-row:last-child { - border-bottom-color: transparent; -} + .br-progress__fill { + forced-color-adjust: none; + background-color: Highlight; + } -/* ⚠ `background-color`, NOT the `background` shorthand, and it skips a tinted - row. Both halves are load-bearing and both were bugs. - This rule is UNLAYERED, so it beats anything inside `@layer utilities` - regardless of specificity — including `tint-selected`, whose wash is a - `background-image`. The shorthand reset that image to `none`, so pointing at - a selected row erased its selection: the same inversion recorded above - `.tint-selected.tint-interactive`, arriving by a different route. Narrowing - the property leaves the tint alone, and `:not(.tint-selected)` then hands a - selected row's hover to that composed pair, which answers the pointer by - DARKENING rather than by dropping a step. */ -.biorouter-settings-row:not(.tint-selected):hover, -.biorouter-settings-row:not(.tint-selected):focus-within { - background-color: color-mix(in srgb, var(--background-medium) 38%, transparent); + /* A floating InfoTip is an inverse fill with no border, like the tooltip it is drawn on. */ + [data-slot='info-tip-content'] { + border: 1px solid CanvasText; + } } -/* No dark-mode override needed: the block is flat and token-driven in both themes. */ +@media (prefers-reduced-motion: reduce) { + .br-spinner { + animation: none; + } + + .br-menu-motion[data-state], + .br-menu-motion[data-state][data-side] { + --tw-enter-scale: 1; + --tw-enter-translate-x: 0; + --tw-enter-translate-y: 0; + } + + .br-switch, + .br-switch-thumb, + .br-tabs-indicator, + .br-checkbox-box, + .br-checkbox-check, + .br-checkbox-bar, + .br-radio-ring, + .br-radio-dot, + .br-segmented-thumb, + .br-segmented-item, + .br-tabs-content[data-entering], + .br-slider-fill, + .br-slider-thumb-slot, + .br-row-actions, + .br-row-actions-meta, + .br-info-tip { + transition: none; + } +} +/* @ws WS-PRIMITIVES end: primitives */ /* D-09 / P1 — two-tone sidebar, separated by a hairline. The gradient edge overlay and the dark-mode drop shadow are gone: the warm surface step already @@ -3701,6 +6416,40 @@ input[type='radio']:checked ~ [data-radio-ring] { box-shadow: 1px 0 0 0 var(--sidebar-border); } +/* @ws WS-SIDEBAR begin: sidebar open and close motion */ +/* The sidebar opens over `--dur-med` (300ms) and closes over `--dur-fast-max` + (175ms): a transition runs with the duration of the state it is going TO, so + the collapsed state carries the shorter one. One curve, `--ease-out`. The + three boxes that move are the gap (its width, a named exception in principle + 9), the panel (its `translate`) and the inset (its margin). Each carries + `data-motion-layout`, so the resize rule above stops all three during a + window or sidebar drag, and the global reduced-motion reset nulls them. + ⚠ The panel moves by `translate`, not `transform`: Tailwind v4's + `translate-x-[-100%]` writes the individual `translate` property, so a + transition on `transform` alone never ran and the panel jumped while the + gap beside it eased (vision V1-H3). Both are listed, so a future utility + that moves it by `transform` eases too. */ +[data-slot='sidebar-gap'] { + transition: width var(--dur-med) var(--ease-out); +} + +[data-slot='sidebar-container'] { + transition-property: translate, transform; + transition-duration: var(--dur-med); + transition-timing-function: var(--ease-out); +} + +[data-slot='sidebar-inset'] { + transition: margin-left var(--dur-med) var(--ease-out); +} + +[data-slot='sidebar'][data-state='collapsed'] > [data-slot='sidebar-gap'], +[data-slot='sidebar'][data-state='collapsed'] > [data-slot='sidebar-container'], +[data-slot='sidebar'][data-state='collapsed'] ~ [data-slot='sidebar-inset'] { + transition-duration: var(--dur-fast-max); +} +/* @ws WS-SIDEBAR end: sidebar open and close motion */ + /* The sidebar's drag edge (components/ui/sidebar.tsx `SidebarResizeHandle`). ⚠ AUTHORED CSS, NOT A TAILWIND UTILITY STRING, for the same reason as `.biorouter-composer-card` below: the hover hairline and the resize cursor are @@ -3729,7 +6478,9 @@ input[type='radio']:checked ~ [data-radio-ring] { right: 0; width: 2px; background: transparent; - transition: background-color var(--motion-fast) var(--ease-out); + /* @ws WS-SIDEBAR begin: resize hairline token */ + transition: background-color var(--dur-fast) var(--ease-out); + /* @ws WS-SIDEBAR end: resize hairline token */ } .biorouter-sidebar-resize-handle:hover::after, @@ -3853,10 +6604,13 @@ body.biorouter-headless-browser .biorouter-chat-composer { max-width: min(760px, calc(100vw - 32px)) !important; } -body.biorouter-headless-browser .biorouter-chat-composer-bar { - padding-left: clamp(16px, 2vw, 32px) !important; - padding-right: clamp(16px, 2vw, 32px) !important; -} +/* @ws WS-TOKENS begin: browser composer gutter */ +/* No gutter override for `.biorouter-chat-composer-bar` here, on purpose. The bar's inline side + padding is the transcript's own chrome (half `CHAT_COLUMN_CHROME`, BaseChat.tsx), and this + surface leaves the transcript's padding alone, so the composer keeps the column's edges at every + width. A `clamp(16px, 2vw, 32px)` override used to sit here and moved the composer up to 12px + off the column below an 816px cell (WS-SUMMARY-1). styles/browserSurfaceGutter.browser.test.ts. */ +/* @ws WS-TOKENS end: browser composer gutter */ body.biorouter-headless-browser .biorouter-route-surface[data-route-path='/pair'], body.biorouter-headless-browser .biorouter-route-surface[data-route-path='/'] { @@ -3912,11 +6666,43 @@ body.biorouter-sidebar-compact [data-slot='sidebar-inset'] { /* A collapsed sidebar overlays the canvas, so it is a floating surface. The old shadow used rgba(15,23,42,…) — Tailwind's cold slate-900, foreign to the warm ramp. */ +/* @ws WS-SIDEBAR begin: overlay shadow only while open */ +/* The shadow belongs to the OPEN overlay only. The closed panel is translated + to x=-288 but stays painted above the page (`--z-sticky`), so a shadow on it + bled a 12px grey band down the window's left edge on every view (vision + V1-M1, V3-24). Closed, it wears nothing: not the shadow, and not the docked + shell's 1px `--sidebar-border` line either, which would sit at x=0. */ body.biorouter-sidebar-compact [data-slot='sidebar-container'] { z-index: var(--z-sticky); + box-shadow: none; +} + +body.biorouter-sidebar-compact + [data-slot='sidebar'][data-state='expanded'] + > [data-slot='sidebar-container'] { box-shadow: var(--shadow-popover); } +/* …and it keeps that shadow while it slides out (vision V1-R2-9). Keyed on + the state alone, the shadow went in the first collapsed frame, with the panel + still at x=0, so its edge went flat in one frame and the panel slid out flat + over the page. Closing, the shadow now goes in ONE step at the end of the + slide (`step-end` over the slide's own duration), never faded: principle 9 + animates transform, opacity and clip-path, and a shadow is none of them. + A duration, not a delay: WS-TOKENS' resize rule and the reduced-motion reset + shorten durations and leave delays alone, so a delayed shadow would linger + on a panel that had already snapped away; this one goes with it. + Opening needs nothing: the expanded state names no shadow transition, so the + shadow is there from the first frame. */ +body.biorouter-sidebar-compact + [data-slot='sidebar'][data-state='collapsed'] + > [data-slot='sidebar-container'] { + transition-property: translate, transform, box-shadow; + transition-duration: var(--dur-fast-max); + transition-timing-function: var(--ease-out), var(--ease-out), step-end; +} +/* @ws WS-SIDEBAR end: overlay shadow only while open */ + /* …and it has an EDGE where it meets the page (QA Q3-60). The rule above replaces the docked column's `--sidebar-border` hairline with the popover shadow, and in dark a black shadow on a near-black page is nothing: the @@ -4199,7 +6985,7 @@ body.biorouter-window-resizing .biorouter-sidebar-shell, body.biorouter-window-resizing [data-slot='sidebar-gap'], body.biorouter-window-resizing [data-testid='artifact-viewer'], /* Dragging the sidebar's own edge needs the same treatment, and for a sharper - reason: `sidebar-gap` carries `transition-[width]` at --motion-slow, so + reason: `sidebar-gap` carries `transition-[width]` at --dur-med, so without this the column the sidebar occupies eases toward each frame's width and the edge visibly trails the pointer by a third of a second. */ body.biorouter-sidebar-resizing .biorouter-sidebar-inset-depth, @@ -4209,6 +6995,15 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { transition-duration: 0ms !important; } +/* @ws WS-PREVIEW begin: fold chevron motion */ +/* The folded sheet's chevron turns rather than flips (spec 3.8, P0 4). Written + here, after the resizing block, because the rung-2 block above must carry no + motion at all (measures.test.ts). `rotate` is a transform. */ +.br-preview-fold-toggle > svg { + transition: rotate var(--dur-fast) var(--ease-out); +} +/* @ws WS-PREVIEW end: fold chevron motion */ + @media (max-height: 520px) { .biorouter-clean-conversation { align-items: flex-start; @@ -4282,300 +7077,344 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { --tw-prose-invert-td-borders: var(--border-subtle); } -/* Shared markdown typography. Documents use a larger title, while the rhythm, - callouts, tables and code wells match their chat counterparts. */ -.biorouter-markdown.prose { - --md-code-pad: 14px 16px; - --md-title-size: calc(24px * var(--app-font-scale, 1)); - font-size: 14px; - line-height: 1.7; -} +/* @ws WS-TRANSCRIPT begin: markdown */ +/* Shared markdown typography: chat answers, documents in the preview, notes. -.biorouter-markdown.prose[data-variant='document'] { - --md-title-size: calc(28px * var(--app-font-scale, 1)); - font-size: 15px; + One prose line, 14px on 21px (`--text-prose`), the same reading line as + Crew's messages (`.crew-md`). Chat headings stop at 17px (`--text-subheading`) + because an answer is a document, not a page; a document's h1 is the 24px + title (`--text-title`). Sizes are the roles' own tokens, so the scale is + changed in one place. Nothing here carries the + accent: no bar beside a heading and no tinted quote. The accent marks state + (spec principle 7). Spacing is on the 4px grid in px, so a heading's gap does + not drift with its own size. */ +.biorouter-markdown.prose { + font-size: var(--text-prose); + line-height: var(--text-prose--line-height); } .biorouter-markdown.prose :where(h1, h2, h3, h4, h5, h6) { color: var(--text-default); - font-family: var(--font-sans); - font-weight: 700; - line-height: 1.35; - letter-spacing: -0.015em; + font-family: var(--font-heading); + font-weight: 600; + letter-spacing: 0; } .biorouter-markdown.prose :where(h1, h2) { - position: relative; - padding-left: 0.7em; -} - -.biorouter-markdown.prose :where(h1, h2)::before { - content: ''; - position: absolute; - left: 0; - top: 0.2em; - width: 3px; - height: 0.85em; - border-radius: 2px; - background: var(--accent-bar); -} - -.biorouter-markdown.prose h1 { - margin: 1.4em 0 0.7em; - font-size: var(--md-title-size); - line-height: 1.25; + margin: 20px 0 8px; + font-size: var(--text-subheading); + line-height: var(--text-subheading--line-height); } -.biorouter-markdown.prose h2 { - margin: 1.6em 0 0.65em; - font-size: 19px; +.biorouter-markdown.prose :where(h3, h4, h5, h6) { + margin: 16px 0 4px; + font-size: var(--text-prose); + line-height: var(--text-prose--line-height); } -.biorouter-markdown.prose h3 { - margin: 1.4em 0 0.5em; - font-size: 16px; +/* A document's first heading is its title: the 24px step, regular weight. */ +.biorouter-markdown.prose[data-variant='document'] h1 { + margin: 24px 0 12px; + font-size: var(--text-title); + line-height: var(--text-title--line-height); + font-weight: var(--text-title--font-weight); + letter-spacing: var(--text-title--letter-spacing); } -.biorouter-markdown.prose :where(h4, h5, h6) { - margin: 1.3em 0 0.5em; - font-size: 14px; +/* A document whose front matter already names it: its first `# H1` would be a + second title, so it takes the h2 step (`demoteFirstHeading`). */ +.biorouter-markdown.prose[data-variant='document'][data-demote-h1] h1:first-of-type { + margin: 20px 0 8px; + font-size: var(--text-subheading); + line-height: var(--text-subheading--line-height); + font-weight: var(--text-subheading--font-weight); letter-spacing: 0; } .biorouter-markdown.prose p { - margin: 0 0 0.85em; + margin: 0 0 12px; } .biorouter-markdown.prose :where(ul, ol) { - margin: 0 0 1em; + margin: 0 0 12px; padding-left: 1.5em; } .biorouter-markdown.prose li { - margin: 0.3em 0; - padding-left: 0.2em; -} - -.biorouter-markdown.prose li > :where(ul, ol) { - margin: 0.45em 0 0.65em; + margin: 4px 0; + padding-left: 2px; } +.biorouter-markdown.prose li > :where(ul, ol), .biorouter-markdown.prose li > p { - margin: 0.5em 0; + margin: 4px 0; } -.biorouter-markdown.prose blockquote { - margin: 1.25em 0; - padding: 1em 1.2em; - border-left: 3px solid var(--accent-bar); - border-radius: 0 10px 10px 0; - background: color-mix(in srgb, var(--accent-bar) 6%, var(--background-default)); - color: var(--text-default); - font-style: normal; - font-weight: 400; +/* A task list (`- [x]`, `- [ ]`) draws the summary rail's glyphs, a 14px check + or circle in muted ink, never a native checkbox (`MarkdownInputSlot`). The + glyph hangs in the bullet gutter where the bullet would be (20px: 14px glyph + plus a 6px gap), so the item's text keeps the left edge of the lists around + it. */ +.biorouter-markdown.prose li.task-list-item { + list-style: none; } -.biorouter-markdown.prose blockquote > :last-child { - margin-bottom: 0; +.biorouter-markdown .br-md-task { + display: inline-flex; + width: 14px; + height: var(--text-prose--line-height); + margin-right: 6px; + margin-left: -20px; + align-items: center; + justify-content: center; + color: var(--text-muted); + vertical-align: top; } -.biorouter-markdown.prose hr { - margin: 2em 0; - border-color: var(--border-subtle); +.biorouter-markdown .br-md-task > svg { + width: 14px; + height: 14px; } -.biorouter-markdown.prose > :first-child, -.biorouter-markdown.prose > .biorouter-md-pre:first-child > .biorouter-md-code { - margin-top: 0; +/* A quote is an edge and muted ink, never a tinted box. */ +.biorouter-markdown.prose blockquote { + margin: 12px 0; + padding: 0 0 0 12px; + border-left: 2px solid var(--border-strong); + color: var(--text-muted); + font-style: normal; + font-weight: 400; + quotes: none; } -.biorouter-markdown.prose > :last-child { +.biorouter-markdown.prose blockquote > :last-child { margin-bottom: 0; } -.biorouter-markdown .biorouter-inline-code { - word-break: normal; - overflow-wrap: anywhere; -} - -.biorouter-markdown .biorouter-md-code { - margin: 1em 0 1.25em; - border: 1px solid var(--border-subtle); - border-radius: 10px; -} - -.biorouter-markdown .biorouter-md-code-head { - min-height: 36px; - gap: 12px; - padding: 4px 10px 4px 16px; - border-bottom: 1px solid var(--border-subtle); - background: var(--background-muted); -} - -.biorouter-markdown .biorouter-md-code-lang { - font-family: var(--font-mono); - letter-spacing: 0; - text-transform: none; -} - -.biorouter-markdown .biorouter-md-code-body { - background: - linear-gradient(to left, var(--background-code) 45%, transparent) 100% 0 / 36px 100% - no-repeat local, - linear-gradient(to left, var(--border-subtle), transparent) 100% 0 / 14px 100% no-repeat - scroll; -} - -.biorouter-markdown .biorouter-md-table-scroll { - max-width: 100%; - margin: 1.2em 0 1.5em; - overflow-x: auto; - border-radius: 8px 8px 0 0; -} - -.biorouter-markdown .biorouter-md-table-scroll:focus-visible, -.biorouter-markdown .biorouter-md-code-body:focus-visible { - outline: 2px solid var(--border-focus); - outline-offset: -2px; -} - -.biorouter-markdown.prose table { - width: 100%; - margin: 0; - border-collapse: collapse; - font-size: 13px; - line-height: 1.6; - font-variant-numeric: tabular-nums; -} - -.biorouter-markdown.prose :where(th, td) { - padding: 11px 16px; - border: 0; - overflow-wrap: normal; - word-break: normal; - vertical-align: top; +.biorouter-markdown.prose hr { + margin: 24px 0; + border-color: var(--border-subtle); } -/* Tokens, not literals. These four were hex, and three of the four matched no - token in any theme file — dark `--background-medium` is #2c2c29, not #353533, - and Parchment dark's `--text-default` is the warm #f4f0e6, not #eeeeeb. So a - table header was a cool grey among warm ink in Parchment dark, and would stay - on Parchment's values under Alma Mater and Roche Limit. `npm run themes` - generates the token blocks and `check-contrast.mjs` discovers families from - this stylesheet; a literal is invisible to both. - The pairing is already guaranteed: check-contrast asserts `--text-default` on - `--background-medium` at 4.5:1 in all six scopes. (`--text-subtle` on that - ground is the one that is sub-AA — do not reach for it here.) */ -.biorouter-markdown.prose thead { - background: var(--background-medium); +/* The first and last block sit flush with the message's own edges. A fenced + block is wrapped (`.biorouter-md-pre`), so its margin is reached through the + wrapper. */ +.biorouter-markdown.prose > :first-child, +.biorouter-markdown.prose > .biorouter-md-pre:first-child > .biorouter-md-code { + margin-top: 0; } -.biorouter-markdown.prose thead th { - color: var(--text-default); - font-weight: 600; - font-size: 13px; - letter-spacing: 0; - text-transform: none; - white-space: nowrap; - text-align: left; +.biorouter-markdown.prose > :last-child, +.biorouter-markdown.prose > .biorouter-md-pre:last-child > .biorouter-md-code { + margin-bottom: 0; } -.biorouter-markdown.prose thead th :where(strong, em, a, button:not(.biorouter-inline-code), span:not(.biorouter-inline-code)) { - color: inherit; +/* Inline code: one chip, Crew's (`.crew-md-code-inline`). It is authored here + rather than as `prose-code:*` utilities on the wrapper, so its size is the + code role and nothing depends on Tailwind having scanned a new class. */ +.biorouter-markdown .biorouter-inline-code { + padding: 1px 4px; + border-radius: var(--radius-inner); + background-color: var(--background-medium); + font-family: var(--font-mono); + font-size: var(--text-code); + font-weight: 400; + word-break: normal; + overflow-wrap: anywhere; } -.biorouter-markdown.prose tbody td { - border-bottom: 1px solid var(--border-subtle); - color: var(--text-default); +/* A code span in a table cell is plain mono, not a chip (Crew, design.md §4.17). */ +.biorouter-markdown .br-md-table .biorouter-inline-code { + padding: 0; + background-color: transparent; + overflow-wrap: normal; } -.biorouter-markdown.prose tbody td:first-child { - font-weight: 600; +/* Fenced code and tables keep the prose rhythm around them. */ +.biorouter-markdown .biorouter-md-code, +.biorouter-markdown .br-md-table-scroll { + margin: 12px 0; } -.biorouter-markdown.prose tbody tr:last-child td { - border-bottom: 0; +.biorouter-markdown .br-md-table { + width: auto; + margin: 0; } -.biorouter-markdown.prose :where(th, td)[align='right'] { +.biorouter-markdown .br-md-table :where(th, td)[align='right'] { text-align: right; } -.biorouter-markdown.prose :where(th, td)[align='center'] { +.biorouter-markdown .br-md-table :where(th, td)[align='center'] { text-align: center; } +/* @ws WS-TRANSCRIPT end: markdown */ + +/* @ws WS-TRANSCRIPT begin: md recipes */ +/* The code block and the table, promoted from Crew so the app has one look for + each (spec section 0, decision 6.15). Copied from + `crew/timeline/timeline.css` (`.crew-md-code*`, `.crew-md-table*`); a parity + test keeps the two equal apart from the differences named below + (`transcriptMarkdown.test.ts`), so any other change is made to both. Chat and + documents use them through `MarkdownContent`; the preview's CSV and notebook + DataFrames use `.br-md-table` too. + + Deliberate differences from Crew, which is the reference and is not + converted: table cells are 8px 12px, on the spacing scale, where Crew's are + 11px 16px (Crew defect D11); and FILLS BEFORE LINES (owner message 13, R-27, + design.md 9.11): the block and the table are a subtle solid fill with no + frame, where Crew draws a hairline around each. The 1px borders stay, + transparent, so no geometry moves and forced colours still draw the boxes in + the system ink. Only the table's rules between rows remain, because they are + inside a filled box and they are what makes rows of a table. */ + +/* A fenced block sits in the well, never on `--background-code`, which in dark + mode is the page itself. The well is the code's fill: it equals + `--background-fill` in dark and is one unit lighter in light, and it is the + ground the syntax palettes are measured against (`themes/*.theme.mjs`), so a + code block keeps it rather than moving to a ground nobody measured. */ +.br-md-code { + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--radius-container); + background-color: var(--background-well); +} -.user-message p { - margin-bottom: 0 !important; +.br-md-code-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 2px 2px 2px 12px; + /* No rule under the head (R-27): the language is muted 12px and the code + starts 10px below it, which separates them on one fill. Transparent rather + than gone, so forced colours still draw the division. */ + border-bottom: 1px solid transparent; } -.scrollbar-thin { - scrollbar-width: thin; +.br-md-code-lang { + font-family: var(--font-mono); } -.assistant:has(+ .user):not(.in-chain) .biorouter-message { - padding-bottom: 24px; +.br-md-code-body { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--text-code); + line-height: var(--text-code--line-height); + white-space: pre; + tab-size: 2; } -/* Virtualized list styles */ -.virtualized-list { - scrollbar-width: thin; - scrollbar-color: var(--color-neutral-300) transparent; +/* Only a box that scrolls is focusable, so only it ever shows this. */ +.br-md-code-body:focus-visible, +.br-md-table-scroll:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--border-accent); } -.virtualized-list::-webkit-scrollbar { - width: 8px; +/* A box with more to its right fades out at its right edge: macOS hides + overlay scrollbars, so a clipped column gave no sign that the box scrolls. + `data-overflow` is set while there is more to the right and dropped at the + scroll end, so the last column is never dimmed. A mask paints nothing + (`black` is alpha), so the fade is right on any ground. On a table it fades + the fill too, so the box's open right side is the sign that it continues; + the closing hairline Crew draws there is a line R-27 retires. */ +.br-md-code-body[data-overflow='true'], +.br-md-table-scroll[data-overflow='true'] { + mask-image: linear-gradient(to right, black calc(100% - 40px), transparent); } -.virtualized-list::-webkit-scrollbar-track { - background: transparent; +/* Focused from the keyboard, the box drops its fade: the mask would fade the + right side of the focus edge with the last column. */ +.br-md-code-body[data-overflow='true']:focus-visible, +.br-md-table-scroll[data-overflow='true']:focus-visible { + mask-image: none; } -.virtualized-list::-webkit-scrollbar-thumb { - background-color: var(--color-neutral-300); - border-radius: 4px; +.br-md-table-scroll { + max-width: 100%; + overflow-x: auto; + /* The table's own radius, so the keyboard's focus edge (drawn on this box + only while it scrolls) follows the fill's corners. */ + border-radius: var(--radius-element); } -.dark .virtualized-list::-webkit-scrollbar-thumb { - background-color: var(--color-neutral-400); +/* Rounded corners need the separate border model (a collapsed table ignores its + radius), so each body cell draws only its top edge and the table carries the + fill. Horizontal rules only; numbers line up in columns. */ +.br-md-table { + border-collapse: separate; + border-spacing: 0; + border: 1px solid transparent; + border-radius: var(--radius-element); + overflow: hidden; + background-color: var(--background-fill); + font-size: var(--text-secondary); + line-height: var(--text-secondary--line-height); + font-variant-numeric: tabular-nums; } -.virtualized-list::-webkit-scrollbar-thumb:hover { - background-color: var(--color-neutral-400); +.br-md-table :where(th, td) { + padding: 8px 12px; + border: 0; + border-block-start: 1px solid var(--border-subtle); + color: var(--text-default); + font-weight: 400; + text-align: start; + vertical-align: top; + /* Never the prose's `overflow-wrap: anywhere`: a narrow column would split + "Sample" into "Sampl / e". A table that is too wide scrolls instead. */ + overflow-wrap: normal; + word-break: normal; } -.dark .virtualized-list::-webkit-scrollbar-thumb:hover { - background-color: var(--color-neutral-300); +/* Headers and every column after the first stay on one line; the first column + (names) may still wrap between words. */ +.br-md-table th, +.br-md-table td:not(:first-child) { + white-space: nowrap; } -/* Ensure virtualized items don't have margin/padding that could break layout */ -.virtualized-list > div { - outline: none; +/* The header row is the first, and only it has no top rule. It is set apart by + its muted 12px 600 type and the rule under it, not by a second ground: a + band on the fill would be a fill on a fill. It paints the table's fill + rather than inheriting it, so a header that sticks (the preview's CSV) stays + opaque over the rows passing under it. */ +.br-md-table thead :where(th, td), +.br-md-table th[data-head] { + border-block-start: 0; + background-color: var(--background-fill); + color: var(--text-muted); + font-size: var(--text-supporting); + line-height: var(--text-supporting--line-height); + font-weight: 600; } -/* Optimize performance for virtualized rendering */ -.virtualized-list * { - contain: layout style paint; +.br-md-table thead :where(strong, em, a) { + color: inherit; } -/* Biorouter icon animation to replace framer-motion */ -@keyframes biorouter-icon-entrance { - from { - opacity: 0; - transform: scale(0.92) translateY(2px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); +/* Forced colours: the right-edge fade would only hide ink there (a mask is + alpha, and the system repaints the rest), so it goes, as Crew's does. */ +@media (forced-colors: active) { + .br-md-code-body[data-overflow='true'], + .br-md-table-scroll[data-overflow='true'] { + mask-image: none; } } +/* @ws WS-TRANSCRIPT end: md recipes */ -.biorouter-icon-animation { - animation: biorouter-icon-entrance var(--motion-slow) var(--ease-out) forwards; - transform-origin: bottom left; +.scrollbar-thin { + scrollbar-width: thin; } -/* Session list optimizations */ +/* @ws WS-HISTORY begin: history list containment */ +/* Chat history's list: a column of rows, each contained so a row's layout never + reaches its neighbours. Nothing here animates (the hover wash is the shared + `.biorouter-list-row`'s), so there is no `will-change` or layer promotion to ask + for. */ .session-grid { display: flex; flex-direction: column; @@ -4583,33 +7422,9 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { } .session-item { - /* Default state - visible and positioned */ - opacity: 1; - transform: translateY(0) translateZ(0); - - /* Optimize individual session cards */ contain: layout style; - /* Use hardware acceleration */ - backface-visibility: hidden; - /* Ensure smooth interactions */ - will-change: auto; -} - -.session-item:hover { - /* Only use will-change during hover */ - will-change: transform, box-shadow; -} - -.session-item:not(:hover) { - will-change: auto; -} - -/* Optimize session skeleton loading */ -.session-skeleton { - contain: layout style paint; - /* Prevent layout thrashing during loading */ - min-height: 140px; } +/* @ws WS-HISTORY end: history list containment */ /* Ensure navigation is always responsive. This was three rules carrying the same `pointer-events: auto !important` @@ -4632,16 +7447,17 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { contain: layout style paint; } +/* @ws WS-SUMMARY begin: composer motion */ +/* The composer shell BaseChat FLIPs on the first turn: translate only, with + WAAPI on DUR.med and EASE_OUT (styles/motion.ts). No standing + `will-change`: WAAPI promotes the layer for the animation's length, and a + permanent hint kept a compositor layer alive for the life of every chat. + The FLIP and the conversation glide (hooks/useColumnGlide.ts) both skip + themselves under reduced motion and while the window is being resized. */ .biorouter-composer-motion { transform-origin: top left; - will-change: transform, opacity; -} - -@media (prefers-reduced-motion: reduce) { - .biorouter-composer-motion { - will-change: auto; - } } +/* @ws WS-SUMMARY end: composer motion */ /* Sidebar transition optimizations */ [data-slot='sidebar-container'] { @@ -4854,23 +7670,24 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { } } +/* @ws WS-TOKENS begin: text-caps transform */ /* * `text-caps` carries the transform, not just the metrics. * * Tailwind's `--text-*` namespace can express a size, a line-height, a weight * and a tracking, but not a `text-transform` — so the generated `.text-caps` - * utility gave a call site 11px at 500 with +0.08em tracking and left the + * utility gave a call site 12px at 500 with +0.04em tracking and left the * casing to be remembered separately. That is a role that only works if you * also know the thing it is named after: a site that forgets `uppercase` gets - * tracked-out 11px LOWERCASE, which does not read as a mistake, it reads as a - * rendering bug. Tracking that wide is a typographic device that only makes - * sense on capitals. + * tracked-out LOWERCASE, which does not read as a mistake, it reads as a + * rendering bug. Tracking is a typographic device that only makes sense on + * capitals. * - * Folding the transform in makes the role complete: `text-caps` is now the - * whole caps style, and it is the app's ONLY one. `uppercase` sitting beside it - * at existing call sites is harmless and redundant — the phase-10 sweep removes - * those. A site that genuinely wants 11px un-capsed does not exist by design; - * the smallest non-caps role is `text-supporting` at 12px. + * Folding the transform in makes the role complete: `text-caps` is the whole + * caps style, and it is the app's ONLY one, for page section labels only (the + * 2026-10 redesign). `uppercase` sitting beside it at existing call sites is + * redundant and each owner deletes it. Sidebar group headers, menu group + * labels and bucket labels are sentence case at `text-supporting`. * * Layered into `utilities` so it composes with the generated rule (which sets * the four other properties) rather than fighting it, and so an explicit @@ -4881,6 +7698,7 @@ body.biorouter-sidebar-resizing [data-slot='sidebar-gap'] { text-transform: uppercase; } } +/* @ws WS-TOKENS end: text-caps transform */ /* * Prism token class names are unprefixed — `token table`, `token list`, `token @@ -5003,6 +7821,8 @@ code [class~='token'] { .br-paper-empty { padding-block: 40px; color: var(--text-muted); + font-size: var(--text-body); + line-height: var(--text-body--line-height); } /* The status strip's count — `88 lines`, `70 rows · 11 columns` — is the first @@ -5033,15 +7853,14 @@ code [class~='token'] { height: 1lh; } -/* Overflow hint. Long lines and wide tables used to clip at the panel edge with - no cue that anything was there. Two background layers, no JS: an ink shadow - pinned to the visible right edge (`scroll`), under a ground-coloured cover - pinned to the END of the content (`local`). While more content lies to the - right the cover is off-screen and the shadow shows; scrolled to the end, or - with nothing to scroll, the cover sits on top of it. A background, so it - paints under the text rather than over it. */ -.br-paper-scroll[data-view='code'], -.br-paper-table-scroll { +/* Overflow hint for the code view. Long lines used to clip at the panel edge + with no cue that anything was there (a table draws its own fade, below). Two + background layers, no JS: an ink shadow pinned to the visible right edge + (`scroll`), under a ground-coloured cover pinned to the END of the content + (`local`). While more content lies to the right the cover is off-screen and the + shadow shows; scrolled to the end, or with nothing to scroll, the cover sits on + top of it. A background, so it paints under the text rather than over it. */ +.br-paper-scroll[data-view='code'] { background: linear-gradient(to left, var(--background-default) 45%, transparent) 100% 0 / 36px 100% no-repeat local, @@ -5058,30 +7877,37 @@ code [class~='token'] { border-bottom: 1px solid var(--border-subtle); } +/* @ws WS-PREVIEW begin: front matter type */ +/* The title is the document h1's step, `text-title` (24/32 at 400, spec 2.1 and + owner decision 6.2); the subtitle is body size in muted ink and the byline is + supporting size. The body's own first `# H1` takes the h2 step under it + (`demoteFirstHeading`), so a report never shows two titles. */ .br-paper-title { margin: 0; color: var(--text-default); - font-size: 28px; - line-height: 1.2; - font-weight: 700; - letter-spacing: -0.015em; + font-size: var(--text-title); + line-height: var(--text-title--line-height); + font-weight: var(--text-title--font-weight); + letter-spacing: var(--text-title--letter-spacing); } .br-paper-subtitle { margin: 8px 0 0; color: var(--text-muted); - font-size: 17px; - line-height: 1.4; + font-size: var(--text-body); + line-height: var(--text-body--line-height); } .br-paper-byline { display: flex; flex-wrap: wrap; gap: 0 12px; - margin: 12px 0 0; + margin: 8px 0 0; color: var(--text-muted); - font-size: 13px; + font-size: var(--text-supporting); + line-height: var(--text-supporting--line-height); } +/* @ws WS-PREVIEW end: front matter type */ .br-paper-byline > span + span::before { content: '·'; @@ -5089,20 +7915,25 @@ code [class~='token'] { color: var(--text-subtle); } +/* @ws WS-PREVIEW begin: front matter disclosure on the scales */ +/* The spacing scale (12, 8) and the supporting role, not 14px, 7px and a bare + 12px: the one thing in the title block that still wrote its own numbers. */ .br-paper-frontmatter-more { - margin: 14px 0 0; + margin: 12px 0 0; } .br-paper-frontmatter-more > summary { display: inline-flex; align-items: center; - gap: 7px; + gap: 8px; color: var(--text-muted); - font-size: 12px; + font-size: var(--text-supporting); + line-height: var(--text-supporting--line-height); cursor: pointer; list-style: none; user-select: none; } +/* @ws WS-PREVIEW end: front matter disclosure on the scales */ .br-paper-frontmatter-more > summary::-webkit-details-marker { display: none; @@ -5117,7 +7948,9 @@ code [class~='token'] { height: 0; border-left: 5px solid currentColor; border-block: 3.5px solid transparent; - transition: transform var(--motion-fast) var(--ease-out); + /* @ws WS-PREVIEW begin: front matter disclosure motion (WS-TOKENS-1) */ + transition: transform var(--dur-fast) var(--ease-out); + /* @ws WS-PREVIEW end: front matter disclosure motion */ } .br-paper-frontmatter-more[open] > summary::before { @@ -5139,79 +7972,114 @@ code [class~='token'] { container-type: inline-size; } -/* Left: the column edge less the row index, so the first DATA column sits on - the column edge and the index hangs in the margin — yielding only as far as a - wide table needs to show its last column, and never closer than the lead to - the panel. `--paper-table-width` is the table's measured width (a - ResizeObserver in DelimitedTable); without it this is the column edge. */ +/* @ws WS-PREVIEW begin: csv on the shared table */ +/* THE ONE TABLE (spec 3.8 and section 0). A CSV wears `.br-md-table`, Crew's + recipe that chat, documents and notebook DataFrames share: radius 8, top + rules only, a 12px muted header, 8px 12px cells, 13/18 type and tabular + numbers. What the panel adds is what a data file needs and a chat table does + not: a header that sticks while 500 rows scroll under it, a quiet row index as + the first column, and packed columns. + + FILLED, NOT FRAMED (R-27, design.md 9.11). The table is one subtle fill + (`--background-fill`) with no outline; its 1px border stays but transparent, + so nothing moves and forced colours still draw an edge. The rules BETWEEN rows + stay, because they are structure. A hovered row steps to the strong fill. The + sticky header rests on the same fill (an opaque ground, so rows never show + through it) and is told apart by its type and the rule under it, never by a + second ground: a header on the strong step would read as a hovered row. + + The frame sits on the column edge, the left edge the bar's name and a + report's prose share. It leaves that edge for one reason only: a table that + would show whole if it sat nearer the panel's edge (`data-yield`, measured in + DelimitedTable) moves exactly that far, never closer than the lead. A table + wider than the panel could not be shown whole from anywhere, so yielding would + only outdent it for nothing: it keeps the column edge and scrolls. + `--paper-table-width` is the table's measured width. */ .br-paper-table-frame { box-sizing: border-box; width: max-content; min-width: 100%; - padding-block: 20px 56px; + padding-block: 16px 32px; padding-right: var(--paper-gutter); + padding-left: var(--paper-inset); +} + +.br-paper-table-frame[data-yield] { padding-left: max( var(--paper-lead), - min( - calc(var(--paper-inset) - var(--paper-rownum-width)), - calc(100cqw - var(--paper-table-width, 0px) - var(--paper-gutter)) - ) + min(var(--paper-inset), calc(100cqw - var(--paper-table-width, 0px) - var(--paper-gutter))) ); } /* Columns never stretch. Every data cell is `width: 1px` (it shrinks to its content) and a trailing `.br-paper-fill` cell absorbs the slack, so a - three-column CSV stays packed on the left while its rules still span the - whole column. `max-content` so a wide table is as wide as its content, never - squeezed into wrapping. - - The rules span the column PLUS the hanging index — but never more than the - panel has room for once the lead and the right gutter are taken. Below about - 870px the inset is narrower than the index, so the index can no longer hang - in it; an unclamped `column + index` then made every narrow CSV wider than - its panel, so it scrolled sideways by 20-35px for nothing and ran its rules - off the right edge under a hint claiming more columns. artifactPaper.test.ts - evaluates this against the frame at every panel width. */ + three-column CSV stays packed on the left while its frame still spans the + column. `max-content` so a wide table is as wide as its content, never + squeezed into wrapping. The frame spans the column, but never more than the + panel has room for once the lead and the right gutter are taken, so a narrow + CSV never scrolls sideways for nothing. */ .br-paper-table { + /* The ground the right-edge fade ends in (below): the table's own fill, which + every row, the header included, rests on. A table that changes its ground + changes this. */ + --paper-fade-width: 40px; + --paper-fade-ground: var(--background-fill); width: max-content; - min-width: min( - calc(var(--paper-column) + var(--paper-rownum-width)), - calc(100cqw - var(--paper-lead) - var(--paper-gutter)) - ); - border-collapse: separate; - border-spacing: 0; + min-width: min(var(--paper-column), calc(100cqw - var(--paper-lead) - var(--paper-gutter))); color: var(--text-default); - font-size: 13px; - line-height: 20px; - font-variant-numeric: tabular-nums; } -.br-paper-table th { +/* The recipe clips its corners with `overflow: hidden`, which would make the + table a scroll container and stop the header sticking; the corner cells + round themselves instead (radius 8 less the 1px border). The fill is clipped + to the padding box, so it never shows as a ring around the transparent + border. */ +.br-paper-table.br-md-table { + overflow: visible; + border-color: transparent; + background-color: var(--background-fill); + background-clip: padding-box; +} + +.br-paper-table.br-md-table th { position: sticky; top: 0; z-index: 1; width: 1px; - padding: 10px 24px 8px 0; + background-color: var(--background-fill); /* A box-shadow, not a border: a sticky cell's border scrolls away with the - table, and this rule has to stay with the header. */ - box-shadow: inset 0 -1px 0 var(--border-strong); - background-color: var(--background-default); - color: var(--text-muted); - font-size: 12px; - font-weight: 600; - text-align: left; + table, and the header's bottom rule has to stay with it. */ + box-shadow: inset 0 -1px 0 var(--border-subtle); vertical-align: bottom; white-space: nowrap; } -.br-paper-table td { +.br-paper-table.br-md-table td { width: 1px; - padding: 7px 24px 7px 0; - border-bottom: 1px solid var(--border-subtle); - vertical-align: top; white-space: nowrap; } +/* The header's own rule is the first row's top edge. */ +.br-paper-table.br-md-table tbody tr:first-child > td { + border-block-start: 0; +} + +.br-paper-table.br-md-table thead th:first-child { + border-start-start-radius: calc(var(--radius-element) - 1px); +} + +.br-paper-table.br-md-table thead th:last-child { + border-start-end-radius: calc(var(--radius-element) - 1px); +} + +.br-paper-table.br-md-table tbody tr:last-child > td:first-child { + border-end-start-radius: calc(var(--radius-element) - 1px); +} + +.br-paper-table.br-md-table tbody tr:last-child > td:last-child { + border-end-end-radius: calc(var(--radius-element) - 1px); +} + .br-paper-table [data-numeric] { text-align: right; } @@ -5220,55 +8088,63 @@ code [class~='token'] { color: var(--text-subtle); } -.br-paper-table .br-paper-fill { +.br-paper-table.br-md-table .br-paper-fill { width: auto; padding: 0; } -/* The overflow hint through the sticky header. The scroller's hint is a - BACKGROUND, and the header row has to be opaque so rows can scroll under it — - so the header painted over the hint and left a notch in the shadow exactly at - the header row. The header's filler cell carries its own copy instead: it - sticks to the scroller's right edge (`right: 0`) whenever the table runs past - that edge, and a scroll-state query shows its shadow only while it is stuck - there. Not stuck — the table fits, or it is scrolled to its end — means no - shadow, the same two cases the background's cover handles for the rows. - Where scroll-state queries are unsupported the header keeps the notch and - nothing else changes. */ -.br-paper-table th.br-paper-fill { +/* THE RIGHT-EDGE FADE. A table wider than the panel fades out at the panel's + right edge while more lies that way, and the fade is gone at the scroll end, so + the last column is never dimmed (the recipe `.br-md-table-scroll` draws with a + mask, spec 3.8). A mask on this scroller would also fade its vertical + scrollbar, and a background shadow cannot sit over the text, so each row's + filler cell draws it instead: it sticks to the scroller's right edge + (`right: 0`) whenever the table runs past that edge, and its pseudo-element + paints 40px of the row's own ground over the text, transparent to opaque + towards the edge. Every row rests on the table's fill, and a hovered row's + filler is on the strong step like the row, so no row shows a patch. `data-overflow` (set by + DelimitedTable while more lies to the right) turns every fade on at once. + Forced colours repaint grounds, so the fade yields to the system there, as the + recipe's does. */ +.br-paper-table .br-paper-fill { + position: sticky; right: 0; - container-type: scroll-state; } -.br-paper-fill-hint { +.br-paper-table .br-paper-fill::after { + content: ''; position: absolute; - top: 0; + inset-block: 0; right: 0; - bottom: 0; - width: 14px; - background: linear-gradient(to left, var(--paper-hint-ink), transparent); - opacity: 0; + display: none; + width: var(--paper-fade-width); + background: linear-gradient(to left, var(--paper-fade-ground), transparent); pointer-events: none; } -@container scroll-state(stuck: right) { - .br-paper-fill-hint { - opacity: 1; +.br-paper-table tbody tr:hover .br-paper-fill::after { + background: linear-gradient(to left, var(--background-fill-strong), transparent); +} + +.br-paper-table-scroll[data-overflow='true'] .br-paper-fill::after { + display: block; +} + +@media (forced-colors: active) { + .br-paper-table .br-paper-fill::after { + display: none !important; } } -/* The row index: quiet mono in the margin, the way the code gutter hangs. No - rule, no hover fill, not selectable, and not sticky sideways — it is a - reading aid, not a frozen column. */ -.br-paper-table .br-paper-rownum { +/* The row index: quiet mono, the first column, not selectable and not sticky + sideways. A reading aid, not a frozen column. */ +.br-paper-table.br-md-table .br-paper-rownum { box-sizing: border-box; width: var(--paper-rownum-width); min-width: var(--paper-rownum-width); - padding: 7px 14px 7px 0; - border-bottom: 0; - box-shadow: none; + padding-inline: 12px 8px; color: var(--text-subtle); - font: 12px/20px var(--font-mono); + font: var(--text-supporting) / var(--text-secondary--line-height) var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; user-select: none; @@ -5284,15 +8160,11 @@ code [class~='token'] { text-overflow: ellipsis; } -.br-paper-table tbody tr:hover td:not(.br-paper-rownum) { - background-color: var(--background-muted); -} - -.br-paper-table-note { - margin: 16px 0 0; - color: var(--text-muted); - font-size: 12px; +/* Hover is neutral (principle 7) and steps to the strong fill (R-27). */ +.br-paper-table tbody tr:hover td { + background-color: var(--background-fill-strong); } +/* @ws WS-PREVIEW end: csv on the shared table */ /* ── Code view (CodeBlock in ArtifactViewer.tsx) ── */ .br-paper-code { @@ -5343,9 +8215,16 @@ code [class~='token'] { } /* ── Notebook (NotebookPreview.tsx) ── */ +/* @ws WS-PREVIEW begin: notebook top */ +/* The first cell opens 16px under the bar, the paper inset the CSV table's frame + uses, plus the first heading's own margin (24px for an h1): the same 40px a + report's title sits at, not the 84px that 36px of padding, a markdown cell's + 8px and a heading's 24px stacked into. A markdown cell's 8px belongs between + cells, so the first one has none. */ .br-paper-nb { - padding-block: 36px 88px; + padding-block: 16px 88px; } +/* @ws WS-PREVIEW end: notebook top */ .br-paper-nb-cell { position: relative; @@ -5356,11 +8235,21 @@ code [class~='token'] { margin-top: 8px; } +.br-paper-nb > .br-paper-nb-cell:first-child { + margin-top: 0; +} + +/* @ws WS-PREVIEW begin: notebook boxes share one radius */ +/* A cell's source, a figure and an error are the notebook's three boxes, and + they share ONE radius, the container's 12 (a markdown cell's fenced block + already draws 12, so 10 put two radii in one notebook). The source is the + code well, which is the code's fill (the syntax palettes are measured on it). */ .br-paper-well { overflow-x: auto; - border-radius: 10px; + border-radius: var(--radius-container); background-color: var(--background-well); } +/* @ws WS-PREVIEW end: notebook boxes share one radius */ /* The execution prompt hangs in the left margin when there is one… */ .br-paper-nb-prompt { @@ -5399,23 +8288,41 @@ code [class~='token'] { .br-paper-nb-error { margin: 0; padding: 10px 16px; - border-radius: 8px; + border-radius: var(--radius-container); background-color: color-mix(in srgb, var(--text-danger) 8%, transparent); color: var(--text-danger); } +/* @ws WS-PREVIEW begin: notebook figure tile */ +/* A figure output is a plot on a tile of the subtle fill (R-27), not a plot in a + hairline: a plot with a white ground still has an edge on the paper because + the fill is around it, and no line is drawn. The tile is the well's box (the + same radius and the same left edge), and the image sits in it 16px in, where + the cell's own text sits, so the picture, the source and the printed output + share one text edge. */ .br-paper-nb-figure { - padding: 6px 16px; + padding: 12px 16px; + border-radius: var(--radius-container); + background-color: var(--background-fill); } +.br-paper-nb-figure > img { + display: block; + border-radius: var(--radius-element); +} +/* @ws WS-PREVIEW end: notebook figure tile */ + /* A `sandbox=""` frame cannot be measured from here, so a short DataFrame keeps the frame's `min-h-40` floor and leaves some air under it. Sizing it would need `allow-same-origin`, a sandbox relaxation this surface does not - make. */ + make. + + The frame spans the cell's width: the DataFrame is a filled box (R-27) like the + source well and the figure tile, so its box starts on their left edge, and the + text inside it sits in its own 12px cell padding. */ .br-paper-nb-output > iframe { display: block; - width: calc(100% - 16px); - margin-left: 16px; + width: 100%; } .br-paper-nb-output > .br-paper-prose { @@ -5538,72 +8445,182 @@ code [class~='token'] { padding: 16px 12px; } -@keyframes biorouter-heatmap-loading { - 0%, - 100% { - background-color: var(--heat-0); - opacity: 0.72; +/* @ws WS-USAGE begin: heatmap */ +/* The usage heatmap (spec 3.9): FLUID, as in Codex. One column per week of the + fetched window (`--heat-weeks`, set inline: 53 for the 371-day window), + seven square rows, a 3px gap, radius 2, the heat ramp. Nothing is measured + in JS any more, so there is no cell-size ladder and no label font that + shrinks with the cells. Authored rather than utilities: the template reads a + custom property, and the reveal needs keyframes and a reduced-motion rest. */ +.biorouter-heatmap { + --heat-gap: 3px; + display: flex; + min-inline-size: 0; + flex-direction: column; + gap: 6px; +} + +/* The month row shares the grid's column template, so a label sits over its + week. Each row is its own grid on the same template (an ARIA grid is rows of + cells), which keeps every column the same width. */ +.biorouter-heatmap-months, +.biorouter-heatmap-row { + display: grid; + grid-template-columns: repeat(var(--heat-weeks, 53), minmax(1px, 1fr)); + column-gap: var(--heat-gap); +} + +/* 12px at every width. A label overflows its ~11px column on purpose: months + are four or five columns apart, so two labels never meet. */ +.biorouter-heatmap-months { + min-block-size: 16px; + color: var(--text-muted); + font-size: var(--text-supporting); + line-height: var(--text-supporting--line-height); + white-space: nowrap; +} + +.biorouter-heatmap-months > span { + min-inline-size: 0; + overflow: visible; +} + +.biorouter-heatmap-grid { + display: flex; + flex-direction: column; + gap: var(--heat-gap); +} + +.biorouter-heatmap-cell { + display: block; + min-inline-size: 0; + aspect-ratio: 1; + border-radius: 2px; + transition: transform var(--dur-fast-min) var(--ease-out); +} + +.biorouter-heatmap-cell[role='gridcell']:hover { + z-index: 1; + transform: scale(1.2); +} + +/* A cell's fill IS its data, so focus cannot be the app's focus fill: it is an + outline outside the cell. */ +.biorouter-heatmap-cell:focus-visible { + z-index: 1; + outline: 2px solid var(--border-focus); + outline-offset: 1px; +} + +/* The current streak: an inset ring in ink, on its cells and on the + footnote's glyph alike. The glyph is a FILLED cell (`StreakGlyph` paints a heat + step under the ring): a hollow ringed square beside a label reads as an + unchecked checkbox, a ringed heat cell reads as a key to the grid. */ +.biorouter-heatmap-cell[data-streak='true'], +.biorouter-heatmap-swatch[data-streak='true'] { + box-shadow: inset 0 0 0 1.5px var(--text-default); +} + +/* No streak is running, so no cell is ringed: the footnote's glyph keeps its + ring (it still leads the words) in the muted ink of the line it sits on. */ +.biorouter-heatmap-swatch[data-streak='past'] { + box-shadow: inset 0 0 0 1.5px var(--text-muted); +} + +/* The first heatmap a session shows reveals column by column (`--heat-delay` + is `col * 8 + row * 12` ms, set inline only then). `backwards` holds the + first frame through the delay and lets go at the end, so the hover scale + works afterwards. A named exception to the 5-item stagger cap (spec 3.9). */ +.biorouter-heatmap[data-reveal='true'] .biorouter-heatmap-cell { + animation: biorouter-heatmap-reveal var(--dur-med) var(--ease-out) backwards; + animation-delay: var(--heat-delay, 0ms); +} + +@keyframes biorouter-heatmap-reveal { + from { + opacity: 0; + transform: scale(0.85); } - 50% { - background-color: var(--heat-1); + to { opacity: 1; + transform: none; } } -.biorouter-heatmap-loading-cell { - animation: biorouter-heatmap-loading 1.8s ease-in-out infinite; -} - +/* The loading grid breathes in opacity only, on the ambient period. */ +.biorouter-heatmap-loading-cell, .biorouter-heatmap-loading-line { - animation: breathe 1.8s ease-in-out infinite; + animation: biorouter-heatmap-breathe calc(var(--dur-slow) * 4) var(--ease-out) infinite + alternate; } -@keyframes biorouter-history-loading { - 0%, - 100% { - background-color: var(--background-medium); - opacity: 0.72; +@keyframes biorouter-heatmap-breathe { + from { + opacity: 0.55; } - 50% { - background-color: var(--background-strong); + to { opacity: 1; } } -.biorouter-history-loading-cell { - animation: biorouter-history-loading 1.4s linear infinite; +/* `Less ■■■■■ More` and the streak glyph: 12px squares at the cells' radius. */ +.biorouter-heatmap-legend { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; +} + +.biorouter-heatmap-swatch { + display: block; + flex: none; + inline-size: 12px; + block-size: 12px; + border-radius: 2px; +} + +.biorouter-heatmap-tooltip-note { + max-inline-size: 240px; } @media (prefers-reduced-motion: reduce) { + .biorouter-heatmap-cell { + transition: none; + } + + .biorouter-heatmap[data-reveal='true'] .biorouter-heatmap-cell, .biorouter-heatmap-loading-cell, - .biorouter-heatmap-loading-line, - .biorouter-history-loading-cell { + .biorouter-heatmap-loading-line { animation: none; } } +/* @ws WS-USAGE end: heatmap */ -/* The heatmap must never make the page scroll sideways; shrink the cells. - The live heatmap fits itself precisely via inline vars (UsageHeatmap's - ResizeObserver); this coarse breakpoint covers the loading skeleton and any - context that renders before measurement. */ -@media (max-width: 860px) { - .biorouter-heatmap { - --heat-cell: 15px; - --heat-gap: 4px; - --heat-labels: 28px; +/* @ws WS-HISTORY begin: history loading */ +/* Opacity only (principle 9: nothing else animates), like the heatmap's loading + grid beside it in the app. The cells keep one ground (`--background-medium`); + an earlier version also cross-faded `background-color`, which repaints. */ +@keyframes biorouter-history-loading { + from { + opacity: 0.55; + } + to { + opacity: 1; } } -/* Label typography rides the fitted cell size (vars set inline by - UsageHeatmap) so a shrunken grid can never have labels taller than its - rows; the fallbacks are the historical fixed sizes. */ -.biorouter-heatmap-days { - font-size: var(--heat-day-font, 10px); +/* An ambient loop, so its period is a multiple of `--dur-slow` (spec 2.4). */ +.biorouter-history-loading-cell { + animation: biorouter-history-loading calc(var(--dur-slow) * 4) var(--ease-out) infinite + alternate; } -.biorouter-heatmap-months { - font-size: var(--heat-month-font, 11px); +@media (prefers-reduced-motion: reduce) { + .biorouter-history-loading-cell { + animation: none; + } } +/* @ws WS-HISTORY end: history loading */ /* The grid settles immediately; only the existing content layer animates. */ [data-preview-open='false'] { @@ -5638,6 +8655,12 @@ code [class~='token'] { Copy button hugging the right edge. The value carries 4px block padding so one 20px line centres against the 28px button, which is also what lets the multi-line form keep its first line level with a top-right Copy. */ +/* @ws WS-PRIMITIVES begin: copy-field well */ +/* A well, not an outline (R-27): the 1px border stays, TRANSPARENT, so the 32px geometry above + holds and forced colours still draw the edge; the well alone sets the box off the page. It is a + field-like box, so it rests where a field does when a ground names one (`--br-control-ground`, + the grounds note): the page ground inside a filled box and the strong step on a recessed ground, + where the well (#f5f5f3 and #232320) equalled the fill and vanished. */ .biorouter-copy-field { display: flex; align-items: center; @@ -5645,10 +8668,11 @@ code [class~='token'] { min-width: 0; padding-block: 1px; padding-inline: 12px 1px; - border: 1px solid var(--border-subtle); + border: 1px solid transparent; border-radius: var(--radius-element); - background-color: var(--background-well); + background-color: var(--br-control-ground, var(--background-well)); } +/* @ws WS-PRIMITIVES end: copy-field well */ .biorouter-copy-field[data-multiline='true'] { align-items: flex-start; @@ -5675,11 +8699,14 @@ code [class~='token'] { white-space: pre-wrap; } -/* A device code someone reads aloud: 20/28 mono. */ +/* @ws WS-PRIMITIVES begin: copy-field code size */ +/* A device code someone reads aloud: 17/24 mono (the subheading role; `text-heading` was its + alias and is retired, spec 2.1). */ .biorouter-copy-field[data-size='code'] .biorouter-copy-field-value { - font-size: var(--text-heading); - line-height: var(--text-heading--line-height); + font-size: var(--text-subheading); + line-height: var(--text-subheading--line-height); } +/* @ws WS-PRIMITIVES end: copy-field code size */ .biorouter-copy-field-value[data-truncate='end'] { overflow: hidden; @@ -5888,9 +8915,15 @@ code [class~='token'] { --status-dot-fill: var(--text-subtle); } +/* @ws WS-PRIMITIVES begin: idle dot */ +/* Idle (a paused schedule, an offline Crew member) is a state, so its mark must be seen: the + control edge's 3:1 neutral (3.48 to 4.49 on the page, a card and the sidebar in every family), + a step lighter than `neutral`'s `--text-subtle`. `--background-strong` measured 1.3:1 on white + and read as a speck (vision round 1, V3-16; checklist 13). */ .biorouter-status-dot[data-tone='idle'] { - --status-dot-fill: var(--background-strong); + --status-dot-fill: var(--border-control); } +/* @ws WS-PRIMITIVES end: idle dot */ .biorouter-status-dot[data-live='true']::after { content: ''; @@ -6246,11 +9279,22 @@ code [class~='token'] { outline-offset: -2px !important; } - /* A Radix tab trigger's default focus mark is a ring drawn on `::before` - (see the tab rules above). This block gives the trigger the OS ring as an - outline instead, so the pseudo ring stands down: one ring, not two. */ - [role='tab'][data-orientation]:focus-visible::before { - content: none; + /* A Radix tab trigger's focus mark is a 2px ring drawn on `::before`, sized + to the label (see the tab rules above). Here it KEEPS that ring and takes + the OS ring's colour; the outline above stands down, so it is still one + ring, not two. The outline used to win instead, and an outline is drawn + round the BOX: a trigger has no side padding, so at a 2px offset it hugged + the label ("Chat" touched it), and the 36px box sits 4px below the + settings band's top edge, which cut the ring's top off (vision V4-16, + forced colours). The pseudo ring stands 4px clear of the glyphs and inside + the trigger's height, the same geometry as normal colours. Forced colours + name `Highlight` for it in the block below. */ + [role='tab'][data-orientation]:not(.br-tab):focus-visible { + outline: none !important; + } + + [role='tab'][data-orientation]:not(.br-tab):focus-visible::before { + border-color: var(--ring); } } @@ -6311,6 +9355,14 @@ code [class~='token'] { forced-color-adjust: none; } + /* A Radix trigger's focus ring is its `::before` border (the OS block + above keeps it and drops the outline). A border colour is forced like a + fill, so it is named in the user's own focus colour and left as written. */ + [role='tab'][data-orientation]:not(.br-tab):focus-visible::before { + border-color: Highlight; + forced-color-adjust: none; + } + /* A filled button's box is its fill, so Save, Done and every destructive action rendered as plain text while Cancel — which has a border — kept its shape. Every button gets the system button edge. */ diff --git a/ui/desktop/src/styles/measures.test.ts b/ui/desktop/src/styles/measures.test.ts index d7f28647d..97997f51a 100644 --- a/ui/desktop/src/styles/measures.test.ts +++ b/ui/desktop/src/styles/measures.test.ts @@ -123,14 +123,17 @@ const CHAT_MEASURE_VIEWS = [ * which any render test noticed, because each view rendered exactly what it * meant to. What no view can now do is mean something different. * - * `ScheduleDetailView` and the two transcripts are deliberately ABSENT: they - * are drill-in surfaces with a Back-button header, which is a different object - * from a page header and keeps its own shape. + * The drill-in surfaces joined on 2026-10-09 (spec 3.10, 3.4): the schedule + * detail and the two transcripts used to keep a Back-button header of their + * own shape, and now take the same band with `onBack`. */ const PAGE_HEADER_VIEWS = [ 'settings/SettingsView.tsx', 'sessions/SessionListView.tsx', + 'sessions/SessionHistoryView.tsx', + 'sessions/SharedSessionView.tsx', 'schedule/SchedulesView.tsx', + 'schedule/ScheduleDetailView.tsx', 'workflows/WorkflowsView.tsx', 'extensions/ExtensionsView.tsx', 'skills/SkillsView.tsx', @@ -426,14 +429,15 @@ describe('the comment stripper the measure rule depends on', () => { /** * Deliberately NOT `size="chat"`, which the rule above already asserts: a * self-check that fails for the same reason as the rule it underwrites tells - * you nothing about the instrument. These two classes are applied, are not - * the subject of any other assertion here, and one of them (`px-6`) sits on - * the very line a too-eager stripper would eat. + * you nothing about the instrument. These are applied classes on the + * transcript's reading column, not the subject of any other assertion here. + * (They were `biorouter-page-header` and `px-6` until the transcript moved + * onto the page band, spec 3.10, and stopped hand-writing its header.) */ it('keeps the classes that are actually applied', () => { const code = codeWithoutComments(HISTORY); - expect(code).toContain('biorouter-page-header'); - expect(code).toContain('px-6'); + expect(HISTORY).toMatch(/className="[^"]*\bflex-col\b[^"]*\bpx-6\b[^"]*"/); + expect(code).toMatch(/className="[^"]*\bflex-col\b[^"]*\bpx-6\b[^"]*"/); }); }); @@ -463,7 +467,7 @@ describe('the schedule detail sizes itself from its parent', () => { if (!detail) throw new Error('ScheduleDetailView.tsx is not in CHAT_MEASURE_VIEWS'); const code = codeWithoutComments(detail.source); expect(code).not.toContain('h-screen'); - expect(code).toContain(''); + expect(code).toMatch(/]*>/); }); }); @@ -807,9 +811,10 @@ describe('rung 2 — the panel stays mounted across a crossing', () => { }); it('keeps the scroll anchor scoped to the live chat', () => { - expect(codeWithoutComments(BASE_CHAT)).toContain( - 'anchorBottomOnResize={artifactPanel.isStacked}' - ); + // Under a stacked preview, and while the reader follows the newest line (V2-R6-1). + const live = codeWithoutComments(BASE_CHAT); + expect(live).toContain('anchorBottomOnResize={transcriptFollow.anchorBottomOnResize}'); + expect(live).toMatch(/useTranscriptFollow\(\{[^}]*isPreviewStacked: artifactPanel\.isStacked,/); for (const { rel, source } of PREVIEW_HOSTS.slice(1)) { expect(source, rel).not.toContain('anchorBottomOnResize'); } diff --git a/ui/desktop/src/styles/motion.test.ts b/ui/desktop/src/styles/motion.test.ts new file mode 100644 index 000000000..c1be4dc50 --- /dev/null +++ b/ui/desktop/src/styles/motion.test.ts @@ -0,0 +1,318 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DUR, + EASE_OUT, + EASE_SPRING, + MOTION, + RESIZING_CLASSES, + THEME_SWITCHING_CLASS, + TRAVEL, + isWindowResizing, + motionDuration, + prefersReducedMotion, + suspendTransitionsForThemeChange, +} from './motion'; + +/** + * `styles/motion.ts` is the TypeScript mirror of the motion tokens in + * `main.css`. WAAPI and FLIP code read the numbers from it because a `var()` + * cannot reach an `element.animate()` timing object, so the mirror is only + * useful while it agrees with the stylesheet. Every assertion below reads the + * declaration out of `main.css` and compares it with the exported value, the + * same source-level technique as `measures.test.ts` (jsdom applies no + * stylesheet, so there is nothing else to measure). + */ +const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); + +/** The CSS with comments blanked, so a token named in prose never matches. */ +const CODE = CSS.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' ')); + +function declaration(name: string): string { + const match = CODE.match(new RegExp(`(?:^|[\\s;{])${name}:\\s*([^;]+);`)); + expect(match, `expected a declaration of ${name} in main.css`).toBeTruthy(); + return match![1].trim(); +} + +function rule(selector: string, from = CODE): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = from.match(new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`)); + expect(match, `expected an authored \`${selector}\` rule in main.css`).toBeTruthy(); + return match![1]; +} + +/** Every `@media (prefers-reduced-motion: reduce) { … }` body in the file. */ +function reducedMotionBlocks(): string { + const out: string[] = []; + const re = /@media \(prefers-reduced-motion: reduce\)\s*\{/g; + while (re.exec(CODE)) { + let depth = 1; + let i = re.lastIndex; + for (; i < CODE.length && depth > 0; i++) { + if (CODE[i] === '{') depth++; + else if (CODE[i] === '}') depth--; + } + out.push(CODE.slice(re.lastIndex, i - 1)); + } + return out.join('\n'); +} + +describe('the TS mirror equals the CSS tokens', () => { + it.each([ + ['--dur-fast-min', DUR.fastMin], + ['--dur-fast', DUR.fast], + ['--dur-fast-max', DUR.fastMax], + ['--dur-med-min', DUR.medMin], + ['--dur-med', DUR.med], + ['--dur-slow', DUR.slow], + ])('%s is %ims', (token, ms) => { + expect(declaration(token)).toBe(`${ms}ms`); + }); + + it('mirrors the one curve and the one spring', () => { + expect(declaration('--ease-out')).toBe(EASE_OUT); + expect(declaration('--ease-spring')).toBe(EASE_SPRING); + }); + + /** + * The tokens the redesign retired. `--dur-med-max` had no use and `--ease-in` + * one stray one, so a third tier and a second general-purpose curve are + * gone; a new `duration-[var(--dur-med-max)]` would silently animate for 0s. + * `--motion-fast|base|slow` were the three-token scale's aliases onto the + * `--dur-*` ladder, deleted once the last reader moved (fast to `--dur-fast`, + * base to `--dur-fast-max`, slow to `--dur-med`). + */ + it.each(['--dur-med-max', '--ease-in', '--motion-fast', '--motion-base', '--motion-slow'])( + 'no longer declares %s', + (token) => { + expect(CODE).not.toMatch(new RegExp(`(?:^|[\\s;{])${token}:`)); + } + ); + + /** An undefined `var()` in `transition-duration` computes to 0s: nothing animates and nothing errors. */ + it('has no reader of a retired token anywhere in the renderer source', () => { + const src = join(__dirname, '..'); + const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + return ['node_modules', 'bin', 'web'].includes(name) ? [] : walk(path); + } + return /\.(?:tsx?|css)$/.test(name) && !/\.test\.|\.d\.ts$/.test(name) ? [path] : []; + }); + const readers = walk(src).filter((file) => + /var\(--(?:motion-(?:fast|base|slow)|dur-med-max|ease-in)\)/.test( + readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1') + ) + ); + expect(readers.map((file) => file.slice(src.length + 1))).toEqual([]); + }); + + /** Exits are a tier faster than entrances; the ladder must stay ordered. */ + it('keeps the ladder strictly increasing', () => { + const ladder = [DUR.fastMin, DUR.fast, DUR.fastMax, DUR.medMin, DUR.med, DUR.slow]; + expect([...ladder].sort((a, b) => a - b)).toEqual(ladder); + expect(new Set(ladder).size).toBe(ladder.length); + }); + + it('halves the travel on the way out', () => { + expect(TRAVEL.previewOut * 2).toBe(TRAVEL.previewIn); + expect(MOTION.popScale).toBeLessThan(1); + expect(MOTION.dialogScale).toBeGreaterThan(MOTION.popScale); + }); +}); + +describe('prefersReducedMotion()', () => { + const original = window.matchMedia; + afterEach(() => { + window.matchMedia = original; + }); + + it('reads the OS setting', () => { + window.matchMedia = vi.fn().mockReturnValue({ matches: true }) as never; + expect(prefersReducedMotion()).toBe(true); + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + window.matchMedia = vi.fn().mockReturnValue({ matches: false }) as never; + expect(prefersReducedMotion()).toBe(false); + }); + + it('answers false where matchMedia does not exist', () => { + // @ts-expect-error simulating an environment without matchMedia + window.matchMedia = undefined; + expect(prefersReducedMotion()).toBe(false); + }); + + it('turns a JS duration into 0 under reduced motion unless asked to keep it', () => { + window.matchMedia = vi.fn().mockReturnValue({ matches: true }) as never; + expect(motionDuration('med')).toBe(0); + expect(motionDuration('fast', { keepUnderReducedMotion: true })).toBe(DUR.fast); + window.matchMedia = vi.fn().mockReturnValue({ matches: false }) as never; + expect(motionDuration('med')).toBe(DUR.med); + }); +}); + +describe('isWindowResizing()', () => { + afterEach(() => { + document.body.classList.remove(...RESIZING_CLASSES); + }); + + it('is false at rest', () => { + expect(isWindowResizing()).toBe(false); + }); + + it.each(RESIZING_CLASSES)('is true while carries %s', (name) => { + document.body.classList.add(name); + expect(isWindowResizing()).toBe(true); + }); + + /** + * The class names are the contract with `AppLayout` (window) and + * `ui/sidebar.tsx` (sidebar drag). If either renames its class, this mirror + * would silently answer false forever. + */ + it('names the classes the layout code actually sets', () => { + const appLayout = readFileSync(join(__dirname, '../components/Layout/AppLayout.tsx'), 'utf8'); + const sidebar = readFileSync(join(__dirname, '../components/ui/sidebar.tsx'), 'utf8'); + expect(appLayout).toContain("'biorouter-window-resizing'"); + expect(sidebar).toContain("'biorouter-sidebar-resizing'"); + }); +}); + +describe('the resize rule', () => { + /** + * Layout snaps while geometry is being dragged: transitions AND animations + * stop on every element that opts in with `data-motion-layout`. The older + * rule beside the preview's resizing block only zeroed transitions, so a CSS + * keyframe kept playing against the drag. + */ + it('stops transitions and animations on [data-motion-layout] during either resize', () => { + const match = CODE.match( + /body\.biorouter-window-resizing \[data-motion-layout\],\s*body\.biorouter-sidebar-resizing \[data-motion-layout\]\s*\{([^}]*)\}/ + ); + expect(match, 'expected the global [data-motion-layout] resize rule').toBeTruthy(); + expect(match![1]).toMatch(/transition-duration:\s*0ms\s*!important/); + expect(match![1]).toMatch(/animation:\s*none\s*!important/); + }); +}); + +describe('a theme change lands in one frame (V4-15)', () => { + /** + * The grounds carry no transition, so they snap; every control that eases + * its colour used to walk to the new theme over 95 to 125ms instead. The rule + * stills every transition on `:root` and everything under it, pseudo-elements + * included, while the class is on . + */ + it('stills every transition while carries the class', () => { + const at = `:root.${THEME_SWITCHING_CLASS}`; + const body = rule(`${at},\n${at} *,\n${at} *::before,\n${at} *::after`); + expect(body).toMatch(/^\s*transition:\s*none\s*!important;\s*$/); + }); + + describe('suspendTransitionsForThemeChange()', () => { + let frames: ((time: number) => void)[] = []; + const runFrame = () => { + const due = frames; + frames = []; + due.forEach((callback) => callback(0)); + }; + + afterEach(() => { + vi.restoreAllMocks(); + frames = []; + document.documentElement.classList.remove(THEME_SWITCHING_CLASS); + }); + + const stubFrames = () => + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + + /** + * The first frame's callback runs BEFORE that frame computes style, so a + * removal there would undo the class before the new theme was ever styled + * with it. It must survive one whole frame. + */ + it('adds the class at once and removes it on the second frame, not the first', () => { + stubFrames(); + const root = document.documentElement; + suspendTransitionsForThemeChange(root); + expect(root.classList.contains(THEME_SWITCHING_CLASS)).toBe(true); + runFrame(); + expect(root.classList.contains(THEME_SWITCHING_CLASS)).toBe(true); + runFrame(); + expect(root.classList.contains(THEME_SWITCHING_CLASS)).toBe(false); + }); + + it('keeps the class until the second frame of the LAST of overlapping changes', () => { + stubFrames(); + const root = document.documentElement; + suspendTransitionsForThemeChange(root); + runFrame(); + suspendTransitionsForThemeChange(root); // a family change right after a mode change + runFrame(); // the first call's second frame + expect(root.classList.contains(THEME_SWITCHING_CLASS)).toBe(true); + runFrame(); + expect(root.classList.contains(THEME_SWITCHING_CLASS)).toBe(false); + }); + + it('defaults to and is a no-op without a root', () => { + stubFrames(); + suspendTransitionsForThemeChange(); + expect(document.documentElement.classList.contains(THEME_SWITCHING_CLASS)).toBe(true); + expect(() => suspendTransitionsForThemeChange(null)).not.toThrow(); + }); + }); +}); + +describe('the shared motion classes', () => { + it('.br-enter rises 4px and fades in over --dur-fast-max on the one curve', () => { + expect(rule('.br-enter')).toMatch( + /animation:\s*br-enter\s+var\(--dur-fast-max\)\s+var\(--ease-out\)/ + ); + expect(CODE).toMatch(/@keyframes br-enter\s*\{[^}]*translateY\(4px\)/); + }); + + it('.br-crossfade stacks its items in one cell, in over --dur-fast-max, out over --dur-fast', () => { + expect(rule('.br-crossfade > .br-crossfade-item')).toMatch(/grid-area:\s*1\s*\/\s*1/); + expect(rule(".br-crossfade-item[data-state='open']")).toMatch( + /animation:\s*br-crossfade-in\s+var\(--dur-fast-max\)\s+var\(--ease-out\)/ + ); + expect(rule(".br-crossfade-item[data-state='closed']")).toMatch( + /animation:\s*br-fade-out\s+var\(--dur-fast\)\s+var\(--ease-out\)/ + ); + expect(CODE).toMatch(/@keyframes br-crossfade-in\s*\{[^}]*translateY\(8px\)/); + }); + + it('.br-highlight washes --overlay-selected away over three --dur-slow periods', () => { + expect(rule('.br-highlight')).toMatch( + /animation:\s*br-highlight-wash\s+calc\(var\(--dur-slow\)\s*\*\s*3\)\s+var\(--ease-out\)/ + ); + expect(CODE).toMatch(/@keyframes br-highlight-wash\s*\{[^}]*var\(--overlay-selected\)/); + }); + + /** + * Crew's contract, promoted with the classes: the global reset shortens + * durations, but it cannot say what a moving element rests as, so every + * animated rule restates its rest under reduced motion. + */ + it('gives every shared class a reduced-motion rest', () => { + const rests = reducedMotionBlocks(); + expect(rests).toMatch(/\.br-enter\s*\{[^}]*animation-name:\s*br-fade-in/); + expect(rests).toMatch(/\.br-crossfade-item\[data-state='open'\]\s*\{[^}]*br-fade-in/); + expect(rests).toMatch(/\.br-crossfade-item\[data-state='closed'\]\s*\{[^}]*br-fade-out/); + expect(rests).toMatch(/\.br-highlight\s*\{[^}]*br-highlight-hold/); + }); + + /** Principle 11: never borrow a Crew class outside `crew/`. */ + it('does not reuse Crew keyframes', () => { + for (const name of ['br-enter', 'br-crossfade-in', 'br-fade-in', 'br-fade-out']) { + expect(CODE).toMatch(new RegExp(`@keyframes ${name}\\b`)); + } + expect(CODE).not.toMatch(/\.br-[\w-]+\s*\{[^}]*crew-[\w-]+/); + }); +}); diff --git a/ui/desktop/src/styles/motion.ts b/ui/desktop/src/styles/motion.ts new file mode 100644 index 000000000..173107510 --- /dev/null +++ b/ui/desktop/src/styles/motion.ts @@ -0,0 +1,191 @@ +/** + * The motion tokens, as numbers JavaScript can use. + * + * `styles/main.css` is the source of truth: `--dur-*`, `--ease-out` and + * `--ease-spring` live in its `:root` block, and every CSS rule reads them with + * `var()`. WAAPI (`element.animate`) and FLIP code cannot read a custom property + * in its timing options, so they used to write their own literals, and those + * drifted (300 / 250 / 125 with a private curve in the preview, 420ms with + * another curve in the composer). This module is the one TypeScript mirror, and + * `motion.test.ts` pins every number here to its `main.css` declaration, so the + * two cannot disagree. + * + * It also replaces the copies of `prefersReducedMotion()` that each animated + * component carried, and adds `isWindowResizing()`: layout snaps while the + * window, a split or the sidebar is being resized (spec principle 9), so a JS + * animation asks this before it starts. + * + * The rules (docs/design/codex-simplicity-redesign/implementation-spec.md §2.4): + * - one curve (`EASE_OUT`) for everything; `EASE_SPRING` only for transforms of + * physical gestures (tab slide and settle, drag lift, the segmented thumb); + * - exits take a faster tier than entrances and travel half the distance; + * - animate `transform`, `opacity` and `clip-path` only, outside the named + * exceptions; + * - a surface that mounts because of navigation, a reload, a tab switch or a + * history load appears at rest. + */ + +/** Durations in milliseconds. Each equals the `--dur-*` token of the same tier. */ +export const DUR = { + /** `--dur-fast-min`: row and menu-item hover, row-action reveal, tooltip exit. */ + fastMin: 95, + /** `--dur-fast`: control state changes, and every exit of a small surface. */ + fast: 125, + /** `--dur-fast-max`: entrances of small surfaces, tab enter, crossfade in. */ + fastMax: 175, + /** `--dur-med-min`: dialog enter, progress fill, side and stack crossfade. */ + medMin: 250, + /** `--dur-med`: panels (preview, summary rail, conversation glide, sidebar). */ + med: 300, + /** `--dur-slow`: the period unit for ambient loops only (`n × slow`). */ + slow: 525, +} as const; + +export type MotionDuration = keyof typeof DUR; + +/** `--ease-out`: the one curve. A strong decelerate with no overshoot. */ +export const EASE_OUT = 'cubic-bezier(0.24, 1, 0.4, 1)'; + +/** `--ease-spring`: transforms of physical gestures only, never colour or opacity. */ +export const EASE_SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; + +/** + * Constants the motion rules are written with. They are not CSS tokens; the + * authored CSS writes the same numbers into the rules that use them (for example + * `.br-enter` travels `TRAVEL.small`). + */ +export const MOTION = { + /** Small surfaces (menus, popovers, context menus) pop in from this scale. */ + popScale: 0.97, + /** Dialogs pop in from this scale. */ + dialogScale: 0.98, + /** Stagger between list items that enter together. */ + staggerMs: 30, + /** At most this many items animate in one batch; the rest appear at rest. */ + staggerMax: 5, + /** The resize settle: layout motion resumes this long after the last resize event. */ + resizeSettleMs: 180, + /** A FLIP glide shorter than this snaps instead (a jiggle reads as a glitch). */ + minGlidePx: 8, +} as const; + +/** Travel distances in px. Exits travel half of an entrance. */ +export const TRAVEL = { + /** Menus and popovers toward their trigger, a row insert (`.br-enter`). */ + small: 4, + /** Crossfade rise, a covering pane, a toast, rail content. */ + medium: 8, + /** The preview body entering. */ + previewIn: 32, + /** The preview body leaving. */ + previewOut: 16, +} as const; + +/** Body classes that mean "geometry is being dragged right now". */ +export const RESIZING_CLASSES = [ + 'biorouter-window-resizing', + 'biorouter-sidebar-resizing', +] as const; + +/** + * True when the person asked the OS to reduce motion. Safe to call where + * `matchMedia` does not exist (jsdom without a stub, a worker): that answers + * false, the same as a machine with no preference. + */ +export function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +/** + * True while the window or the sidebar is being resized: `AppLayout` sets + * `biorouter-window-resizing` on `` and clears it `MOTION.resizeSettleMs` + * after the last resize event, and the sidebar's drag handle sets + * `biorouter-sidebar-resizing`. A JS animation that moves layout-driven geometry + * must not start while this is true; it snaps instead (CSS gets the same rule + * from `[data-motion-layout]`). + * + * The window half is only as early as `AppLayout`'s `resize` listener. Chromium + * runs window `resize` listeners in registration order, capture flag or not, and + * a component below `AppLayout` registers its listeners first (a child's effects + * run before its parent's). A `setState` in such a listener is committed in the + * microtask after it returns, before the marker has run. In the event that + * begins a resize (any event that finds no hold left by the one before it), a + * glide that commit starts gets `false` here, and a layout read in it resolves + * styles without the class. A listener that moves layout therefore puts the + * class on `` itself before it moves anything, as `AppLayout`'s width + * watcher does (vision V1-R4-1). + * + * One marker at module scope here would run before every component's listener + * and close that gap everywhere. It is not installed: no other listener has been + * measured to slide, and `Layout/sidebarAutoCollapseMotion.browser.test.tsx` + * proves the watcher's hold by removing it, which an earlier marker would hide. + * Installing it means changing that test to remove both. + */ +export function isWindowResizing(): boolean { + if (typeof document === 'undefined' || !document.body) return false; + const { classList } = document.body; + return RESIZING_CLASSES.some((name) => classList.contains(name)); +} + +/** + * The duration to use for a JS animation: the tier's value, or 0 when the + * person prefers reduced motion. A caller that would rather keep a fade under + * reduced motion (opacity is not movement) passes `{ keepUnderReducedMotion }`. + */ +export function motionDuration( + tier: MotionDuration, + options: { keepUnderReducedMotion?: boolean } = {} +): number { + if (!options.keepUnderReducedMotion && prefersReducedMotion()) return 0; + return DUR[tier]; +} + +/** + * The class that stills every transition for the frame a theme changes in. + * `main.css` pairs it with `transition: none !important` on `:root` and every + * element and pseudo-element under it. + */ +export const THEME_SWITCHING_CLASS = 'biorouter-theme-switching'; + +let themeSwitchGeneration = 0; + +/** + * Call immediately before flipping the light/dark class or `data-theme`, in the + * same task, so the new theme lands in one frame. + * + * Without it the grounds snap (they carry no transition) while every button, + * row and tooltip that eases its `color`, `background-color` or `border-color` + * over 95 to 125ms walks there from the old theme: a dark button on a white + * page and 2:1 sidebar text for about 120ms after Dark to Light (vision + * finding V4-15, checklist 12). A theme change is a jump, not a change the + * person is watching an element make, so nothing animates it. + * + * The class comes off two animation frames later. The first frame's callback + * runs before that frame computes style, so removing the class there would undo + * it before the new theme was ever styled; by the second, the new colours are + * the computed values, and restoring the transitions starts none. Overlapping + * calls keep the class until the last one's second frame. A hidden window runs + * no frames, so the class stays until it is shown, which is harmless. + */ +export function suspendTransitionsForThemeChange( + root: HTMLElement | null = typeof document === 'undefined' ? null : document.documentElement +): void { + if (!root) return; + const view = root.ownerDocument.defaultView; + const frame = + view && typeof view.requestAnimationFrame === 'function' + ? (callback: () => void) => view.requestAnimationFrame(callback) + : (callback: () => void) => setTimeout(callback, 16); + + const generation = ++themeSwitchGeneration; + root.classList.add(THEME_SWITCHING_CLASS); + frame(() => + frame(() => { + if (generation === themeSwitchGeneration) root.classList.remove(THEME_SWITCHING_CLASS); + }) + ); +} diff --git a/ui/desktop/src/styles/popupType.test.ts b/ui/desktop/src/styles/popupType.test.ts index e88fcfadd..5ae455d56 100644 --- a/ui/desktop/src/styles/popupType.test.ts +++ b/ui/desktop/src/styles/popupType.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; /** @@ -22,6 +22,67 @@ const DIR = join(__dirname, '..', 'components', 'bottom_menu'); const popups = readdirSync(DIR).filter((f) => f.endsWith('.tsx') && !f.endsWith('.test.tsx')); +/** + * The transcript is the other place a hand-rolled size and a re-pinned face + * crept in (spec 3.1 item 5, WS-TOKENS-1), so the same two rules read it too: + * the message, markdown, activity and tool-call files, plus every component in + * `subagent/` and `transcript/`. + */ +const COMPONENTS = join(__dirname, '..', 'components'); +const TRANSCRIPT = [ + 'BioRouterMessage', + 'UserMessage', + 'MessageMeta', + 'MessageCopyLink', + 'MessageDivergeLink', + 'ProgressiveMessageList', + 'ProvenanceChip', + 'SessionNamePill', + 'InlineImage', + 'ImagePreview', + 'WorkflowHeader', + 'MarkdownContent', + 'TurnActivityIndicator', + 'LoadingBioRouter', + 'ToolCallWithResponse', + 'PendingToolCallCard', + 'ToolCallStatusIndicator', + 'ToolContentPreview', + 'ToolCallArguments', + 'ToolCallPreview', + 'ToolCallConfirmation', + 'ElicitationRequest', + 'SecretRequestCard', + 'MCPUIResourceRenderer', +].map((name) => `${name}.tsx`); +const folderComponents = (folder: string) => + existsSync(join(COMPONENTS, folder)) + ? readdirSync(join(COMPONENTS, folder)) + .filter((f) => f.endsWith('.tsx') && !/\.(test|browser)\./.test(f)) + .map((f) => `${folder}/${f}`) + : []; + +/** The rest of the composer: the card, its queue and mention menu, Home, the readouts. */ +const COMPOSER = [ + 'ChatInput.tsx', + 'MessageQueue.tsx', + 'MentionPopover.tsx', + 'Hub.tsx', + 'ContextWindowIndicator.tsx', + 'copilot/CopilotControl.tsx', + 'settings/models/bottom_bar/ModelsBottomBar.tsx', +]; + +const sources: { label: string; path: string }[] = [ + ...popups.map((f) => ({ label: `bottom_menu/${f}`, path: join(DIR, f) })), + ...[ + ...COMPOSER, + ...TRANSCRIPT, + ...folderComponents('subagent'), + ...folderComponents('transcript'), + ].map((f) => ({ label: f, path: join(COMPONENTS, f) })), +]; + /** * The source with every comment form removed. * @@ -51,18 +112,57 @@ describe('composer popup typography', () => { expect(popups.length).toBeGreaterThan(3); }); - it.each(popups)('%s uses the type scale, not raw pixel sizes', (file) => { + it('reads every transcript file it names, so a rename cannot empty the list', () => { + expect([...COMPOSER, ...TRANSCRIPT].filter((f) => !existsSync(join(COMPONENTS, f)))).toEqual( + [] + ); + expect(folderComponents('subagent').length).toBeGreaterThan(0); + }); + + it.each(sources)('$label uses the type scale, not raw pixel sizes', ({ label, path }) => { // Strip comments so a note explaining the rule cannot trip it — this repo // has now three times shipped a guard that matched prose. - const code = codeOnly(readFileSync(join(DIR, file), 'utf8')); + const code = codeOnly(readFileSync(path, 'utf8')); const raw = code.match(/text-\[\d+px\]/g); - expect(raw, `${file} pins a raw font size; use a token from main.css:103-170`).toBeNull(); + expect(raw, `${label} pins a raw font size; use a token from main.css:103-170`).toBeNull(); + }); + + it.each(sources)( + '$label does not re-pin font-sans, which the body already sets', + ({ label, path }) => { + const code = codeOnly(readFileSync(path, 'utf8')); + const uses = code.match(/font-sans/g)?.length ?? 0; + expect(uses, `${label} re-pins font-sans`).toBe(0); + } + ); + + /** + * Spec 3.7 acceptance: "No mono anywhere in the composer." Mono is for + * machine strings (principle 3); a count, a cost or a folder name is not one. + * The one sanctioned use is the working folder's full path, which lives in the + * folder chip's tooltip, not on the line. + */ + const MONO_ALLOWED: Record = { 'DirSwitcher.tsx': 1 }; + const composerSurfaces = [ + ...popups.map((file) => join(DIR, file)), + ...COMPOSER.map((file) => join(COMPONENTS, file)), + ]; + + it.each(composerSurfaces)('%s draws no visible text in mono', (path) => { + const code = codeOnly(readFileSync(path, 'utf8')); + const name = path.split(/[\\/]/).pop() ?? path; + const uses = code.match(/font-mono/g)?.length ?? 0; + expect(uses, `${name}: mono is for machine strings in tooltips only`).toBeLessThanOrEqual( + MONO_ALLOWED[name] ?? 0 + ); }); - it.each(popups)('%s does not re-pin font-sans, which the body already sets', (file) => { - const code = codeOnly(readFileSync(join(DIR, file), 'utf8')); - expect(code.includes('font-sans')).toBe(false); + it('keeps the pickers stylesheet on the type tokens', () => { + const css = codeOnly(readFileSync(join(DIR, 'pickers.css'), 'utf8')); + expect(css, 'a raw px font size').not.toMatch(/font-size:\s*\d/); + expect(css, 'a font stack').not.toMatch(/font-family/); + expect(css).toMatch(/font-size: var\(--text-secondary\)/); }); // Guard the guard. Without this, a stripper that quietly stopped working diff --git a/ui/desktop/src/styles/search.css b/ui/desktop/src/styles/search.css index ff8a707ff..2545140e1 100644 --- a/ui/desktop/src/styles/search.css +++ b/ui/desktop/src/styles/search.css @@ -24,35 +24,138 @@ box-shadow: inset 0 0 0 1px var(--color-yellow-100); } -.search-bar-enter, -.search-bar-exit { - --search-bar-height: 72px; - overflow: hidden; - transition: max-height 150ms ease-out; +/* ── Find in chat: an overlay, not a row (spec 3.16) ───────────────────────── + The bar used to be a sticky row inside the transcript's scroll content that + revealed with a `max-height` transition under `overflow: hidden`, so opening + it pushed the whole conversation down by up to 88px while it animated, and + the ceiling clipped the two-row "type at least…" state. It now floats. + + `position: absolute` against the transcript cell (`BaseChat`'s `ScrollArea` + root, `relative`, and the same in `SessionHistoryView`), which sits OUTSIDE + the scrolling viewport, so the bar neither scrolls with the conversation nor + is clipped by it. ⚠ `SearchView` PORTALS the bar into that cell; rendered in + place it would not stay there, because `SearchHighlighter` makes the + viewport `position: relative` as soon as a search starts, and from then on + the viewport is the containing block and the bar scrolls off with the text + (vision V2-02). It sits 8px under the header band and 16px in from the right + edge, where a toast also lands. A toast there steps 8px below the bar for as + long as it is open, instead of covering its count and buttons + (`components/alerts/toastClearance.ts`, vision V2-R7-4). + + Width: compact, like an editor's find widget, and never wider than the + transcript's measure or the cell less its gutters. Nothing geometric + animates: the bar opens with opacity and an 8px drop over `--dur-fast-max` + and closes with opacity and a 4px rise over `--dur-fast` (exits travel half + as far, a tier faster). `SearchBar` closes on the close keyframe's + `animationend`, so the exit can never be cut short by a timer. */ +.br-find-overlay { + position: absolute; + top: 8px; + right: 16px; + z-index: 60; + width: min(440px, calc(100% - 32px)); } -/* ⚠ **The ceiling has to clear what the bar actually renders.** The reveal is - a `max-height` transition, and `overflow: hidden` is what makes it one — so a - bar that is TALLER than the ceiling is silently cut off rather than scrolled. - When the bar is saying it has not searched yet it is two rows, and at the - one-row 72px the sentence was clipped through its middle (measured in the app - on Settings → Extensions, 2026-09-12). Raised only while that row is there, - so the ordinary open/close keeps the travel it was tuned for. */ -.search-bar-enter.search-bar-has-note, -.search-bar-exit.search-bar-has-note { - --search-bar-height: 132px; +.br-find-overlay[data-state='open'] { + animation: br-find-in var(--dur-fast-max) var(--ease-out); } -.search-bar-enter { - max-height: var(--search-bar-height); +.br-find-overlay[data-state='closed'] { + pointer-events: none; + animation: br-find-out var(--dur-fast) var(--ease-out) forwards; } -.search-bar-exit { - max-height: 0; +@keyframes br-find-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes br-find-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(-4px); + } } -@starting-style { - .search-bar-enter { - max-height: 0; +/* Reduced motion: no travel. `SearchBar` closes at once, so only the opening + fade is left, and the global reset shortens it. */ +@media (prefers-reduced-motion: reduce) { + .br-find-overlay[data-state='open'] { + animation-name: br-fade-in; + } + + .br-find-overlay[data-state='closed'] { + animation-name: br-fade-out; } } + +/* One row: glyph, field, count, help, then the four 28px icon buttons. */ +.br-find-row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 4px 4px 12px; +} + +.br-find-icon { + flex: none; + width: var(--icon-row); + height: var(--icon-row); + color: var(--text-muted); +} + +.br-find-input { + flex: 1 1 auto; + min-width: 0; + height: 32px; + padding: 0 4px; + border: 0; + outline: none; + background: transparent; + color: var(--text-default); + font-family: var(--font-sans); + font-size: var(--text-secondary); + line-height: var(--text-secondary--line-height); +} + +.br-find-input::placeholder { + color: var(--text-muted); +} + +.br-find-count { + flex: none; + padding: 0 4px; + color: var(--text-muted); + font-size: var(--text-supporting); + line-height: var(--text-supporting--line-height); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.br-find-count:empty { + display: none; +} + +/* "Aa" while match-case is on: the pressed fill, never the accent. The fill + step (R-27), as the band's pressed toggles take it; inside the bar's dark + floating ground it is re-pointed one step deeper, where `--background-medium` + equalled that ground and the "on" state vanished. */ +.br-find-case[aria-pressed='true'] { + background-color: var(--background-fill-strong); + color: var(--text-default); +} + +.br-find-note { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); +} diff --git a/ui/desktop/src/styles/searchBarNote.test.ts b/ui/desktop/src/styles/searchBarNote.test.ts index eca71d047..44a89e1e9 100644 --- a/ui/desktop/src/styles/searchBarNote.test.ts +++ b/ui/desktop/src/styles/searchBarNote.test.ts @@ -1,49 +1,121 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; /** - * The search bar's open/close reveal is a `max-height` transition under - * `overflow: hidden`, so the ceiling is also a CLIP: a bar taller than - * `--search-bar-height` is cut off, not scrolled. + * Find in chat is an OVERLAY (spec 3.16), and these are the stylesheet's half of + * that claim. * - * When the bar is telling the user that the term is too short to search, it is - * two rows rather than one, and the one-row ceiling cut that sentence through - * its middle — measured in the running app on Settings → Extensions before this - * rule existed. `SearchBar` adds `search-bar-has-note` for exactly that state - * (asserted in `SearchBar.test.tsx`, which can read the class but not the - * layout) and this is the stylesheet's half: the class has to buy real room. + * It used to be a sticky row in the transcript's scroll content that revealed + * with a `max-height` transition under `overflow: hidden`, so opening it pushed + * the conversation down while it animated, and the ceiling was also a CLIP: + * when the bar said "type at least 2 characters" it was two rows, and the + * one-row ceiling cut that sentence through its middle (measured in the app on + * 2026-09-12). An absolutely positioned overlay pushes nothing and has no + * ceiling, so the note row fits by construction, and these tests keep it that + * way. * * ⚠ **Asserted at the source, and it has to be.** jsdom has no layout engine and - * never loads this file, so a component test that reads `maxHeight` sees the - * empty string whether the rule exists or not. + * never loads this file, so a component test that reads a position or a height + * passes whether the rule exists or not. */ -const CSS = readFileSync(join(__dirname, 'search.css'), 'utf8'); - -/** The `--search-bar-height` a selector sets, in px. */ -function ceiling(selector: RegExp): number { - const rule = CSS.match(new RegExp(`${selector.source}[^}]*\\}`)); - expect(rule, `no rule matching ${selector}`).not.toBeNull(); - const value = rule![0].match(/--search-bar-height:\s*(\d+)px/); - expect(value, `no --search-bar-height in ${rule![0]}`).not.toBeNull(); - return Number(value![1]); +/** The stylesheet with its comments blanked: the history in them names what is gone. */ +const CSS = readFileSync(join(__dirname, 'search.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** The body of the first rule whose selector is exactly `selector`. */ +function ruleBody(selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = CSS.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`search.css has no rule for ${selector}`); + return match[1]; } -describe('the search bar reveal ceiling', () => { - it('gives the two-row bar more room than the one-row bar', () => { - const oneRow = ceiling(/\.search-bar-enter,\s*\n?\s*\.search-bar-exit\s*\{/); - const withNote = ceiling( - /\.search-bar-enter\.search-bar-has-note,\s*\n?\s*\.search-bar-exit\.search-bar-has-note\s*\{/ +describe('find in chat floats over the transcript', () => { + it('is positioned absolutely, top right, so opening it moves nothing', () => { + const overlay = ruleBody('.br-find-overlay'); + expect(overlay).toMatch(/position:\s*absolute/); + expect(overlay).toMatch(/top:\s*8px/); + expect(overlay).toMatch(/right:\s*16px/); + expect(overlay).not.toMatch(/sticky/); + }); + + it('has no reveal ceiling left to clip the two-row note', () => { + // The old reveal and its two ceilings are gone for good. + expect(CSS).not.toMatch(/max-height/); + expect(CSS).not.toMatch(/--search-bar-height/); + expect(CSS).not.toMatch(/\.search-bar-(enter|exit)/); + expect(ruleBody('.br-find-overlay')).not.toMatch(/overflow:\s*hidden/); + }); + + it('opens with an 8px drop and closes with a 4px rise, on the motion tokens', () => { + expect(ruleBody(".br-find-overlay[data-state='open']")).toMatch( + /animation:\s*br-find-in var\(--dur-fast-max\) var\(--ease-out\)/ ); + expect(ruleBody(".br-find-overlay[data-state='closed']")).toMatch( + /animation:\s*br-find-out var\(--dur-fast\) var\(--ease-out\) forwards/ + ); + const enter = CSS.match(/@keyframes br-find-in\s*\{([\s\S]*?)\n\}/); + const exit = CSS.match(/@keyframes br-find-out\s*\{([\s\S]*?)\n\}/); + expect(enter?.[1]).toMatch(/translateY\(-8px\)/); + expect(exit?.[1]).toMatch(/translateY\(-4px\)/); + // Only opacity and transform move. + for (const frames of [enter?.[1] ?? '', exit?.[1] ?? '']) { + const properties = [...frames.matchAll(/([a-z-]+):/g)].map((m) => m[1]); + expect(new Set(properties)).toEqual(new Set(['opacity', 'transform'])); + } + }); + + it('rests to a fade under reduced motion', () => { + const block = CSS.match(/@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}/); + expect(block, 'no reduced-motion block in search.css').not.toBeNull(); + expect(block![1]).toMatch(/\[data-state='open'\]\s*\{\s*animation-name:\s*br-fade-in/); + }); +}); + +/** + * `SearchView` is the transcript's find and nothing else (spec 3.16): the list + * views filter with the band's `FilterInput`. So only the live chat and a saved + * transcript import it. + * + * `PENDING` names a view whose owner has not moved it to `FilterInput` yet. It + * may only shrink: an entry whose file no longer imports `SearchView` fails the + * second test, so the owner deletes its line in the same commit. + */ +const ALLOWED = ['components/BaseChat.tsx', 'components/sessions/SessionHistoryView.tsx']; +const PENDING: Record = {}; + +const SRC = join(__dirname, '..'); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name.startsWith('.')) continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)); + else if (/\.(ts|tsx)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name)) out.push(path); + } + return out; +} + +function importsSearchView(path: string): boolean { + return /from\s+['"][./]*(?:conversation\/)?SearchView['"]/.test(readFileSync(path, 'utf8')); +} + +describe('SearchView is the transcript find only', () => { + const importers = sourceFiles(SRC) + .filter((path) => !path.endsWith(join('conversation', 'SearchView.tsx'))) + .filter(importsSearchView) + .map((path) => relative(SRC, path).split('\\').join('/')) + .sort(); - // The note row measured 33px in the app (py-2 + a line + its hairline), and - // it wraps to two lines in a narrow window. - expect(withNote).toBeGreaterThanOrEqual(oneRow + 33); + it('is imported only by the live chat and a saved transcript (plus the pending views)', () => { + const unexpected = importers.filter((path) => !ALLOWED.includes(path) && !(path in PENDING)); + expect(unexpected).toEqual([]); + for (const path of ALLOWED) expect(importers).toContain(path); }); - it('still collapses to nothing on the way out', () => { - // The exit animates to 0 whatever the ceiling is; a `has-note` bar that - // raised the floor instead of the ceiling would never close. - expect(CSS).toMatch(/\.search-bar-exit\s*\{\s*max-height:\s*0;/); + it('carries no pending entry that has already moved', () => { + const moved = Object.keys(PENDING).filter((path) => !importers.includes(path)); + expect(moved, 'delete these PENDING lines: the view no longer imports SearchView').toEqual([]); }); }); diff --git a/ui/desktop/src/styles/settingsRowHover.test.ts b/ui/desktop/src/styles/settingsRowHover.test.ts index 696ecafd3..1260aa8f8 100644 --- a/ui/desktop/src/styles/settingsRowHover.test.ts +++ b/ui/desktop/src/styles/settingsRowHover.test.ts @@ -30,15 +30,22 @@ const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); function hoverRule(): string { const match = CSS.match( - /\.biorouter-settings-row:not\(\.tint-selected\):hover,\s*\.biorouter-settings-row:not\(\.tint-selected\):focus-within\s*\{([^}]*)\}/ + /\.biorouter-settings-row:not\(\.tint-selected\):hover,\s*\.biorouter-settings-row:not\(\.tint-selected\):has\(:focus-visible\)\s*\{([^}]*)\}/ ); expect(match, 'expected the settings-row hover rule to exclude a tinted row').toBeTruthy(); return match![1]; } +/** The ONE rule the content row and the settings row share (spec 2.6). */ function baseRule(): string { - const match = CSS.match(/\n\.biorouter-settings-row\s*\{([^}]*)\}/); - expect(match, 'expected a `.biorouter-settings-row` base rule').toBeTruthy(); + const match = CSS.match(/\n\.biorouter-list-row,\s*\.biorouter-settings-row\s*\{([^}]*)\}/); + expect(match, 'expected one rule shared by the list row and the settings row').toBeTruthy(); + return match![1]; +} + +function ruleFor(selector: RegExp): string { + const match = CSS.match(new RegExp(`\\n${selector.source}\\s*\\{([^}]*)\\}`)); + expect(match, `expected a rule for ${selector.source}`).toBeTruthy(); return match![1]; } @@ -49,8 +56,16 @@ describe('the settings row hover wash', () => { expect(rule).not.toMatch(/(^|[\s;])background\s*:/); }); - it('still paints the same 38% neutral wash', () => { - expect(hoverRule()).toContain('color-mix(in srgb, var(--background-medium) 38%, transparent)'); + it('paints the one row hover, --overlay-hover, the same as a list row (no 42% / 38% fork)', () => { + expect(hoverRule()).toMatch(/background-color:\s*var\(--overlay-hover\);/); + expect( + ruleFor(/\.biorouter-list-row:hover,\s*\.biorouter-list-row:has\(:focus-visible\)/) + ).toMatch(/background-color:\s*var\(--overlay-hover\);/); + expect(CSS).not.toMatch(/var\(--background-medium\) (42|38)%/); + }); + + it('eases the hover over --dur-fast-min', () => { + expect(baseRule()).toMatch(/background-color var\(--dur-fast-min\) var\(--ease-out\)/); }); /** @@ -77,7 +92,50 @@ describe('the settings row hover wash', () => { */ it('suppresses the trailing hairline by `:last-child` alone', () => { expect(CSS).toMatch( - /\.biorouter-settings-row:last-child\s*\{\s*border-bottom-color:\s*transparent;\s*\}/ + /\.biorouter-list-row:last-child::after,\s*\.biorouter-settings-row:last-child::after\s*\{\s*content:\s*none;\s*\}/ + ); + }); + + /** + * A bottom BORDER on a rounded row curls up at both ends (plainly in forced colours, which + * paint every border in the text colour). The divider is a straight line under the row, inset + * by the corner radius, and the border stays only as 1px of transparent room so no row moves + * (Crew's `dialogs/dialogs.css` recipe, QA Q4-25). + */ + it('draws a straight hairline inset by the row radius, not a curling border', () => { + expect(baseRule()).toMatch(/border-bottom:\s*1px solid transparent;/); + const line = ruleFor(/\.biorouter-list-row::after,\s*\.biorouter-settings-row::after/); + expect(line).toMatch(/inset-inline:\s*var\(--radius-md\);/); + expect(line).toMatch(/bottom:\s*-1px;/); + expect(line).toMatch(/border-top:\s*1px solid var\(--border-subtle\);/); + expect( + ruleFor( + /\.biorouter-list-row:focus-visible::after,\s*\.biorouter-list-row:has\(:focus-visible\)::after,\s*\.biorouter-settings-row:focus-visible::after,\s*\.biorouter-settings-row:has\(:focus-visible\)::after/ + ) + ).toMatch(/border-top-color:\s*var\(--border-focus\);/); + }); + + /** + * A click is not focus the person asked to see (spec 2.5, QA 2026-10-10). Chromium: click the + * "Skills" switch, move away, wait; the row kept BOTH its hover wash and the firmed hairline + * because `:focus-within` is true for a clicked button while `:focus-visible` is not. So no + * row rule may key its wash or its line on `:focus-within`; the keyboard half is + * `:focus-visible` on the row or on something inside it (a Tab, an arrow key, a text field). + */ + it('shows the focus treatment for keyboard focus only, never for `:focus-within` (a click)', () => { + const source = CSS.replace(/\/\*[\s\S]*?\*\//g, ' '); + // Every rule that washes or firms a row. RowActions' reveal also listens to `:focus-within` + // on purpose, but it only changes opacity, so it is not among them. + const rules = Array.from(source.matchAll(/([^{}]+)\{([^{}]*)\}/g)).filter( + (match) => + /\.biorouter-(?:list|settings)-row/.test(match[1]) && + /(?:^|[\s;])(?:background-color|border-top-color)\s*:/.test(match[2]) ); + expect(rules.length).toBeGreaterThanOrEqual(3); + expect( + rules.filter((match) => /:focus-within/.test(match[1])).map((match) => match[1]) + ).toEqual([]); + expect(CSS).toMatch(/\.biorouter-list-row:has\(:focus-visible\)::after/); + expect(CSS).toMatch(/\.biorouter-settings-row:has\(:focus-visible\)::after/); }); }); diff --git a/ui/desktop/src/styles/summaryRail.test.ts b/ui/desktop/src/styles/summaryRail.test.ts new file mode 100644 index 000000000..a78d3e840 --- /dev/null +++ b/ui/desktop/src/styles/summaryRail.test.ts @@ -0,0 +1,375 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + CHAT_COLUMN_CHROME, + CHAT_FULL_MEASURE_WIDTH, + CHAT_MEASURE, + READABLE_CHAT_WIDTH, + SUMMARY_RAIL_MAX_WIDTH, + SUMMARY_RAIL_MIN_WIDTH, +} from '../components/Layout/yieldLadder'; + +/** + * RUNG 0, pinned at the source. + * + * The decision lives in `yieldLadder.summaryRailFit` and is unit-tested on both + * sides of every seam. What this file guards is the half no component test can + * see: jsdom never loads a stylesheet, never lays out a grid and never resolves + * a custom property, so a render test of the split box passes whether the rail's + * grid agrees with the ladder or not. The real layout is measured in + * `components/summary/summaryRailGeometry.browser.test.ts`. + */ +const MAIN_CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); +const RAIL_CSS = readFileSync(join(__dirname, '../components/summary/summaryRail.css'), 'utf8'); +const BASE_CHAT = readFileSync(join(__dirname, '../components/BaseChat.tsx'), 'utf8'); +const RAIL_TSX = readFileSync(join(__dirname, '../components/summary/ChatSummaryRail.tsx'), 'utf8'); + +/** The stylesheet with comments blanked (same length), so offsets survive. */ +const RAIL_CODE = RAIL_CSS.replace(/\/\*[\s\S]*?\*\//g, (comment) => + comment.replace(/[^\n]/g, ' ') +); + +/** Strip `//` and block comments from TS/TSX so prose cannot satisfy a match. */ +function codeWithoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function geometryBlock(): string { + const start = RAIL_CSS.indexOf('/* RAIL GEOMETRY BEGIN */'); + const end = RAIL_CSS.indexOf('/* RAIL GEOMETRY END */'); + expect(start, 'the geometry block markers moved').toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + return RAIL_CODE.slice(start, end); +} + +/** Every rule in the rail stylesheet whose whitespace-collapsed selector equals `selector`. */ +function rulesFor(selector: string): { index: number; body: string }[] { + const wanted = selector.replace(/\s+/g, ' ').trim(); + const found: { index: number; body: string }[] = []; + const pattern = /([^{}]+)\{([^{}]*)\}/g; + for (let match = pattern.exec(RAIL_CODE); match; match = pattern.exec(RAIL_CODE)) { + if (match[1].replace(/\s+/g, ' ').trim() === wanted) { + found.push({ index: match.index, body: match[2] }); + } + } + return found; +} + +/** The one top-level (unconditioned) rule for `selector`. */ +function onlyRule(selector: string): { index: number; body: string } { + const found = rulesFor(selector).filter(({ index }) => depthAt(index) === 0); + if (found.length !== 1) { + throw new Error(`expected exactly one rule for ${selector}, got ${found.length}`); + } + return found[0]; +} + +function property(body: string, name: string): string | null { + const match = body.match(new RegExp(`(?:^|;|\\s)${name}:\\s*([^;]+);`)); + return match ? match[1].replace(/\s+/g, ' ').trim() : null; +} + +/** Brace depth at an offset: 0 is a top-level, unlayered rule. */ +function depthAt(index: number): number { + let depth = 0; + for (let i = 0; i < index; i += 1) { + if (RAIL_CODE[i] === '{') depth += 1; + else if (RAIL_CODE[i] === '}') depth -= 1; + } + return depth; +} + +const RAIL_ONLY = '[data-preview-split][data-summary-rail]:not([data-preview-layout])'; +const SIDE_AND_RAIL = "[data-preview-split][data-preview-layout='side'][data-summary-rail]"; +const RAIL_CELL = "[data-preview-split][data-summary-rail] > [data-preview-area='rail']"; + +describe('rung 0: the summary rail’s grid agrees with the ladder', () => { + it('derives the 816px floor from the chat measure and the transcript’s own chrome', () => { + const measure = MAIN_CSS.match(/--measure-chat:\s*([^;]+);/); + expect(measure?.[1].trim()).toBe(`${CHAT_MEASURE}px`); + expect(CHAT_FULL_MEASURE_WIDTH).toBe(CHAT_MEASURE + CHAT_COLUMN_CHROME); + // 56 = the ScrollArea root's `px-1` (2 × 4) + its viewport's `paddingX={6}` + // (2 × 24). If either moves in BaseChat, the 56 is wrong and so is every + // seam rung 0 computes from it. + expect(CHAT_COLUMN_CHROME).toBe(2 * 4 + 2 * 6 * 4); + const code = codeWithoutComments(BASE_CHAT); + expect(code).toContain('paddingX={6}'); + expect(code).toContain("const contentClassName = 'px-1';"); + }); + + it('places the rail beside the transcript and the composer, under the header', () => { + const rail = onlyRule(RAIL_ONLY); + expect(property(rail.body, 'display')).toBe('grid'); + // `clip`, never `hidden`, for the reason rung 2 gives: a hidden box is a + // scroll container that a descendant's scrollIntoView can shift sideways. + expect(property(rail.body, 'overflow')).toBe('clip'); + expect(property(rail.body, 'grid-template-columns')).toBe( + 'minmax(0, 1fr) var(--summary-rail-width)' + ); + expect(property(rail.body, 'grid-template-rows')).toBe('auto auto minmax(0, 1fr) auto'); + // The subagent line sits on the transcript's track, so its box is centred + // on the column it belongs to; the rail starts beside it, under the header + // (vision V2-R2-3). + expect(property(rail.body, 'grid-template-areas')).toBe( + "'header header' 'subheader rail' 'transcript rail' 'composer rail'" + ); + }); + + it('keys the rail-only template on the absence of a preview layout', () => { + // Rung 2's stack template has the same specificity; without the `:not()` a + // stray `data-summary-rail` could override it on source order. + for (const { body } of rulesFor(RAIL_ONLY)) expect(body).toContain('grid-template'); + const selectors = [...geometryBlock().matchAll(/([^{}]+)\{/g)] + .flatMap((match) => match[1].split(',')) + .map((selector) => selector.replace(/\s+/g, ' ').trim()) + .filter((selector) => selector.includes('[data-summary-rail]')); + expect(selectors.length).toBeGreaterThan(0); + for (const selector of selectors) { + const guarded = + selector.includes(':not([data-preview-layout])') || + selector.includes("[data-preview-layout='side']") || + selector === RAIL_CELL; + expect(guarded, selector).toBe(true); + } + }); + + it('flattens the column and the body instead of re-parenting them', () => { + const flattened = onlyRule( + `${RAIL_ONLY} > [data-preview-area='column'], ${RAIL_ONLY} > [data-preview-area='column'] > [data-preview-area='body']` + ); + expect(property(flattened.body, 'display')).toBe('contents'); + for (const area of ['header', 'subheader', 'transcript', 'composer']) { + const placed = onlyRule(`${RAIL_ONLY} [data-preview-area='${area}']`); + expect(property(placed.body, 'grid-area'), area).toBe(area); + } + }); + + it('seats the rail between the conversation and a side preview on an ultrawide pane', () => { + const both = onlyRule(SIDE_AND_RAIL); + expect(property(both.body, 'grid-template-columns')).toBe( + `minmax(${READABLE_CHAT_WIDTH}px, 1fr) var(--summary-rail-width) var(--preview-panel-width)` + ); + expect(property(both.body, 'grid-template-areas')).toBe( + "'header header preview' 'subheader rail preview' 'transcript rail preview' 'composer rail preview'" + ); + }); + + it('pads the rail cell 12px except on the side the transcript’s gutter already holds', () => { + const cell = onlyRule(RAIL_CELL); + expect(property(cell.body, 'grid-area')).toBe('rail'); + expect(property(cell.body, 'padding')).toBe('12px 12px 12px 0'); + expect(property(cell.body, 'min-width')).toBe('0'); + expect(SUMMARY_RAIL_MIN_WIDTH).toBe(240); + expect(SUMMARY_RAIL_MAX_WIDTH).toBe(280); + }); + + it('is unlayered, so it beats the utilities on the same elements', () => { + for (const selector of [RAIL_ONLY, SIDE_AND_RAIL, RAIL_CELL]) { + // Every rule for the selector, not just the top-level one `onlyRule` picks. + const found = rulesFor(selector); + expect(found, selector).toHaveLength(1); + expect(depthAt(found[0].index), selector).toBe(0); + } + expect(RAIL_CODE).not.toMatch(/@layer/); + }); + + /** + * The seam is decided in JS, once. A container or media condition here would + * be a second seam free to drift from 1056; a transition here would tween the + * transcript's width, which re-lays out a transcript that is not virtualised + * on every frame (Codex's pinned-summary jitter). + */ + it('has no condition, transition or animation in its geometry', () => { + const block = geometryBlock(); + expect(block).not.toMatch(/@container|@media|transition|animation/); + }); +}); + +describe('rung 0: the card’s motion stays off the grid', () => { + it('enters once, from the card, and never replays after a resize', () => { + const enter = onlyRule(".br-summary-card[data-state='open']:not([data-entered])"); + expect(property(enter.body, 'animation')).toBe( + 'br-summary-rail-in var(--dur-fast-max) var(--ease-out) 60ms both' + ); + const exit = onlyRule(".br-summary-card[data-state='closed']"); + expect(property(exit.body, 'animation')).toBe( + 'br-summary-rail-out var(--dur-fast) var(--ease-out) forwards' + ); + }); + + it('animates only transform and opacity', () => { + const keyframes = RAIL_CODE.match(/@keyframes br-summary-rail-[\w-]+\s*\{[\s\S]*?\}\s*\}/g); + expect(keyframes?.length).toBe(4); + for (const frame of keyframes ?? []) { + const props = [...frame.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]); + for (const prop of props) expect(['opacity', 'transform']).toContain(prop); + } + }); + + it('stops while the window or the sidebar is resized, and only fades under reduced motion', () => { + const resize = onlyRule( + "body.biorouter-window-resizing [data-preview-area='rail'] > .br-summary-card, body.biorouter-sidebar-resizing [data-preview-area='rail'] > .br-summary-card" + ); + expect(property(resize.body, 'animation')).toBe('none !important'); + const reduced = RAIL_CODE.slice(RAIL_CODE.indexOf('@media (prefers-reduced-motion: reduce)')); + expect(reduced).toContain('br-summary-rail-fade-in'); + expect(reduced).toContain('br-summary-rail-fade-out'); + }); + + it('snaps the header button’s pressed fill when geometry flips it (V2-R2-6)', () => { + // Top level and unlayered, so it beats the Button's layered transition utility. + expect(property(onlyRule('[data-summary-mode][data-still]').body, 'transition')).toBe('none'); + // The button stamps it, and BaseChat tells it which changes were the person's. + const button = readFileSync( + join(__dirname, '../components/summary/SummaryHeaderButton.tsx'), + 'utf8' + ); + expect(codeWithoutComments(button)).toContain("data-still={stillAt !== 0 ? '' : undefined}"); + expect(codeWithoutComments(BASE_CHAT)).toContain('railStill={summaryRail.still}'); + }); +}); + +describe('rung 0: BaseChat mounts one rail, statically', () => { + it('marks the rail cell exactly once and renders one ChatSummaryRail', () => { + const code = codeWithoutComments(BASE_CHAT); + expect(code.match(/data-preview-area="rail"/g) ?? []).toHaveLength(1); + expect(code.match(/ { + // A lazily imported stylesheet would leave the split box unstyled until the + // module arrived. + expect(codeWithoutComments(BASE_CHAT)).toMatch( + /^import \{ ChatSummaryRail \} from '\.\/summary\/ChatSummaryRail';$/m + ); + expect(codeWithoutComments(BASE_CHAT)).not.toMatch( + /lazy\(\s*\(\)\s*=>\s*import\([^)]*ChatSummaryRail/ + ); + expect(codeWithoutComments(RAIL_TSX)).toMatch(/^import '\.\/summaryRail\.css';$/m); + }); +}); + +describe('the summary’s type: two sizes only, 13 and 12', () => { + const CONTENT_CSS = readFileSync( + join(__dirname, '../components/summary/chatSummary.css'), + 'utf8' + ).replace(/\/\*[\s\S]*?\*\//g, ''); + const SUMMARY_TSX = codeWithoutComments( + readFileSync(join(__dirname, '../components/ChatSummary.tsx'), 'utf8') + ); + + it('declares only the secondary (13) and supporting (12) roles', () => { + const sizes = [...CONTENT_CSS.matchAll(/font-size:\s*([^;]+);/g)].map((match) => + match[1].trim() + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(['var(--text-secondary)', 'var(--text-supporting)'], size).toContain(size); + } + }); + + it('sets a size on every Button it draws, because a Button is 14/500 at every rung', () => { + // The footer rows and the retry each carry a class that overrides it; a new + // Button without one would put a third size in the card. + const buttons = SUMMARY_TSX.match(//g) ?? []; + expect(buttons.length).toBe(3); + for (const button of buttons) { + expect(button).toMatch(/className="br-summary__(action|retry)"/); + } + expect(CONTENT_CSS).toMatch(/\.br-summary \.br-summary__retry \{[^}]*font-size/); + expect(CONTENT_CSS).toMatch(/\.br-summary \.br-summary__action \{[^}]*font-size/); + }); +}); + +describe('the summary is filled, not outlined (owner message 13, R-27)', () => { + const CONTENT_CODE = readFileSync( + join(__dirname, '../components/summary/chatSummary.css'), + 'utf8' + ).replace(/\/\*[\s\S]*?\*\//g, ''); + const BUTTON_TSX = codeWithoutComments( + readFileSync(join(__dirname, '../components/summary/SummaryHeaderButton.tsx'), 'utf8') + ); + + /** The body of the one top-level rule for `selector` in the content stylesheet. */ + function contentRule(selector: string): string { + const wanted = selector.replace(/\s+/g, ' ').trim(); + const pattern = /([^{}]+)\{([^{}]*)\}/g; + const found: string[] = []; + for (let match = pattern.exec(CONTENT_CODE); match; match = pattern.exec(CONTENT_CODE)) { + if (match[1].replace(/\s+/g, ' ').trim() === wanted) found.push(match[2]); + } + if (found.length !== 1) + throw new Error(`expected one rule for ${selector}, got ${found.length}`); + return found[0]; + } + + it('fills the rail card and keeps its 1px border, transparent', () => { + const card = onlyRule('.br-summary-card').body; + expect(property(card, 'background')).toBe('var(--background-fill)'); + // Transparent, never removed: nothing moves, and forced colours still draw it. + expect(property(card, 'border')).toBe('1px solid transparent'); + expect(property(card, 'border-radius')).toBe('var(--radius-container)'); + // The card is a filled box, so a filled control inside steps back to the page. + expect(codeWithoutComments(RAIL_TSX)).toMatch(/className="br-summary-card br-fill-surface"/); + }); + + it('parts the to-do list, the statistics and the footer by space, never by a hairline', () => { + expect(CONTENT_CODE).not.toMatch(/border(-top|-bottom)?\s*:/); + expect(property(contentRule('.br-summary__stats[data-after-todos]'), 'margin-top')).toBe( + '12px' + ); + expect(property(contentRule('.br-summary__footer'), 'padding')).toBe('4px'); + }); + + it('grounds the progress track one step off the card’s fill, where the primitive’s would vanish', () => { + // The primitive's track is --background-muted, which IS --background-fill. + expect(property(onlyRule('.br-summary-card').body, '--br-summary-track')).toBe( + 'var(--background-fill-strong)' + ); + expect(property(contentRule('.br-summary .br-summary__progress'), 'background-color')).toBe( + 'var(--br-summary-track, var(--background-muted))' + ); + }); + + it('fades a scroll edge only where rows are hidden, from the body’s own scroll position', () => { + for (const name of ['--br-summary-fade-top', '--br-summary-fade-bottom']) { + expect(CONTENT_CODE).toMatch( + new RegExp(`@property ${name} \\{[^}]*syntax: '';[^}]*initial-value: 0px;`) + ); + } + const supports = CONTENT_CODE.slice(CONTENT_CODE.indexOf('@supports (animation-timeline')); + expect(supports).toMatch(/animation-timeline: scroll\(self\), scroll\(self\);/); + expect(supports.replace(/\s+/g, ' ')).toContain( + 'animation-range: 0 16px, calc(100% - 16px) 100%;' + ); + expect(supports).toMatch(/mask-image: linear-gradient\(/); + // A focused body draws its focus edge whole. + expect(supports.replace(/\s+/g, ' ')).toMatch( + /\.br-summary__body:focus-visible \{ -webkit-mask-image: none; mask-image: none; \}/ + ); + // The running step is kept clear of the fades when scrolled into view. + expect(CONTENT_CODE).toMatch(/\n\.br-summary__body \{[^}]*scroll-padding-block: 16px;/); + }); + + it('presses the header button on the band’s fill step, and rings the dot in the ground under it', () => { + expect(BUTTON_TSX).toContain("expanded && 'bg-background-fill-strong text-text-default'"); + expect(BUTTON_TSX).not.toContain('bg-background-medium'); + expect(codeWithoutComments(BASE_CHAT)).toContain( + "isTerminalDockOpen && 'bg-background-fill-strong text-text-default'" + ); + expect(property(onlyRule('.br-summary-dot').body, 'box-shadow')).toBe( + '0 0 0 2px var(--sidebar)' + ); + expect(property(onlyRule("[aria-expanded='true'] > .br-summary-dot").body, 'box-shadow')).toBe( + '0 0 0 2px var(--background-fill-strong)' + ); + }); +}); diff --git a/ui/desktop/src/styles/tabFocus.test.ts b/ui/desktop/src/styles/tabFocus.test.ts index e33be70fb..e16a02632 100644 --- a/ui/desktop/src/styles/tabFocus.test.ts +++ b/ui/desktop/src/styles/tabFocus.test.ts @@ -95,12 +95,14 @@ function exemptions(): string[] { * first two-space `}`. It is UNLAYERED and sits at the end of main.css (live QA * 2026-09-24, P0-5 — inside `@layer base` it lost to every unlayered * `outline: none`); `focusFallback.test.ts` asserts where it lives and how it - * wins. The tests here only ask what it still REACHES. + * wins. The tests here only ask what it still REACHES. The LAST block with that + * head is the one: a primitive may adjust its own ring under the same query + * earlier in the file (the InfoTip's, V4-R3-1). */ function osRingBlock(): string { const stripped = CSS.replace(/\/\*[\s\S]*?\*\//g, ''); const head = '@media (prefers-contrast: more), (forced-colors: active) {'; - const start = stripped.indexOf(head); + const start = stripped.lastIndexOf(head); expect(start, 'the prefers-contrast block is unrecognisable').toBeGreaterThan(-1); let depth = 1; let i = start + head.length; @@ -389,7 +391,8 @@ describe('a tab panel takes no focus fill either', () => { * The same fill, on a region that has NO role to exempt — the third instance, * measured but deliberately left by #185 because its `:not()` keys on roles. * - * The chat summary's To Do list (`components/ChatSummary.tsx`) is an `
      ` + * The chat summary's scrolling body (`components/ChatSummary.tsx`, the + * `data-summary-body` div that holds the To do list and the statistics) is * given `tabIndex={0}` so a keyboard user can scroll it. That makes it a * REGION by #185's argument — entered, not operated — and the `[tabindex]` * arm reaches it exactly as it reached the panel. Measured on the real popover @@ -428,14 +431,17 @@ describe('a scroll region with no role opts out of the focus fill by class', () * keeps NO role — the plausible wrong fix is `role="region"`, which would * silence the fill and take the rows' list semantics with it. */ - it('is carried by the To Do list, which stays a list', () => { - const tag = CHAT_SUMMARY.match(/]*aria-label="To Do tasks"[^>]*>/); - expect(tag, 'the To Do list is no longer recognisable').toBeTruthy(); + it('is carried by the summary body, and the To do list inside it stays a list', () => { + const tag = CHAT_SUMMARY.match(/]*data-summary-body=""[^>]*>/); + expect(tag, 'the summary body is no longer recognisable').toBeTruthy(); expect(tag![0]).toContain('tabIndex={0}'); expect(tag![0]).toMatch(/className="[^"]*\bbiorouter-focus-region\b/); expect(tag![0]).not.toMatch(/\brole=/); // A selector hook, never a utility that has to be generated. expect(tag![0]).not.toContain('focus-visible:'); + const list = CHAT_SUMMARY.match(/]*aria-label=\{summaryCopy\.todoListLabel\}[^>]*>/); + expect(list, 'the To do list is no longer recognisable').toBeTruthy(); + expect(list![0]).not.toMatch(/\brole=/); }); /** diff --git a/ui/desktop/src/styles/tabFocusRing.browser.test.ts b/ui/desktop/src/styles/tabFocusRing.browser.test.ts new file mode 100644 index 000000000..144283aea --- /dev/null +++ b/ui/desktop/src/styles/tabFocusRing.browser.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +/** + * A Radix tab trigger's keyboard ring keeps one geometry in every colour mode (vision V4-16). + * + * A trigger (`ui/tabs.tsx`) is a 36px box with no side padding round a 20px label, and the + * settings band centres it in 44px, 4px below the window's top edge. Its focus mark is a 2px ring + * on `::before`, 28px tall and 6px past each side, so it stands 4px clear of the glyphs and inside + * the trigger's height. Under `prefers-contrast: more` and forced colours the OS ring block used + * to switch that ring off and let the 2px outline at a 2px offset draw instead: round the BOX, so + * it hugged the label ("Chat" touched it) and its top edge left the window. Now the ring stays and + * takes the OS colour (`--ring`, or the user's `Highlight` in forced colours) and the outline + * stands down. + * + * That is geometry and paint, so it is measured in Chromium: every top level unlayered rule and + * media block in main.css that names `[role='tab']`, over markup shaped like the settings band, + * focused with a real Tab, in normal colours, more contrast and forced colours. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAIN_CSS = readFileSync(resolve(here, 'main.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** Top level rules and at-rules that name a tab, leaving cascade layers out (none is needed). */ +function tabRules(): string { + const chunks: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < MAIN_CSS.length; i += 1) { + const ch = MAIN_CSS[i]; + if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) { + chunks.push(MAIN_CSS.slice(from, i + 1).trim()); + from = i + 1; + } + } else if (ch === ';' && depth === 0) { + from = i + 1; + } + } + return chunks + .filter((chunk) => chunk.includes("[role='tab']") && !chunk.startsWith('@layer')) + .join('\n'); +} + +/** The first value main.css gives `--name` (the base `:root` / `@theme` one, light mode). */ +function firstToken(name: string): string { + const match = MAIN_CSS.match(new RegExp(`\\s${name}:\\s*([^;]+);`)); + if (!match) throw new Error(`main.css does not declare ${name}`); + return match[1].trim(); +} + +const TOKENS = [ + '--border-focus', + '--ring', + '--color-neutral-600', + '--radius-element', + '--text-default', + '--accent-bar', +] + .map((name) => `${name}: ${firstToken(name)};`) + .join(' '); + +/** The settings band: a 44px strip at the window's top edge, 36px triggers centred, gap-5. */ +const html = `
      + + + + +
      `; + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +type Measure = { + focused: boolean; + outline: string; + ringContent: string; + ringWidth: string; + ringColour: string; + highlight: string; + ring: { left: number; right: number; top: number; bottom: number }; + label: { left: number; right: number }; + band: { top: number }; +}; + +const measure = (page: Page): Promise => + page.evaluate(() => { + const chat = document.getElementById('chat')!; + const cs = getComputedStyle(chat); + const before = getComputedStyle(chat, '::before'); + const box = chat.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(chat); + const label = range.getBoundingClientRect(); + const probe = document.createElement('div'); + probe.style.cssText = 'color: Highlight; forced-color-adjust: none'; + document.body.append(probe); + const highlight = getComputedStyle(probe).color; + probe.remove(); + // An absolutely positioned box resolves its insets to pixels against the trigger. + const px = (v: string) => Number.parseFloat(v); + return { + focused: chat.matches(':focus-visible'), + outline: cs.outlineStyle, + ringContent: before.content, + ringWidth: before.borderTopWidth, + ringColour: before.borderTopColor, + highlight, + ring: { + left: box.left + px(before.left), + right: box.right - px(before.right), + top: box.top + px(before.top), + bottom: box.bottom - px(before.bottom), + }, + label: { left: label.left, right: label.right }, + band: { top: document.querySelector('.band')!.getBoundingClientRect().top }, + }; + }); + +/** The token's colour as Chromium computes it (`--ring` is a var chain in main.css). */ +const computedRing = (page: Page) => + page.evaluate(() => { + const probe = document.createElement('div'); + probe.style.color = 'var(--ring)'; + document.body.append(probe); + const colour = getComputedStyle(probe).color; + probe.remove(); + return colour; + }); + +const MODES = ['normal colours', 'more contrast', 'forced colours'] as const; + +describe('a Radix tab trigger keeps its label-sized ring in every colour mode (V4-16)', () => { + let browser: Browser | null = null; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn( + 'No Chromium found, so the tab focus ring tests are skipped. ' + + 'Run `npx playwright install chromium` to enable them.' + ); + } + }); + + afterAll(async () => { + await browser?.close(); + }); + + it.for(MODES)('%s', async (mode, ctx) => { + if (!browser) return ctx.skip(); + const page = await browser.newPage({ viewport: { width: 520, height: 120 } }); + try { + if (mode === 'more contrast') await page.emulateMedia({ contrast: 'more' }); + if (mode === 'forced colours') await page.emulateMedia({ forcedColors: 'active' }); + await page.setContent(html); + for (let i = 0; i < 3; i += 1) await page.keyboard.press('Tab'); + const m = await measure(page); + expect(m.focused, 'Chat has keyboard focus').toBe(true); + + // One ring, and it is the pseudo ring: no outline round the box. + expect(m.outline, `${mode}: no outline round the trigger`).toBe('none'); + expect(m.ringContent, `${mode}: the ::before ring is drawn`).not.toBe('none'); + expect(m.ringWidth).toBe('2px'); + + // Its geometry: 28px tall inside the trigger, 6px past each side, inside the window. + expect(m.ring.bottom - m.ring.top, `${mode}: the ring is 28px tall`).toBeCloseTo(28, 0); + expect( + m.ring.top - m.band.top, + `${mode}: the ring's top is inside the window` + ).toBeGreaterThanOrEqual(4); + const clearLeft = m.label.left - (m.ring.left + 2); + const clearRight = m.ring.right - 2 - m.label.right; + expect(clearLeft, `${mode}: inner edge clear of the label's start`).toBeGreaterThanOrEqual(4); + expect(clearRight, `${mode}: inner edge clear of the label's end`).toBeGreaterThanOrEqual(4); + + // Its colour: the neutral focus edge, the OS ring, or the user's own Highlight. + if (mode === 'more contrast') expect(m.ringColour).toBe(await computedRing(page)); + if (mode === 'forced colours') expect(m.ringColour).toBe(m.highlight); + } finally { + await page.close(); + } + }); +}); diff --git a/ui/desktop/src/styles/themeNeutrals.test.ts b/ui/desktop/src/styles/themeNeutrals.test.ts index 4b6d74f1d..a75577d2b 100644 --- a/ui/desktop/src/styles/themeNeutrals.test.ts +++ b/ui/desktop/src/styles/themeNeutrals.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import almaMater from '../../themes/alma-mater.theme.mjs'; @@ -118,3 +120,109 @@ describe.each(['light', 'dark'] as const)('%s: the neutral scaffolding is shared expect(stale).toEqual([]); }); }); + +/** + * The control edge and the switch's off track are NEUTRALS (redesign spec 2.3), + * and so are the two fills of R-27 (owner message 13, "fills before lines"), so + * each is declared exactly twice, in the hand-authored base `:root` and `.dark` + * blocks, and never in a family block or in the generated region. + * + * Two reasons this is asserted rather than assumed. A family that restated + * `--border-control` would pass every per-family contrast assertion (it would + * still clear 3:1 on its own ground) while quietly giving that family a control + * edge the others do not have, which is the exact drift this file exists to + * stop. And `check-contrast.mjs` resolves these two as hex literals, so a + * `color-mix(...)` rewrite would turn an assertion into UNRESOLVED rather than + * into a quiet pass: the guard here keeps the declaration where the checker + * reads it. + */ +describe('the control edge, the off track and the fills are shared neutrals', () => { + const raw = readFileSync(join(__dirname, 'main.css'), 'utf8'); + // Comments are blanked to spaces of the same length, so a prose mention of a + // token cannot count as a declaration and every offset still matches `raw`. + const css = raw.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' ')); + const generatedStart = raw.indexOf('THEMES:GENERATED:FAMILIES:START'); + const generatedEnd = raw.indexOf('THEMES:GENERATED:FAMILIES:END'); + + // The dark popover's re-pointing of the strong fill and the off track is the only declaration + // of these tokens outside the two base blocks; it is held by its own test below. + const popoverFill = (() => { + const from = raw.indexOf('@ws WS-TOKENS begin: popover fill'); + const to = raw.indexOf('@ws WS-TOKENS end: popover fill'); + return [from, to] as const; + })(); + + it.each(['border-control', 'control-track-off', 'background-fill', 'background-fill-strong'])( + '--%s is declared as a hex once for light and once for dark, outside the family blocks', + (token) => { + const declarations = [ + ...css.matchAll(new RegExp(`(^|[\\s;{])--${token}:\\s*([^;]+);`, 'g')), + ].filter((d) => (d.index ?? 0) < popoverFill[0] || (d.index ?? 0) > popoverFill[1]); + expect(declarations.map((d) => d[2].trim())).toHaveLength(2); + for (const declaration of declarations) { + expect(declaration[2].trim()).toMatch(/^#[0-9a-f]{6}$/i); + const at = declaration.index ?? 0; + expect(at < generatedStart || at > generatedEnd, `--${token} sits inside a family`).toBe( + true + ); + } + } + ); + + /** + * A dark popover floats on `--background-medium` (#2c2c29), which is also the dark strong fill, so + * a hovered field there would vanish into the popover. The rule re-points the strong step one + * step DEEPER (#1b1b19): the rest fill is not lifted, because the placeholder ink falls under + * 4.5:1 on any fill a visible step above that ground. The off Switch's track sinks to the same + * well, because the page's track measured 1.05:1 on a highlighted popover row (WS-PICKERS-2). + * Nothing else moves (main.css "popover fill"; `check-contrast.mjs` measures the rows in all + * three families). + */ + it('re-points only the strong fill and the off track inside a dark popover, to the page ground, after the elevation rule', () => { + const [from, to] = popoverFill; + expect(from, 'main.css has the popover fill block').toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + const block = css.slice(from, to); + const rule = block.match(/\.dark \.biorouter-popover-surface \{([^}]*)\}/); + expect(rule, 'the block holds the .dark .biorouter-popover-surface rule').toBeTruthy(); + const declared = [...rule![1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((d) => [ + d[1], + d[2].trim(), + ]); + expect(declared).toEqual([ + ['--background-fill-strong', 'var(--color-neutral-900)'], + ['--control-track-off', 'var(--color-neutral-900)'], + ]); + // Only custom properties: the surface's own paint is WS-PRIMITIVES' rule, which comes first. + expect(rule![1].replace(/--[\w-]+\s*:[^;]+;/g, '').trim()).toBe(''); + const elevation = raw.indexOf('@ws WS-PRIMITIVES end: popover elevation'); + expect(elevation).toBeGreaterThan(-1); + expect(from).toBeGreaterThan(elevation); + }); + + it('keeps the markers it measures against', () => { + expect(generatedStart).toBeGreaterThan(0); + expect(generatedEnd).toBeGreaterThan(generatedStart); + }); +}); + +/** + * Nav icons wear their row's resting ink in every family (2026-10 redesign, + * principle 7; vision findings V1-M3 and V1-R2-5). `--sidebar-icon` is a + * per-family token so a family COULD tint its icons, and Alma Mater and Roche + * Limit did: teal and orange on Home, New chat, Crew and every Components row, + * which put the accent outside the zest list. The accent belongs to the active + * rail and the lock badge. The resting ink is the MUTED one: a nav row's label + * and a chat row's glyph both wear `--text-muted` at rest, so an icon in + * `--sidebar-foreground` (the default ink, which V1-M3 first chose) read heavier + * than its own label and gave the 16px icon column two inks. Crew's sidebar (the + * reference design) reads the same token beside its own muted rows. + */ +describe.each(['light', 'dark'] as const)('%s: nav icons wear the resting label ink', (mode) => { + it.each(Object.keys(FAMILIES) as (keyof typeof FAMILIES)[])('%s', (id) => { + const tokens = FAMILIES[id][mode].tokens as Record; + expect(tokens['sidebar-icon']).toBe(tokens['text-muted']); + expect(tokens['sidebar-icon']).not.toBe(tokens['sidebar-foreground']); + expect(tokens['sidebar-icon']).not.toBe(tokens['text-accent']); + }); +}); diff --git a/ui/desktop/src/styles/themeSwitch.browser.test.ts b/ui/desktop/src/styles/themeSwitch.browser.test.ts new file mode 100644 index 000000000..cb6cf7e86 --- /dev/null +++ b/ui/desktop/src/styles/themeSwitch.browser.test.ts @@ -0,0 +1,247 @@ +// @vitest-environment node +/** + * A theme change lands in one frame, measured in a real engine (vision finding + * V4-15, checklist 12). + * + * jsdom runs no transitions, so `motion.test.ts` can only pin the rule's text + * and the helper's frame count. Here the real rule from `main.css` and the real + * `suspendTransitionsForThemeChange()` from `motion.ts` (bundled with esbuild) + * run in Chromium against a control that eases its colours the way the app's + * buttons and rows do: + * + * - without the helper, flipping `.dark` starts colour transitions, so the + * control shows the OLD theme's colours on the NEW ground for ~125ms (the + * dark button on a white page the reviewer measured at 48ms); + * - with it, nothing transitions, on the element or its pseudo-elements, and + * the first styled frame already has the final colours; + * - once the class is gone (two frames later) the transitions are back for + * the next ordinary state change, and restoring them started none. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transform } from 'esbuild'; +import { chromium, type Browser, type BrowserServer, type Page } from 'playwright'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAIN_CSS = readFileSync(resolve(here, 'main.css'), 'utf8'); +const MOTION_TS = readFileSync(resolve(here, 'motion.ts'), 'utf8'); + +/** The theme-switch rule, from its first selector line to its closing brace. */ +function themeSwitchRule(): string { + const start = MAIN_CSS.indexOf(':root.biorouter-theme-switching,'); + if (start < 0) throw new Error('main.css has no theme-switch rule'); + return MAIN_CSS.slice(start, MAIN_CSS.indexOf('}', start) + 1); +} + +const PAGE_CSS = ` +:root { --ground: #ffffff; --ink: #222222; --fill: #e6e6e6; } +:root.dark { --ground: #131312; --ink: #eeeeee; --fill: #2c2c29; } +:root[data-theme='other'] { --fill: #c0c0c0; } +:root.dark[data-theme='other'] { --fill: #404040; } +body { margin: 0; background: var(--ground); } +.control { + color: var(--ink); + background-color: var(--fill); + border: 1px solid var(--ink); + transition: color 125ms ease-out, background-color 125ms ease-out, border-color 95ms ease-out; +} +.control::before { + content: ''; + background-color: var(--ink); + transition: background-color 125ms ease-out; +} +`; + +/** + * Every teardown step gets this long and no longer, so the whole afterAll + * (disconnect, close, kill and reap: three bounded steps, 15s at worst) ends + * well inside the runner's 30s hook limit, which stays a backstop. + */ +const TEARDOWN_STEP_MS = 5_000; + +/** Resolve `true` if `work` settled within `ms`, `false` if it ran out of time. */ +const settledWithin = async (work: Promise, ms: number): Promise => { + let timer: ReturnType | undefined; + const expired = new Promise((done) => { + timer = setTimeout(() => done(false), ms); + // The watchdog itself must never hold the loop open. + (timer as unknown as { unref?: () => void }).unref?.(); + }); + try { + // A rejected close has still settled: what matters here is elapsed time, + // and a browser that refused to close is handled by the caller's next step. + return ( + (await Promise.race([ + work.then( + () => true, + () => true + ), + expired, + ])) !== false + ); + } finally { + if (timer) clearTimeout(timer); + } +}; + +/** + * Launch whichever Chromium build this machine has. + * + * `launchServer` + `connect`, not `launch`, because of teardown. A `Browser` + * from `chromium.launch()` has no handle on its process, so `close()` is the + * only way to end it, and on a saturated machine (the full suite starts a + * Chromium per browser file, load average 115 to 240) that call blocked past + * the 30s hook limit after all four tests had passed: the file failed with + * zero failing tests. A `BrowserServer` hands back `process()`, so the last + * resort is a SIGKILL rather than an unbounded await. Same recipe as + * `utils/artifactCdnAssets.browser.test.ts`. + */ +const launchChromium = async (): Promise<{ server: BrowserServer; browser: Browser } | null> => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + let server: BrowserServer | null = null; + try { + server = await chromium.launchServer(options); + return { server, browser: await chromium.connect(server.wsEndpoint()) }; + } catch { + // Launched but not connected: end it here rather than leave a Chromium + // running beside the next attempt. + if (server) await settledWithin(server.close(), TEARDOWN_STEP_MS); + // try the next build + } + } + return null; +}; + +type Flip = { useHelper: boolean; mode?: 'dark' | 'light'; family?: string }; + +describe('a theme change lands in one frame', () => { + let browser: Browser | null = null; + let server: BrowserServer | null = null; + let page: Page; + let motionBundle = ''; + + beforeAll(async () => { + const launched = await launchChromium(); + browser = launched?.browser ?? null; + server = launched?.server ?? null; + if (!browser) { + console.warn( + 'No Chromium found — skipping the theme switch tests. ' + + 'Run `npx playwright install chromium` to enable them.' + ); + return; + } + motionBundle = ( + await transform(MOTION_TS, { loader: 'ts', format: 'iife', globalName: 'Motion' }) + ).code; + page = await browser.newPage({ viewport: { width: 400, height: 200 } }); + }); + + afterAll(async () => { + const connection = browser; + const launched = server; + browser = null; + server = null; + + if (connection) await settledWithin(connection.close(), TEARDOWN_STEP_MS); + if (!launched) return; + + if (await settledWithin(launched.close(), TEARDOWN_STEP_MS)) return; + + // Still up. Stop asking. + launched.process().kill('SIGKILL'); + await settledWithin(launched.close(), TEARDOWN_STEP_MS); + }); + + beforeEach(async () => { + if (!browser) return; + await page.setContent( + `` + + `` + + `` + ); + await page.addScriptTag({ content: motionBundle }); + // Let the first frame paint so the control's resting colours are settled. + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => done(null)))); + }); + + /** Flip the theme as ThemeContext does, then read the first styled state. */ + const flip = ({ useHelper, mode, family }: Flip) => + page.evaluate( + ({ useHelper: withHelper, mode: nextMode, family: nextFamily }) => { + const root = document.documentElement; + const motion = (window as unknown as { Motion: typeof import('./motion') }).Motion; + if (withHelper) motion.suspendTransitionsForThemeChange(root); + if (nextMode) { + root.classList.add(nextMode); + root.classList.remove(nextMode === 'dark' ? 'light' : 'dark'); + } + if (nextFamily) root.setAttribute('data-theme', nextFamily); + const control = document.getElementById('c')!; + const style = getComputedStyle(control); + const before = getComputedStyle(control, '::before'); + return { + color: style.color, + fill: style.backgroundColor, + edge: style.borderTopColor, + beforeFill: before.backgroundColor, + running: document.getAnimations().length, + switching: root.classList.contains(motion.THEME_SWITCHING_CLASS), + }; + }, + { useHelper, mode, family } + ); + + const twoFrames = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))) + ) + ); + + it('control: without the helper the colours walk from the old theme', async () => { + if (!browser) return; + const first = await flip({ useHelper: false, mode: 'dark' }); + expect(first.running).toBeGreaterThan(0); + expect(first.color).toBe('rgb(34, 34, 34)'); // still the light ink on the dark ground + }); + + it('with the helper a Dark flip has its final colours on the first styled frame', async () => { + if (!browser) return; + const first = await flip({ useHelper: true, mode: 'dark' }); + expect(first.switching).toBe(true); + expect(first.running).toBe(0); + expect(first).toMatchObject({ + color: 'rgb(238, 238, 238)', + fill: 'rgb(44, 44, 41)', + edge: 'rgb(238, 238, 238)', + beforeFill: 'rgb(238, 238, 238)', + }); + }); + + it('a family change is still too', async () => { + if (!browser) return; + const first = await flip({ useHelper: true, family: 'other' }); + expect(first.running).toBe(0); + expect(first.fill).toBe('rgb(192, 192, 192)'); + }); + + it('the class comes off two frames later without starting a transition', async () => { + if (!browser) return; + await flip({ useHelper: true, mode: 'dark' }); + await twoFrames(); + const after = await page.evaluate(() => ({ + switching: document.documentElement.classList.contains('biorouter-theme-switching'), + running: document.getAnimations().length, + color: getComputedStyle(document.getElementById('c')!).color, + })); + expect(after).toEqual({ switching: false, running: 0, color: 'rgb(238, 238, 238)' }); + + // The transitions are back for an ordinary change made afterwards. + const later = await flip({ useHelper: false, mode: 'light' }); + expect(later.running).toBeGreaterThan(0); + }); +}); diff --git a/ui/desktop/src/styles/themes.generated.ts b/ui/desktop/src/styles/themes.generated.ts index 2e624eef1..c090ce892 100644 --- a/ui/desktop/src/styles/themes.generated.ts +++ b/ui/desktop/src/styles/themes.generated.ts @@ -48,10 +48,10 @@ export type TerminalPalette = { export type ThemeModeData = { /** - * The brand mark's two inks for this family and mode. The boot splash paints - * these before React exists, and BioRouterMark paints them after — reading - * both from here is what stops the two disagreeing. Roche Limit used to flash - * an orange mark that React then re-rendered in Parchment's coral. + * The BR monogram's two inks for this family and mode, read by BioRouterMark. + * The boot splash no longer paints the monogram: it draws the wordmark, which + * is fixed to the brand colours, so every family's splash takes the BASE + * family's inks and this family's own mark never reaches it. */ mark: { navy: string; coral: string }; syntax: SyntaxPalette; diff --git a/ui/desktop/src/styles/toastLayer.test.ts b/ui/desktop/src/styles/toastLayer.test.ts index 42c86f182..d5b70ea61 100644 --- a/ui/desktop/src/styles/toastLayer.test.ts +++ b/ui/desktop/src/styles/toastLayer.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { TOAST_SURFACE_CLASS_NAME } from '../components/alerts/NotificationSurface'; +import { SELECT_MENU_SLOT } from '../components/ui/Select'; /** * Where notifications appear, and in what order they pile up. @@ -151,3 +152,177 @@ describe("a screen's toasts go when the person leaves it", () => { expect(APP).toMatch(/dismissScreenToasts\(\);\s*\}, \[screen\]\)/); }); }); + +/** + * Spec 3.16: the toast's motion. react-toastify's default Bounce flew in from + * 3000px with overshoots over 500ms and then collapsed over 300ms; the app + * passes its own `cssTransition` instead, with the two keyframes authored beside + * the Toastify block. + */ +describe('a toast slides 8px with no bounce and collapses over 125ms', () => { + /** The body of `@keyframes name`. */ + function keyframes(name: string): string { + const match = CSS.match(new RegExp(`@keyframes ${name}\\s*\\{([\\s\\S]*?)\\n\\}`)); + if (!match) throw new Error(`no @keyframes ${name} in main.css`); + return match[1]; + } + + it('hands the container the custom transition, never the default Bounce', () => { + expect(APP).toMatch(/transition=\{TOAST_TRANSITION\}/); + expect(APP).toMatch( + /cssTransition\(\{\s*enter: 'br-toast-in',\s*exit: 'br-toast-out',\s*collapseDuration: DUR\.fast,?\s*\}\)/ + ); + expect(APP).not.toMatch(/import \{[^}]*\bBounce\b[^}]*\} from 'react-toastify'/); + expect(APP).not.toMatch(/transition=\{Bounce\}/); + }); + + it('enters with an 8px drop and leaves with an 8px slide, on the motion tokens', () => { + expect(CSS).toMatch( + /\.br-toast-in\s*\{\s*animation:\s*br-toast-in var\(--dur-fast-max\) var\(--ease-out\)/ + ); + expect(CSS).toMatch( + /\.br-toast-out\s*\{\s*animation:\s*br-toast-out var\(--dur-fast\) var\(--ease-out\)/ + ); + expect(keyframes('br-toast-in')).toMatch(/translateY\(-8px\)/); + expect(keyframes('br-toast-out')).toMatch(/translateX\(8px\)/); + }); + + /** + * ⚠ The floor, again, for the frames in between. The entrance starts its + * travel ABOVE the resting inset, so the inset minus that travel must still + * clear the drag band, or the toast's × and "View details" are dead while it + * animates (issue #74). + */ + it('never enters the titlebar drag band while it animates', () => { + const travel = Number(keyframes('br-toast-in').match(/translateY\(-(\d+)px\)/)?.[1]); + expect(travel).toBeGreaterThan(0); + expect(resolvePx('toast-inset-top') - travel).toBeGreaterThanOrEqual( + resolvePx('titlebar-drag-height') + ); + }); + + it('keeps the exit an animation under reduced motion, or no toast would ever leave', () => { + const reduced = CSS.match( + /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.br-toast-in\s*\{[^}]*\}\s*\.br-toast-out\s*\{([^}]*)\}/ + ); + expect(reduced, 'no reduced-motion rest for the toast').not.toBeNull(); + expect(reduced![1]).toMatch(/animation-name:\s*br-toast-fade-out/); + expect(reduced![1]).not.toMatch(/animation:\s*none/); + }); +}); + +/** + * Vision V1-R7-1. A sticky toast (the extension-load report) sat over the menus that open into + * the top-right corner: menus are at `--z-modal-dropdown` and the toast layer at `--z-toast`, so + * the tab strip's ▾ filter field and a tab's "Open in new window" could not be pointed at. While + * a menu or popover is open the layer drops one step below the menus. Chromium's hit test is + * `chatGroups/toastOverMenu.browser.test.tsx`; this holds the two ends at the source: the rule + * names the primitives' own `data-slot`s, and each primitive still emits its one. + */ +describe('a menu the person opened stands above a toast', () => { + const SLOTS: Record = { + '../components/ui/dropdown-menu.tsx': ['dropdown-menu-content', 'dropdown-menu-sub-content'], + '../components/ui/context-menu.tsx': ['context-menu-content'], + '../components/ui/popover.tsx': ['popover-content'], + // react-select's menu, named by the shared wrapper (WS-PRIMITIVES-2). The name is set in an + // object (`'data-slot': SELECT_MENU_SLOT`), not as JSX, so its row is matched by the constant. + '../components/ui/Select.tsx': ['select-content'], + }; + + function yieldRule(): { selector: string; body: string } { + const match = CSS.match(/\n(body:has\([\s\S]*?\)\s*\.Toastify__toast-container)\s*\{([^}]*)\}/); + if (!match) throw new Error('no toast yield rule in main.css'); + return { selector: match[1], body: match[2] }; + } + + it('drops the toast layer below the menus while one is open', () => { + const { selector, body } = yieldRule(); + for (const slot of Object.values(SLOTS).flat()) { + expect(selector).toContain(`[data-slot='${slot}']`); + } + expect(selector).toMatch(/\)\[data-state='open'\]\s*\)/); + expect(body).toMatch(/z-index:\s*calc\(var\(--z-modal-dropdown\) - 1\)/); + }); + + it('keeps the yielded toast above every dialog, and the resting toast above the menus', () => { + const z = (name: string) => Number(declaration(name)); + expect(z('z-modal-dropdown') - 1).toBeGreaterThan(z('z-modal')); + expect(z('z-toast')).toBeGreaterThan(z('z-modal-dropdown')); + }); + + it('names data-slots the menu primitives really emit', () => { + for (const [file, slots] of Object.entries(SLOTS)) { + const source = readFileSync(join(__dirname, file), 'utf8'); + for (const slot of slots) { + if (file.endsWith('Select.tsx')) { + expect(SELECT_MENU_SLOT).toBe(slot); + expect(source, file).toMatch(/'data-slot': SELECT_MENU_SLOT, 'data-state': 'open'/); + } else { + expect(source, file).toContain(`data-slot="${slot}"`); + } + } + } + }); + + /** + * WS-PRIMITIVES-2. A Select whose menu stays inside a dialog (no portal) is stacked inside the + * dialog box's own context, which competes at `--z-modal` in the page, so the yielded toast + * (one under the menus) still covered it. While one is open the toast steps behind the dialog. + */ + it('steps behind a dialog while a Select inside it is open', () => { + const match = CSS.match( + /\nbody:has\(\.biorouter-modal-surface \[data-slot='select-content'\]\[data-state='open'\]\)\s*\.Toastify__toast-container\s*\{([^}]*)\}/ + ); + expect(match, 'no rule for a Select open inside a dialog').not.toBeNull(); + expect(match![1]).toMatch(/z-index:\s*calc\(var\(--z-modal\) - 1\)/); + const z = (name: string) => Number(declaration(name)); + expect(z('z-modal') - 1).toBeGreaterThan(0); + // The dialog box really is the surface the rule names. + const dialog = readFileSync(join(__dirname, '../components/ui/dialog.tsx'), 'utf8'); + expect(dialog).toMatch(/'biorouter-modal-surface [^']*fixed[^']*z-\[var\(--z-modal\)\]/); + }); +}); + +/** + * Vision V2-R7-4, checklist 15. A toast covered find in chat's count and buttons, and the + * docked Chat summary's first rows, because all three dock in the same corner. The stack now + * steps below find and left of the summary (`components/alerts/toastClearance.ts`, whose own + * test holds the geometry; Chromium's is `alerts/toastClearance.browser.test.tsx`). This holds + * the two ends that live elsewhere: the stylesheet turns the measured move into a `translate` + * that leaves `top` alone, and App starts the watcher. + */ +describe('a toast steps out of the way of find in chat and the Chat summary', () => { + function rule(selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = CSS.match(new RegExp(`\\n${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`no rule for ${selector} in main.css`); + return match[1]; + } + + it('moves the container by a translate, only ever left and down, never by its top', () => { + expect(rule('.Toastify__toast-container')).toMatch( + /translate:\s*calc\(-1 \* var\(--toast-clear-x, 0px\)\) var\(--toast-clear-y, 0px\);/ + ); + // The resting corner is still the token, so the drag-band floor above still holds. + expect(APP).toContain("top: 'var(--toast-inset-top)'"); + }); + + it('glides only once placed, snaps during a resize, and not at all under reduced motion', () => { + expect(rule('.Toastify__toast-container[data-toast-placed]')).toMatch( + /transition:\s*translate var\(--dur-fast-max\) var\(--ease-out\);/ + ); + expect(CSS).toMatch( + /body\.biorouter-window-resizing \.Toastify__toast-container,\s*body\.biorouter-sidebar-resizing \.Toastify__toast-container\s*\{\s*transition-duration:\s*0ms;/ + ); + expect(CSS).toMatch( + /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.Toastify__toast-container\[data-toast-placed\]\s*\{\s*transition:\s*none;/ + ); + }); + + it('starts the watcher once, beside the container', () => { + expect(APP).toMatch( + /import \{ watchToastClearance \} from '\.\/components\/alerts\/toastClearance'/ + ); + expect(APP).toMatch(/useEffect\(\(\) => watchToastClearance\(\), \[\]\);/); + }); +}); diff --git a/ui/desktop/src/styles/transcriptMarkdown.test.ts b/ui/desktop/src/styles/transcriptMarkdown.test.ts new file mode 100644 index 000000000..f54bb2eb4 --- /dev/null +++ b/ui/desktop/src/styles/transcriptMarkdown.test.ts @@ -0,0 +1,280 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * The transcript's markdown stylesheet (`main.css`, the WS-TRANSCRIPT + * "markdown" and "md recipes" blocks). jsdom applies no stylesheet and runs no + * Tailwind, so a component test cannot see a size, a margin or a fill; these + * assertions read the source the browser will. + * + * What they hold in place (implementation spec 2.1 and 3.6, section 0): + * - five sizes: chat prose 14/21, chat h1 and h2 17/24, h3 to h6 14/21, a + * document's h1 the 24/32 title at 400; + * - no accent bar beside a heading, no tinted quote; + * - Crew's code block and table as shared `br-md-*` recipes, on the well and + * with 8px 12px cells; + * - fills before lines (owner message 13, R-27): the block and the table are a + * solid fill with a transparent 1px border, and only the table's rules + * between rows are drawn. + */ + +// vitest runs with `ui/desktop` as the root. +const css = readFileSync('src/styles/main.css', 'utf-8'); + +/** The body of the first rule whose selector list is exactly `selector`. */ +function ruleBody(selector: string): string | null { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); + const match = new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(css); + return match ? match[1] : null; +} + +/** A `:root` token's value, e.g. `--text-prose` is `14px`. */ +function token(name: string): string { + const match = new RegExp(`\\n\\s*${name}:\\s*([^;]+);`).exec(css); + expect(match, `token ${name}`).not.toBeNull(); + return match![1].trim(); +} + +function block(name: string): string { + const begin = css.indexOf(`/* @ws WS-TRANSCRIPT begin: ${name} */`); + const end = css.indexOf(`/* @ws WS-TRANSCRIPT end: ${name} */`); + expect(begin, `begin marker for ${name}`).toBeGreaterThan(-1); + expect(end, `end marker for ${name}`).toBeGreaterThan(begin); + return css.slice(begin, end); +} + +describe('markdown type', () => { + it('sets chat prose on the 14/21 reading line, from the prose role', () => { + const body = ruleBody('.biorouter-markdown.prose'); + expect(body).toContain('font-size: var(--text-prose);'); + expect(body).toContain('line-height: var(--text-prose--line-height);'); + expect(token('--text-prose')).toBe('14px'); + expect(token('--text-prose--line-height')).toBe('21px'); + }); + + it('uses 17/24 for chat h1 and h2 and 14/21 below them, all at 600', () => { + expect(ruleBody('.biorouter-markdown.prose :where(h1, h2, h3, h4, h5, h6)')).toContain( + 'font-weight: 600;' + ); + const top = ruleBody('.biorouter-markdown.prose :where(h1, h2)'); + expect(top).toContain('font-size: var(--text-subheading);'); + expect(top).toContain('line-height: var(--text-subheading--line-height);'); + expect(token('--text-subheading')).toBe('17px'); + expect(token('--text-subheading--line-height')).toBe('24px'); + const rest = ruleBody('.biorouter-markdown.prose :where(h3, h4, h5, h6)'); + expect(rest).toContain('font-size: var(--text-prose);'); + expect(rest).toContain('line-height: var(--text-prose--line-height);'); + }); + + it("makes a document's h1 the 24/32 title at regular weight", () => { + const h1 = ruleBody(".biorouter-markdown.prose[data-variant='document'] h1"); + expect(h1).toContain('font-size: var(--text-title);'); + expect(h1).toContain('line-height: var(--text-title--line-height);'); + expect(h1).toContain('font-weight: var(--text-title--font-weight);'); + expect(token('--text-title')).toBe('24px'); + expect(token('--text-title--line-height')).toBe('32px'); + expect(token('--text-title--font-weight')).toBe('400'); + }); + + it('takes every size from a role token, never a literal', () => { + // A literal here is a sixth size waiting to happen; the tokens are the + // scale (spec 2.1), so changing it is one edit in `:root`. + const sizes = [...block('markdown').matchAll(/font-size:\s*([^;]+);/g)].map((m) => m[1].trim()); + expect(sizes.length).toBeGreaterThan(4); + for (const size of sizes) { + expect(size).toMatch(/^var\(--text-(?:prose|subheading|title|code)\)$/); + } + }); + + it('carries no accent bar on headings and no tinted quote', () => { + const markdown = block('markdown'); + expect(markdown).not.toMatch(/::before/); + expect(markdown).not.toContain('--accent-bar'); + expect(markdown).not.toMatch(/letter-spacing:\s*-0\.015em/); + const quote = ruleBody('.biorouter-markdown.prose blockquote'); + expect(quote).toContain('border-left: 2px solid var(--border-strong);'); + expect(quote).toContain('color: var(--text-muted);'); + expect(quote).not.toContain('background'); + }); + + it('keeps a table cell a plain cell: no bold first column', () => { + expect(css).not.toMatch(/td:first-child\s*\{[^}]*font-weight:\s*600/); + }); + + // Vision V2-06: a task item drops its bullet and hangs the rail's 14px glyph + // in the gutter (14px glyph + 6px gap = 20px), so its text keeps the edge of + // the lists around it; the glyph is muted ink, never the accent. + it('hangs a task glyph in the bullet gutter, muted, at 14px', () => { + const markdown = block('markdown'); + expect(markdown).toContain('.biorouter-markdown.prose li.task-list-item'); + expect(ruleBody('.biorouter-markdown.prose li.task-list-item')).toContain('list-style: none;'); + const glyph = ruleBody('.biorouter-markdown .br-md-task'); + expect(glyph).toContain('width: 14px;'); + expect(glyph).toContain('margin-right: 6px;'); + expect(glyph).toContain('margin-left: -20px;'); + expect(glyph).toContain('color: var(--text-muted);'); + expect(glyph).toContain('height: var(--text-prose--line-height);'); + expect(ruleBody('.biorouter-markdown .br-md-task > svg')).toContain('width: 14px;'); + }); +}); + +describe("Crew's code block and table, promoted", () => { + it('puts fenced code on the well as a fill, with no frame and no rule under its head', () => { + const code = ruleBody('.br-md-code'); + expect(code).toContain('background-color: var(--background-well);'); + // Transparent, never removed: nothing moves, and forced colours draw it. + expect(code).toContain('border: 1px solid transparent;'); + expect(code).toContain('border-radius: var(--radius-container);'); + const head = ruleBody('.br-md-code-head'); + expect(head).toContain('border-bottom: 1px solid transparent;'); + expect(head).not.toContain('background'); + const body = ruleBody('.br-md-code-body'); + expect(body).toContain('padding: 10px 12px;'); + expect(body).toContain('font-size: var(--text-code);'); + }); + + it('fades a box that scrolls at its right edge, and not while focused', () => { + // One fade for both boxes; a table no longer closes its cut with a hairline. + expect(block('md recipes').replace(/\/\*[\s\S]*?\*\//g, '')).not.toMatch(/border-right/); + expect(ruleBody('.br-md-table-scroll')).toContain('border-radius: var(--radius-element);'); + expect( + ruleBody(".br-md-code-body[data-overflow='true'],\n.br-md-table-scroll[data-overflow='true']") + ).toContain('mask-image: linear-gradient(to right, black calc(100% - 40px), transparent);'); + expect( + ruleBody( + ".br-md-code-body[data-overflow='true']:focus-visible,\n.br-md-table-scroll[data-overflow='true']:focus-visible" + ) + ).toContain('mask-image: none;'); + }); + + it('fills a table at radius 8 with top rules only and 8px 12px cells', () => { + const table = ruleBody('.br-md-table'); + expect(table).toContain('border-collapse: separate;'); + expect(table).toContain('border: 1px solid transparent;'); + expect(table).toContain('background-color: var(--background-fill);'); + expect(table).toContain('border-radius: var(--radius-element);'); + expect(table).toContain('font-size: var(--text-secondary);'); + expect(table).toContain('font-variant-numeric: tabular-nums;'); + const cell = ruleBody('.br-md-table :where(th, td)'); + expect(cell).toContain('padding: 8px 12px;'); + expect(cell).toContain('border-block-start: 1px solid var(--border-subtle);'); + // The header is the table's own fill, never a band on it (a fill on a + // fill), and paints it so a sticky header stays opaque. + const head = ruleBody('.br-md-table thead :where(th, td),\n.br-md-table th[data-head]'); + expect(head).toContain('background-color: var(--background-fill);'); + expect(head).toContain('color: var(--text-muted);'); + expect(head).toContain('font-size: var(--text-supporting);'); + expect(head).toContain('font-weight: 600;'); + }); + + it('drops the right-edge fade under forced colours, as Crew does', () => { + // A mask is alpha: where the system repaints the rest, the fade only hides ink. + const recipes = block('md recipes'); + const forced = /@media \(forced-colors: active\) \{([\s\S]*?)\n\}/.exec(recipes); + expect(forced, 'a forced-colors block in the md recipes').not.toBeNull(); + expect(forced![1]).toMatch( + /\.br-md-code-body\[data-overflow='true'\],\s*\.br-md-table-scroll\[data-overflow='true'\]\s*\{\s*mask-image: none;/ + ); + }); + + it('names no crew-* class outside Crew', () => { + // Comments may cite Crew's source; selectors may not borrow it (principle 11). + const rules = block('md recipes').replace(/\/\*[\s\S]*?\*\//g, ''); + expect(rules).not.toMatch(/\.crew-/); + expect(rules).toContain('.br-md-table'); + }); +}); + +describe('the transcript rhythm', () => { + it('reserves no padding under an assistant reply before a user turn', () => { + // The `.assistant:has(+ .user)` padding was a third of the 84px gap + // between turns; the list's `mt-6` owns that gap now (spec 3.6). + expect(css).not.toContain('.assistant:has(+ .user)'); + }); +}); + +/** + * The promoted recipes stay Crew's (spec section 0, decision 6.15, critic item + * 39): one code block and one table look in the app. Read from both sheets and + * compared declaration by declaration, so a change to Crew's recipe that is not + * carried over (or the reverse) fails here. The deliberate differences are + * named rather than skipped: the table cell's 8px 12px against Crew's off-token + * 11px 16px (Crew defect D11), the role line height, and fills before lines + * (owner message 13, R-27), which converts these recipes and not Crew's: the + * frames and the code head's rule are transparent, the table carries the fill, + * and the table's scroll box rounds its focus edge instead of closing its cut + * with a hairline. + */ +describe('parity with Crew’s code block and table', () => { + const crew = readFileSync('src/components/crew/timeline/timeline.css', 'utf-8'); + + /** Declarations of the first rule whose selector list is exactly `selector`. */ + function declarations(sheet: string, selector: string): string[] { + const want = selector.replace(/\s+/g, ' '); + const rules = sheet.replace(/\/\*[\s\S]*?\*\//g, '').split('}'); + const rule = rules + .map((chunk) => chunk.split('{')) + .find(([head]) => head !== undefined && head.trim().replace(/\s+/g, ' ') === want); + expect(rule, `rule ${selector}`).toBeDefined(); + return rule![1] + .split(';') + .map((line) => line.trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .sort(); + } + + /** What ours declares that Crew's does not, and the reverse. */ + function difference(ours: string, theirs: string) { + const a = declarations(css, ours); + const b = declarations(crew, theirs); + return { added: a.filter((d) => !b.includes(d)), dropped: b.filter((d) => !a.includes(d)) }; + } + + const same: Array<[string, string]> = [ + ['.br-md-code-lang', '.crew-md-code-lang'], + ['.br-md-code-body', '.crew-md-code-body'], + ]; + + it.each(same)('%s declares exactly what %s does', (ours, theirs) => { + expect(declarations(css, ours)).toEqual(declarations(crew, theirs)); + }); + + it('frames the code block as Crew does, but with a transparent edge (R-27)', () => { + expect(difference('.br-md-code', '.crew-md-code')).toEqual({ + added: ['border: 1px solid transparent'], + dropped: ['border: 1px solid var(--border-subtle)'], + }); + expect(difference('.br-md-code-head', '.crew-md-code-head')).toEqual({ + added: ['border-bottom: 1px solid transparent'], + dropped: ['border-bottom: 1px solid var(--border-subtle)'], + }); + }); + + it('fills the table where Crew frames it, adding the role line height', () => { + expect(difference('.br-md-table', '.crew-md-table')).toEqual({ + added: [ + 'background-color: var(--background-fill)', + 'border: 1px solid transparent', + 'line-height: var(--text-secondary--line-height)', + ], + dropped: ['border: 1px solid var(--border-subtle)'], + }); + }); + + it('scrolls the table as Crew does, with a rounded focus edge and no closing hairline', () => { + expect(difference('.br-md-table-scroll', '.crew-md-table-scroll')).toEqual({ + added: ['border-radius: var(--radius-element)'], + dropped: [], + }); + // Crew closes an overflowing table's cut with a hairline; ours fades the + // fill instead, through the fade the code body shares. + expect(declarations(crew, ".crew-md-table-scroll[data-overflow='true']")).toContain( + 'border-right: 1px solid var(--border-subtle)' + ); + }); + + it('differs from Crew’s cell only in the on-token padding', () => { + expect(declarations(crew, '.crew-md-cell')).toContain('padding: 11px 16px'); + expect(declarations(css, '.br-md-table :where(th, td)')).toContain('padding: 8px 12px'); + }); +}); diff --git a/ui/desktop/src/styles/typeScale.test.ts b/ui/desktop/src/styles/typeScale.test.ts new file mode 100644 index 000000000..864e3b11b --- /dev/null +++ b/ui/desktop/src/styles/typeScale.test.ts @@ -0,0 +1,314 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * The type scale, asserted at the token (redesign 2026-10, spec §2.1). + * + * The owner asked for "very few" font sizes and weights. The app had thirteen + * sizes in use; the redesign settles on five (12 · 13 · 14 · 17 · 24) and two + * visible weights, because the face is Arial and Arial draws 400 and 700 only + * (500 renders as 400, 600 as 700). This file pins the ROLES: what each type + * token is allowed to be, and then holds the CALL SITES to them (no `text-sm`, no + * `text-[11px]`, no stray `tracking-*`, no bare `uppercase`, no `font-bold`). + * + * The call-site guard is STRICT: no guarded file may carry a legacy form. It began + * as a ratchet (a list of the files that still had classes to sweep, each with the + * count it carried, so the guard could land while eight owners were mid-sweep + * without failing on arrival and being switched off). The sweep finished in wave 2, + * the list was deleted, and a file that regresses now fails with its own name. + */ +const CSS = readFileSync(join(__dirname, 'main.css'), 'utf8'); +const UTILS = readFileSync(join(__dirname, '../utils.ts'), 'utf8'); +const CODE = CSS.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' ')); + +const SCALE = [12, 13, 14, 17, 24]; + +/** Every `--text-` declared in a plain `@theme` block, with its parts. */ +function roles(): Map> { + const out = new Map>(); + const re = /(^|\n)@theme\s*\{/g; + while (re.exec(CODE)) { + let depth = 1; + let i = re.lastIndex; + for (; i < CODE.length && depth > 0; i++) { + if (CODE[i] === '{') depth++; + else if (CODE[i] === '}') depth--; + } + const body = CODE.slice(re.lastIndex, i - 1); + for (const d of body.matchAll(/--text-([a-z]+)(--[a-z-]+)?:\s*([^;]+);/g)) { + const [, role, part, value] = d; + const entry = out.get(role) ?? {}; + entry[part ? part.slice(2) : 'size'] = value.trim(); + out.set(role, entry); + } + } + return out; +} + +const ROLES = roles(); +const px = (v: string) => Number(/^(\d+)px$/.exec(v)?.[1]); + +/** Follow `var(--text-x…)` to the role it aliases. */ +function resolve(role: string, part: string): string { + let value = ROLES.get(role)?.[part] ?? ''; + for (let i = 0; i < 4 && value.startsWith('var('); i++) { + const m = /^var\(--text-([a-z]+)(?:--([a-z-]+))?\)$/.exec(value); + if (!m) break; + value = ROLES.get(m[1])?.[m[2] ?? 'size'] ?? ''; + } + return value; +} + +describe('the type roles', () => { + it('declares the roles the redesign names', () => { + expect([...ROLES.keys()].sort()).toEqual( + [ + 'body', + 'caps', + 'chip', + 'code', + 'label', + 'prose', + 'secondary', + 'subheading', + 'supporting', + 'title', + ].sort() + ); + }); + + it.each([...ROLES.keys()])('%s renders at one of the five sizes', (role) => { + expect(SCALE).toContain(px(resolve(role, 'size'))); + }); + + it.each([...ROLES.keys()])('%s uses a weight of 400, 500 or 600', (role) => { + expect(['400', '500', '600']).toContain(resolve(role, 'font-weight')); + }); + + it.each([ + ['supporting', '12px', '16px', '400'], + ['chip', '12px', '16px', '500'], + ['caps', '12px', '16px', '500'], + ['secondary', '13px', '18px', '400'], + ['code', '13px', '20px', '400'], + ['body', '14px', '20px', '400'], + ['label', '14px', '20px', '500'], + ['prose', '14px', '21px', '400'], + ['subheading', '17px', '24px', '600'], + ['title', '24px', '32px', '400'], + ])('%s is %s / %s at %s', (role, size, lineHeight, weight) => { + expect(resolve(role, 'size')).toBe(size); + expect(resolve(role, 'line-height')).toBe(lineHeight); + expect(resolve(role, 'font-weight')).toBe(weight); + }); + + /** One caps style, 12px so it is not a sixth size, tracked +0.04em. */ + it('keeps tracking only on the caps label and the title', () => { + const tracked = [...ROLES.entries()] + .filter(([, parts]) => parts['letter-spacing']) + .map(([role]) => role) + .sort(); + expect(tracked).toEqual(['caps', 'title']); + expect(resolve('caps', 'letter-spacing')).toBe('0.04em'); + expect(resolve('title', 'letter-spacing')).toBe('-0.01em'); + }); + + /** The two retired roles stay gone: `heading` (20/28) and `display` (30/36). */ + it('declares neither text-heading nor text-display', () => { + expect(ROLES.has('heading')).toBe(false); + expect(ROLES.has('display')).toBe(false); + }); + + /** `text-caps` carries its casing; the generated utility cannot. */ + it('folds the uppercase transform into text-caps', () => { + expect(CODE).toMatch(/\.text-caps\s*\{\s*text-transform:\s*uppercase;\s*\}/); + }); +}); + +/** + * The document default (wave 2). Anything that names no size inherits `body`, so + * `body` itself must be a step on the scale: it was 16px, a size the redesign + * retired, and every unsized dialog paragraph, raw input and menu text rendered + * at it. Only the size is pinned; the line height stays inherited (main.css says + * why). + */ +describe('the document default', () => { + it('sets body to text-body, not to a rem or px literal', () => { + const rules = [...CODE.matchAll(/(^|[\s}])body\s*\{([^}]*)\}/g)].map((m) => m[2]); + const sizes = rules.flatMap((rule) => + [...rule.matchAll(/font-size:\s*([^;]+);/g)].map((m) => m[1].trim()) + ); + expect(sizes).toEqual(['var(--text-body)']); + expect(rules.join('\n')).not.toMatch(/line-height/); + }); +}); + +/** + * tailwind-merge has to be taught every role, or `cn()` reads `text-prose` as a + * text COLOUR and drops the real colour beside it (typography audit R7). The + * list lives in `src/utils.ts`; this keeps it in step with the stylesheet. + */ +describe('tailwind-merge knows every role', () => { + const list = /text:\s*\[([^\]]*)\]/.exec(UTILS)?.[1] ?? ''; + const registered = [...list.matchAll(/'([a-z]+)'/g)].map((m) => m[1]).sort(); + + it('registers exactly the declared roles', () => { + expect(registered).toEqual([...ROLES.keys()].sort()); + }); +}); + +/** + * ── The call sites ──────────────────────────────────────────────────────────── + * + * What is counted, per file (comments are stripped first, so a note explaining + * the rule cannot trip it): + * - `.ts` / `.tsx`: a legacy size class (`text-xs|sm|base|lg|xl|2xl…`, + * `text-[Npx]`, or the retired `text-heading` / `text-display`), a `tracking-*` class, a bare `uppercase` class (`text-caps` + * carries its own casing) and `font-bold` (Arial draws 400 and 700, so the + * strong weight is `font-semibold`, which the face renders as bold anyway). + * - `.css`: a `font-size` in px (a role is a `--text-*` token) and a + * `font-weight` of 700 or heavier. + * + * Out of scope, each for a reason: `components/crew/` (its own guard, + * `crewCss.sourceGuard.test.ts`), the frozen files no workstream may restyle + * (`components/privacy/**`, `context_management/**` except + * `SystemNotificationInline.tsx`, `LauncherView`, `Pair`, the unmounted + * `settings/{dictation,tunnel,sessions}/**`, `ModelAndProviderContext.tsx`), the + * generated `api/`, the gitignored build output under `bin/` and `web/`, and tests. + * `onboarding/**` and `ProviderGuard` were out of scope until their P2 sweep landed + * (df9186abf); they are guarded now. + */ +const SRC = join(__dirname, '..'); + +const OUT_OF_SCOPE = [ + /^components\/crew\//, + /^components\/privacy\//, + /^components\/context_management\/(?!SystemNotificationInline)/, + /^components\/LauncherView\./, + /^components\/Pair\./, + /^components\/settings\/(dictation|tunnel|sessions)\//, + /^components\/ModelAndProviderContext\./, + /^api\//, + // Build output that is gitignored but sits under src/: the staged binaries and the + // root-base browser bundle (`npm run build:web`). Compiled CSS is full of px sizes. + /^bin\//, + /^web\//, +]; + +const CLASS_RULES: Record = { + 'legacy size class': + /(? = { + 'px font-size': /font-size:\s*\d*\.?\d+px/g, + 'weight 700 or heavier': /font-weight:\s*(?:700|bold|800|900)\b/g, +}; + +/** Comments blanked (newlines kept, so line numbers hold). `//` skips `://` in URLs. */ +function withoutComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ' ')) + .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1'); +} + +/** Every legacy type form in a source file, as `rule @ line`. */ +function legacyTypeHits(file: string, source: string): string[] { + const rules = file.endsWith('.css') ? CSS_RULES : CLASS_RULES; + const code = withoutComments(source); + return Object.entries(rules).flatMap(([name, pattern]) => + [...code.matchAll(pattern)].map( + (m) => `${name} @ line ${code.slice(0, m.index).split('\n').length}` + ) + ); +} + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return name === 'node_modules' ? [] : walk(path); + return [path]; + }); +} + +function guardedFiles(): string[] { + return walk(SRC) + .map((path) => relative(SRC, path).split(sep).join('/')) + .filter((file) => /\.(?:tsx?|css)$/.test(file)) + .filter((file) => !/\.test\.|\.browser\.|\.d\.ts$/.test(file)) + .filter((file) => !OUT_OF_SCOPE.some((pattern) => pattern.test(file))); +} + +describe('the call sites use the type roles', () => { + const files = guardedFiles(); + + it('is looking at the whole renderer', () => { + expect(files.length).toBeGreaterThan(500); + expect(files).toContain('components/ui/button.tsx'); + expect(files).toContain('styles/main.css'); + expect(files.some((file) => file.startsWith('components/crew/'))).toBe(false); + }); + + it('lets no guarded file carry a legacy type form', () => { + const over: string[] = []; + for (const file of files) { + const hits = legacyTypeHits(file, readFileSync(join(SRC, file), 'utf8')); + if (hits.length > 0) over.push(`${file}: ${hits.length}: ${hits.slice(0, 3).join('; ')}`); + } + expect(over, 'use a text- from main.css; see implementation-spec 2.1').toEqual([]); + }); + + it('guards the sweep that finished: onboarding and the provider guard are in scope', () => { + expect(files.some((file) => file.startsWith('components/onboarding/'))).toBe(true); + expect(files).toContain('components/ProviderGuard.tsx'); + expect(files).toContain('components/ui/badge.tsx'); + }); + + /** Guards the guard: each form must be seen, and prose about it must not be. */ + it('recognises each legacy form and ignores comments about them', () => { + const tsx = [ + '

      a

      ', + '

      b

      ', + '

      c

      ', + '

      d

      ', + ].join('\n'); + expect(legacyTypeHits('a.tsx', tsx)).toEqual([ + 'legacy size class @ line 1', + 'legacy size class @ line 2', + 'tracking class @ line 3', + 'bare uppercase @ line 3', + 'font-bold @ line 4', + ]); + const prose = [ + '// text-sm is retired, so is tracking-wide and uppercase', + '/* font-bold and text-[11px] */', + '{/* uppercase */}', + '

      e

      ', + '

      f

      ', + ].join('\n'); + expect(legacyTypeHits('b.tsx', prose)).toEqual([]); + // An identifier named `uppercase` is not the class: a prop, its default, its use. + const identifier = [ + ' uppercase?: boolean;', + ' uppercase = false,', + " uppercase && 'text-caps',", + ].join('\n'); + expect(legacyTypeHits('d.tsx', identifier)).toEqual([]); + expect(legacyTypeHits('e.tsx', "

      x

      ")).toEqual([ + 'bare uppercase @ line 1', + ]); + const css = + '.a { font-size: 13px; }\n.b { font-weight: 700; }\n.c { font-size: var(--text-body); }'; + expect(legacyTypeHits('c.css', css)).toEqual([ + 'px font-size @ line 1', + 'weight 700 or heavier @ line 2', + ]); + }); +}); diff --git a/ui/desktop/src/test/hookTimeout.test.ts b/ui/desktop/src/test/hookTimeout.test.ts new file mode 100644 index 000000000..f519f64a5 --- /dev/null +++ b/ui/desktop/src/test/hookTimeout.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment node +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { HOOK_TIMEOUT_MS, TEST_TIMEOUT_MS } from './timeouts'; + +/** + * The hook budget the Chromium-backed files depend on, pinned. + * + * About eighty `*.browser.test.*` files launch their own Chromium in a + * `beforeAll` and close it in an `afterAll`. Under a full run on a loaded + * machine the close took longer than the old 30 s hook budget in two to four + * files per run, with every assertion in them passed (`Hook timed out in + * 30000ms`). The budget is ./timeouts.ts's `HOOK_TIMEOUT_MS`; this file keeps + * the three places that could silently put it back from doing so: + * + * - vitest.config.ts must resolve `hookTimeout` to that constant (a literal + * there, or an `export default` that stops reading it, would not). + * - A `--hookTimeout` flag beats the config. The `unit` job in frontend.yml + * carried `--hookTimeout=30000` until this budget moved, so CI would have + * kept the old number whatever the config said. No script or workflow step + * may pass one lower than the constant. + * - The constant must stay above the test budget. A hook that is allowed + * less time than the test it serves is the shape of the original failure. + * + * The config is resolved in a child `node` for the reason vitestIsolation.test.ts + * gives: this worker runs under the very config being checked. + */ + +const DESKTOP = join(__dirname, '../..'); +const REPO = join(DESKTOP, '../..'); + +/** + * The smallest hook budget that has held. A full run of the whole suite at load + * average 115 to 300 overran 30 s, and one at 560 overran 120 s; a lower number + * than this is a regression to be argued, not a tidy-up. + */ +const FLOOR_MS = 300_000; + +const MARKER = '@@hook-timeout-guard@@'; + +const RESOLVE_IN_CHILD = ` +const { parseCLI, resolveConfig } = await import('vitest/node'); +const { options } = parseCLI('vitest run'); +const { vitestConfig } = await resolveConfig({ ...options, root: process.cwd() }); +process.stdout.write('\\n${MARKER}' + JSON.stringify({ + hookTimeout: vitestConfig.hookTimeout, + testTimeout: vitestConfig.testTimeout, +}) + '\\n'); +`; + +function resolveConfigInChild(): { hookTimeout: number; testTimeout: number } { + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== 'VITEST' && !key.startsWith('VITEST_')) + ); + const stdout = execFileSync(process.execPath, ['--input-type=module', '-e', RESOLVE_IN_CHILD], { + cwd: DESKTOP, + env, + encoding: 'utf8', + }); + const line = stdout.split('\n').find((l) => l.startsWith(MARKER)); + if (!line) throw new Error(`the resolver printed no result:\n${stdout}`); + return JSON.parse(line.slice(MARKER.length)); +} + +/** Every file that can carry a vitest command line: package scripts, workflows, composite actions. */ +function commandFiles(): { where: string; body: string }[] { + const files: { where: string; body: string }[] = [ + { where: 'ui/desktop/package.json', body: readFileSync(join(DESKTOP, 'package.json'), 'utf8') }, + ]; + const workflows = join(REPO, '.github/workflows'); + for (const file of readdirSync(workflows)) { + if (/\.ya?ml$/.test(file)) { + files.push({ + where: relative(REPO, join(workflows, file)), + body: readFileSync(join(workflows, file), 'utf8'), + }); + } + } + const actions = join(REPO, '.github/actions'); + if (existsSync(actions)) { + for (const dir of readdirSync(actions)) { + for (const name of ['action.yml', 'action.yaml']) { + const path = join(actions, dir, name); + if (existsSync(path)) + files.push({ where: relative(REPO, path), body: readFileSync(path, 'utf8') }); + } + } + } + const justfile = join(REPO, 'Justfile'); + if (existsSync(justfile)) files.push({ where: 'Justfile', body: readFileSync(justfile, 'utf8') }); + return files; +} + +describe('the hook budget the Chromium-backed files rely on', () => { + it('is no lower than the floor that held in a loaded full run, and above the test budget', () => { + expect(HOOK_TIMEOUT_MS).toBeGreaterThanOrEqual(FLOOR_MS); + expect(HOOK_TIMEOUT_MS).toBeGreaterThan(TEST_TIMEOUT_MS); + }); + + it('is what vitest.config.ts resolves, and the test budget is untouched', () => { + const resolved = resolveConfigInChild(); + expect(resolved.hookTimeout, 'vitest.config.ts resolves a different hookTimeout').toBe( + HOOK_TIMEOUT_MS + ); + expect(resolved.testTimeout).toBe(TEST_TIMEOUT_MS); + }); + + it('is not undercut by a --hookTimeout flag in any script, workflow, action or Justfile', () => { + const undercut: string[] = []; + for (const { where, body } of commandFiles()) { + // `--hookTimeout=N`, `--hookTimeout N` and the kebab spelling cac also reads. + for (const match of body.matchAll(/--hook-?timeout(?:=|\s+)(\d+)/gi)) { + if (Number(match[1]) < HOOK_TIMEOUT_MS) { + undercut.push(`${where}: ${match[0]} (the config says ${HOOK_TIMEOUT_MS})`); + } + } + } + expect( + undercut, + `a command line beats vitest.config.ts, so these keep the old hook budget: ${undercut.join('; ')}` + ).toEqual([]); + }); + + it('finds the unit job it is guarding (frontend.yml still runs vitest)', () => { + const frontend = commandFiles().find((f) => f.where === '.github/workflows/frontend.yml'); + expect(frontend, 'frontend.yml is not among the files scanned').toBeDefined(); + expect(frontend?.body).toMatch(/npx vitest run/); + }); +}); diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts index c8376cf0c..97611bcc6 100644 --- a/ui/desktop/src/test/setup.ts +++ b/ui/desktop/src/test/setup.ts @@ -6,6 +6,7 @@ import { assertNoUnexpectedNetworkAttempts, installOfflineFetch } from './networ import { waitForQueuedZeroDelayTimers } from './queuedTimers'; import { client } from '../api/client.gen'; import { resetComposerQueuesForTests } from '../utils/composerQueues'; +import { resetLastKnownForTests } from '../components/settings/lastKnown'; // This is the standard setup to ensure that React Testing Library's // automatic cleanup runs after each test. @@ -27,6 +28,9 @@ import { resetComposerQueuesForTests } from '../utils/composerQueues'; afterEach(() => { cleanup(); resetComposerQueuesForTests(); + // Settings' controls start from the last answer this window heard (settings/lastKnown.ts), so + // one test's answer must not be the next test's first paint. + resetLastKnownForTests(); assertNoUnexpectedNetworkAttempts(expect.getState().testPath); }); diff --git a/ui/desktop/src/test/timeouts.ts b/ui/desktop/src/test/timeouts.ts index c414a2e8f..5037b320d 100644 --- a/ui/desktop/src/test/timeouts.ts +++ b/ui/desktop/src/test/timeouts.ts @@ -40,8 +40,32 @@ */ export const TEST_TIMEOUT_MS = 30_000; -/** Same budget for beforeEach/afterEach and friends. */ -export const HOOK_TIMEOUT_MS = 30_000; +/** + * Budget for beforeEach/afterEach/beforeAll/afterAll. NOT the same number as + * TEST_TIMEOUT_MS, on purpose, and larger than it. + * + * About eighty `*.browser.test.*` files each launch their own Chromium in a + * `beforeAll` and close it in an `afterAll`. Closing is a handshake with a + * multi-process browser, so its time is set by how busy the machine is, not by + * anything the test does. In a full run (963 files across up to 15 workers on + * 16 cores, load average 115 to 300, measured 2026-10-11) the 30 s that used to + * be here was overrun by `await browser?.close()` in 2 to 4 files per run, every + * one with all its assertions passed and a different set of files each time: + * `Error: Hook timed out in 30000ms` at `afterAll`. Alone, the same files + * finish in 2 to 18 s. The failure was the budget, not a test, so the budget is + * what moved. 120 s was tried first and was overrun once in the next three + * full runs (`tabFocusRing.browser.test.ts`, whose three tests took 1.3 s and + * whose close took over 120 s, at a load average of 560), so it is 300 s. + * + * Raising it costs nothing on a green run (it bounds failures only). What it + * does cost is that a hook that really hangs is now reported after 300 s, not + * 30. A test body keeps `TEST_TIMEOUT_MS`. + * + * `src/test/hookTimeout.test.ts` pins this number into every command that runs + * vitest, because a `--hookTimeout=` flag on the command line beats the config + * and CI carried one at 30000 until this change. + */ +export const HOOK_TIMEOUT_MS = 300_000; /** * Single-wait budget. Must stay comfortably under TEST_TIMEOUT_MS so that a diff --git a/ui/desktop/src/toasts.test.tsx b/ui/desktop/src/toasts.test.tsx index 9d356bd4e..ace0e7318 100644 --- a/ui/desktop/src/toasts.test.tsx +++ b/ui/desktop/src/toasts.test.tsx @@ -148,7 +148,7 @@ describe('which toasts interrupt a screen reader', () => { expect(optionsOf(mocks.warning)).toMatchObject({ role: 'alert' }); }); - it('lets the grouped extension report interrupt only when something failed', () => { + it('lets the grouped extension report interrupt only when something failed', async () => { const loading = [{ name: 'developer', status: 'loading' as const }]; toastService.extensionLoading(loading, 1, false); expect(mocks.toast.mock.calls[0][1]).toMatchObject({ role: 'status' }); @@ -160,9 +160,58 @@ describe('which toasts interrupt a screen reader', () => { toastService.extensionLoading(failed, 1, true); expect(mocks.update.mock.calls[0][1]).toMatchObject({ type: 'error', role: 'alert' }); + // A clean load arriving now does not overwrite the failure report. + const { toast } = await import('react-toastify'); const loaded = [{ name: 'developer', status: 'success' as const }]; toastService.extensionLoading(loaded, 1, true); - expect(mocks.update.mock.calls[1][1]).toMatchObject({ type: 'success', role: 'status' }); + expect(mocks.update).toHaveBeenCalledTimes(1); + expect(toast.dismiss).not.toHaveBeenCalled(); + }); +}); + +// Vision V2-14: "All extensions loaded" landed over the docked summary rail on a +// new chat's first turn, reporting a load the person did not ask for. +describe('a clean extension load raises no toast', () => { + beforeEach(async () => { + vi.clearAllMocks(); + mocks.isActive.mockReturnValue(false); + toastService.configure({ silent: false }); + const { toast } = await import('react-toastify'); + (toast.dismiss as ReturnType).mockClear(); + }); + + it('raises nothing when every extension loaded', () => { + const loaded = [ + { name: 'developer', status: 'success' as const }, + { name: 'memory', status: 'success' as const }, + ]; + toastService.extensionLoading(loaded, 2, true); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it('takes down a progress report that ends cleanly', async () => { + const { toast } = await import('react-toastify'); + toastService.extensionLoading([{ name: 'developer', status: 'loading' as const }], 1, false); + expect(mocks.toast).toHaveBeenCalledTimes(1); + + mocks.isActive.mockReturnValue(true); + toastService.extensionLoading([{ name: 'developer', status: 'success' as const }], 1, true); + expect(mocks.update).not.toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith('extension-loading'); + }); + + it('still reports a failure, assertively and until dismissed', () => { + toastService.extensionLoading( + [{ name: 'developer', status: 'error' as const, error: 'boom' }], + 1, + true + ); + expect(mocks.toast.mock.calls[0][1]).toMatchObject({ + type: 'error', + role: 'alert', + autoClose: false, + }); }); }); diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index ab4a764c4..8d7c728de 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -28,6 +28,8 @@ const EXTENSION_TOAST_ID = 'extension-loading'; class ToastService { private silent: boolean = false; private shouldThrow: boolean = false; + /** Whether the grouped extension toast last raised was reporting a failure. */ + private extensionToastHasErrors: boolean = false; // Create a singleton instance private static instance: ToastService; @@ -121,6 +123,20 @@ class ToastService { // while loading, a green check when all loaded, the error theme on failure. const hasErrors = extensions.some((ext) => ext.status === 'error') || builtinFailures.length > 0; + + // A clean load is not news (vision V2-14, and the toast rule: never notify + // for something the person did not do). Every caller reports a chat's own + // extension load, which runs when a chat opens or starts its first turn, + // and "All extensions loaded" then sat over the docked summary rail for 5s. + // So nothing is raised for it, and a progress report still on screen is + // taken down. A failure report is left alone: it has its one announcement + // to make and a "View details" to keep open. + if (isComplete && !hasErrors) { + if (toast.isActive(toastId) && !this.extensionToastHasErrors) toast.dismiss(toastId); + return toastId; + } + this.extensionToastHasErrors = hasErrors; + const type: 'success' | 'error' | 'default' = !isComplete ? 'default' : hasErrors diff --git a/ui/desktop/src/utils.ts b/ui/desktop/src/utils.ts index 0ebc20ae2..0637f7152 100644 --- a/ui/desktop/src/utils.ts +++ b/ui/desktop/src/utils.ts @@ -33,12 +33,13 @@ import { extendTailwindMerge } from 'tailwind-merge'; const twMerge = extendTailwindMerge({ extend: { theme: { + // `heading` and `display` are gone (redesign 2026-10): the sizes they named + // are `subheading` and `title`. text: [ - 'display', 'title', - 'heading', 'subheading', 'body', + 'prose', 'label', 'secondary', 'supporting', @@ -65,6 +66,7 @@ const twMerge = extendTailwindMerge({ 'tab', 'row', 'row-rail', + 'row-nav', 'dialog-sm', 'dialog-md', 'dialog-lg', @@ -74,8 +76,6 @@ const twMerge = extendTailwindMerge({ 'measure-graph', 'knowledge-rail-sources', 'knowledge-rail-detail', - 'knowledge-subject-height', - 'knowledge-filter-height', ], // All five elevations, not just the new one. The four that shipped before // `raised` were never registered, so they fell through to the `shadow-color` diff --git a/ui/desktop/src/utils/appFolder.test.ts b/ui/desktop/src/utils/appFolder.test.ts new file mode 100644 index 000000000..6514b7fe6 --- /dev/null +++ b/ui/desktop/src/utils/appFolder.test.ts @@ -0,0 +1,328 @@ +// @vitest-environment node + +/** + * The Windows app folder (R-31) from the desktop main process's side. + * + * The first block runs the `resolution` cases of the table the daemon's suite + * runs too (`crates/biorouter-mcp/src/app_folder/resolution-cases.json`), + * against real temporary directories, so the two implementations resolve + * every state to the same folder rather than each agreeing with its own idea + * of it. The `moves` cases are the daemon's alone: this process never moves + * anything, and the block after the table holds it to that. The rest pins what + * only this side has: the notice for a hidden legacy folder, the inputs it + * reads, and the resolver's Windows branch, which runs here on every OS + * through `path.win32`. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as appFolder from './appFolder'; +import { + APP_AUTHOR, + APP_NAME, + LEGACY_APP_AUTHOR, + appRootUnder, + describeLegacyFolderNotice, + legacyFolderNotice, + presenceOf, + resolveAppRoot, + windowsBases, + type Presence, +} from './appFolder'; +import { platformConfigDirFor } from './biorouterPaths'; + +interface ResolutionCase { + name: string; + before: string[]; + unreadable?: string[]; + resolves: string; +} + +const TABLE = new URL( + '../../../../crates/biorouter-mcp/src/app_folder/resolution-cases.json', + import.meta.url +); + +const temporary: string[] = []; +const locked: string[] = []; +function tempBase(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'br-app-folder-')); + temporary.push(dir); + return dir; +} +afterEach(() => { + for (const dir of locked.splice(0)) fs.chmodSync(dir, 0o755); + for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** `//`, for the tokens the shared table defines. */ +function tokenPath(base: string, spec: string): string { + const [head, ...rest] = spec.replace(/\/+$/, '').split('/'); + const roots: Record = { + current: path.join(base, APP_AUTHOR, APP_NAME), + legacy: path.join(base, LEGACY_APP_AUTHOR, APP_NAME), + currentParent: path.join(base, APP_AUTHOR), + legacyParent: path.join(base, LEGACY_APP_AUTHOR), + lookalike: path.join(base, `${LEGACY_APP_AUTHOR}2`, APP_NAME), + }; + const root = roots[head]; + if (!root) throw new Error(`unknown path token ${head}`); + return path.join(root, ...rest.filter(Boolean)); +} + +function materialize(base: string, spec: string): void { + const target = tokenPath(base, spec); + if (spec.endsWith('/')) { + fs.mkdirSync(target, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, spec); + } +} + +/** Remove the permissions of `specs`; false when that cannot set the state up + * here (Windows, or permissions not enforced because this runs as root). */ +function makeUnreadable(base: string, specs: string[]): boolean { + if (specs.length === 0) return true; + if (process.platform === 'win32') return false; + for (const spec of specs) { + const dir = tokenPath(base, spec); + fs.chmodSync(dir, 0o000); + locked.push(dir); + } + return specs.every((spec) => { + try { + fs.readdirSync(tokenPath(base, spec)); + return false; + } catch { + return true; + } + }); +} + +describe('the resolution table both implementations run', () => { + const cases: ResolutionCase[] = JSON.parse(fs.readFileSync(TABLE, 'utf-8')).resolution; + + it('has every case the daemon suite counts on', () => { + expect(cases.length).toBeGreaterThanOrEqual(8); + }); + + for (const testCase of cases) { + it(testCase.name, (context) => { + const base = tempBase(); + for (const spec of testCase.before) materialize(base, spec); + if (!makeUnreadable(base, testCase.unreadable ?? [])) { + context.skip(); + return; + } + expect(resolveAppRoot(appRootUnder(base))).toBe(tokenPath(base, testCase.resolves)); + }); + } +}); + +describe('this process never moves the folder', () => { + it('exports nothing that moves, and resolving leaves a legacy folder where it is', () => { + const exported = Object.keys(appFolder); + for (const mover of ['moveForProcess', 'moveLegacyRoot', 'ensureAppFolderMoved']) { + expect(exported).not.toContain(mover); + } + const base = tempBase(); + materialize(base, 'legacy/config/config.yaml'); + expect(resolveAppRoot(appRootUnder(base))).toBe(tokenPath(base, 'legacy')); + expect(fs.existsSync(tokenPath(base, 'legacy/config/config.yaml'))).toBe(true); + expect(fs.existsSync(tokenPath(base, 'currentParent'))).toBe(false); + }); + + it('the main process does not move it at load either', () => { + const main = fs.readFileSync(new URL('../main.ts', import.meta.url), 'utf-8'); + expect(main).not.toMatch(/ensureAppFolderMoved|moveForProcess|describeFolderMove/); + const resolver = fs.readFileSync(new URL('./biorouterPaths.ts', import.meta.url), 'utf-8'); + expect(resolver).not.toMatch(/ensureAppFolderMoved|moveForProcess/); + }); +}); + +describe('presence', () => { + it('reads a missing path and a path under a file as absent, and a real one as present', () => { + const base = tempBase(); + materialize(base, 'legacyParent'); + expect(presenceOf(tokenPath(base, 'legacy'))).toBe('absent'); + expect(presenceOf(tokenPath(base, 'current'))).toBe('absent'); + expect(presenceOf(base)).toBe('present'); + }); + + it.skipIf(process.platform === 'win32')( + 'reads a failed check as unknown, not absent', + (context) => { + const base = tempBase(); + materialize(base, 'legacy/config/config.yaml'); + if (!makeUnreadable(base, ['legacyParent'])) { + context.skip(); + return; + } + expect(presenceOf(tokenPath(base, 'legacy'))).toBe('unknown'); + } + ); +}); + +describe('the notice for a hidden legacy folder', () => { + const both = () => { + const roaming = tempBase(); + materialize(roaming, 'legacy/config/config.yaml'); + materialize(roaming, 'current/config/config.yaml'); + return roaming; + }; + + it('names both folders when both exist on Windows', () => { + const roaming = both(); + const notice = legacyFolderNotice({ + platform: 'win32', + env: { APPDATA: roaming, LOCALAPPDATA: tempBase() }, + homedir: roaming, + }); + expect(notice).toEqual({ + current: tokenPath(roaming, 'current'), + legacy: tokenPath(roaming, 'legacy'), + }); + const words = describeLegacyFolderNotice(notice!); + expect(words.detail).toContain(tokenPath(roaming, 'legacy')); + expect(words.detail).toContain(tokenPath(roaming, 'current')); + expect(words.buttons).toHaveLength(2); + }); + + it('says nothing in every other state, off Windows, and under a sandbox root', () => { + const states: string[][] = [ + [], + ['legacy/config/config.yaml'], + ['current/config/config.yaml'], + ['lookalike/config/config.yaml', 'current/config/config.yaml'], + ]; + for (const before of states) { + const roaming = tempBase(); + for (const spec of before) materialize(roaming, spec); + expect( + legacyFolderNotice({ platform: 'win32', env: { APPDATA: roaming }, homedir: roaming }), + before.join(', ') + ).toBeUndefined(); + } + const roaming = both(); + expect( + legacyFolderNotice({ platform: 'darwin', env: { APPDATA: roaming }, homedir: roaming }) + ).toBeUndefined(); + expect( + legacyFolderNotice({ + platform: 'win32', + env: { APPDATA: roaming, BIOROUTER_PATH_ROOT: '/sb' }, + homedir: roaming, + }) + ).toBeUndefined(); + expect( + legacyFolderNotice({ + platform: 'win32', + env: { APPDATA: roaming, BIOROUTER_PATH_ROOT: ' ' }, + homedir: roaming, + }), + 'a blank root is no root' + ).toBeDefined(); + }); + + it('only for the roaming folder: a cache folder holds nothing to recover', () => { + const roaming = tempBase(); + materialize(roaming, 'current/config/config.yaml'); + const local = tempBase(); + materialize(local, 'legacy/cache/a.html'); + materialize(local, 'current/cache/a.html'); + expect( + legacyFolderNotice({ + platform: 'win32', + env: { APPDATA: roaming, LOCALAPPDATA: local }, + homedir: roaming, + }) + ).toBeUndefined(); + }); + + it('is not raised by a check that failed', () => { + const roaming = both(); + const unknownLegacy = (target: string): Presence => + target === tokenPath(roaming, 'legacy') ? 'unknown' : presenceOf(target); + expect( + legacyFolderNotice({ + platform: 'win32', + env: { APPDATA: roaming }, + homedir: roaming, + presence: unknownLegacy, + }) + ).toBeUndefined(); + }); +}); + +describe('the Windows bases', () => { + it('falls back to the home folder when APPDATA and LOCALAPPDATA are blank', () => { + const home = 'C:\\Users\\Ana'; + expect(windowsBases({ APPDATA: '', LOCALAPPDATA: '' }, home, path.win32.join)).toEqual({ + roaming: 'C:\\Users\\Ana\\AppData\\Roaming', + local: 'C:\\Users\\Ana\\AppData\\Local', + }); + }); +}); + +/** + * `biorouterConfigDir`'s Windows branch, on any OS: the folder the daemon's + * `Paths::config_dir()` names for the same state (pinned on the Rust side by + * `windows_dirs` and the shared table). + */ +describe('the resolver on Windows', () => { + const home = 'C:\\Users\\Ana'; + const appData = 'C:\\Users\\Ana\\AppData\\Roaming'; + const current = path.win32.join(appData, APP_AUTHOR, APP_NAME); + const legacy = path.win32.join(appData, LEGACY_APP_AUTHOR, APP_NAME); + const resolveWith = (states: Record, env: Record) => + platformConfigDirFor({ + platform: 'win32', + env, + homedir: home, + pathImpl: path.win32, + presence: (target) => states[target] ?? 'absent', + }); + + it('names the current folder on a fresh install', () => { + expect(resolveWith({}, { APPDATA: appData })).toBe( + 'C:\\Users\\Ana\\AppData\\Roaming\\UCSF\\biorouter\\config' + ); + }); + + it('keeps the legacy folder while the daemon has not moved it', () => { + expect(resolveWith({ [legacy]: 'present' }, { APPDATA: appData })).toBe( + path.win32.join(legacy, 'config') + ); + }); + + it('keeps the legacy folder while it cannot be checked', () => { + expect(resolveWith({ [legacy]: 'unknown' }, { APPDATA: appData })).toBe( + path.win32.join(legacy, 'config') + ); + }); + + it('prefers the current folder when both exist', () => { + expect(resolveWith({ [legacy]: 'present', [current]: 'present' }, { APPDATA: appData })).toBe( + path.win32.join(current, 'config') + ); + }); + + it('joins the home folder when APPDATA is blank, as etcetera does', () => { + expect(resolveWith({}, { APPDATA: '' })).toBe( + 'C:\\Users\\Ana\\AppData\\Roaming\\UCSF\\biorouter\\config' + ); + }); + + it('never reads the author off Windows', () => { + expect( + platformConfigDirFor({ + platform: 'linux', + env: {}, + homedir: '/home/ana', + pathImpl: path.posix, + }) + ).toBe('/home/ana/.config/biorouter'); + }); +}); diff --git a/ui/desktop/src/utils/appFolder.ts b/ui/desktop/src/utils/appFolder.ts new file mode 100644 index 000000000..897fdd25d --- /dev/null +++ b/ui/desktop/src/utils/appFolder.ts @@ -0,0 +1,176 @@ +/** + * appFolder.ts — Biorouter's own folder on Windows, as the desktop main + * process resolves it (R-31). + * + * The desktop half of `crates/biorouter-mcp/src/app_folder/`, whose module + * header states the whole rule. For each base (`%APPDATA%` for config, data + * and logs, `%LOCALAPPDATA%` for the cache) there is a current folder, + * `\UCSF\biorouter`, and the legacy folder earlier releases used. + * + * This process only RESOLVES, on every call: the legacy folder while it is (or + * may be) there and the current one is known not to be, otherwise the current + * one. It never moves anything. The daemon moves the legacy folder as it + * starts, when no other Biorouter process is using it, and then rewrites the + * paths stored inside; because this process resolves per call and freezes no + * path, a move during its run costs it nothing. (It used to move the folder + * itself, at module load. That could rename the folder out from under a + * command line session that had already frozen the legacy path, and a second + * instance about to quit as a duplicate did it too.) + * + * When both folders exist the current one wins, and anything saved in the + * legacy one is hidden; `legacyFolderNotice` is what the main process shows + * the person then, naming the folder. + * + * Both implementations run the `resolution` cases of one table, + * `crates/biorouter-mcp/src/app_folder/resolution-cases.json`, and a Rust test + * reads the three constants below from this file and compares them with its + * own. + * + * macOS and Linux use the XDG layout, which has no author segment, so nothing + * here applies there. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** `%APPDATA%\UCSF\biorouter`. Not `Biorouter`: that is Electron's own folder + * on Windows (the desktop `productName`), and the two must stay apart. */ +export const APP_AUTHOR = 'UCSF'; +export const APP_NAME = 'biorouter'; +/** + * The author folder earlier releases used on Windows. Resolution has to name + * the folder to find it, so this is the one place the desktop code writes the + * name; the daemon's copy is `LEGACY_APP_AUTHOR` in `app_folder/mod.rs`, + * pinned equal to this one by a Rust test. + */ +export const LEGACY_APP_AUTHOR = 'Block'; + +type Join = (...parts: string[]) => string; + +/** One base directory's two candidate folders, and their author folders. */ +export interface AppRoot { + current: string; + legacy: string; + currentParent: string; + legacyParent: string; +} + +export function appRootUnder(base: string, join: Join = path.join): AppRoot { + return { + current: join(base, APP_AUTHOR, APP_NAME), + legacy: join(base, LEGACY_APP_AUTHOR, APP_NAME), + currentParent: join(base, APP_AUTHOR), + legacyParent: join(base, LEGACY_APP_AUTHOR), + }; +} + +/** What a check of one folder found. `unknown`: the check failed (permission + * denied, an unreachable share), so it may be there. */ +export type Presence = 'present' | 'absent' | 'unknown'; + +/** `fs.existsSync` reads every failure as "absent", which is how a check that + * failed for a moment could send this process into a new, empty current + * folder. Only "not found" and "a segment above it is a file" are absent. */ +export function presenceOf(target: string): Presence { + try { + fs.statSync(target); + return 'present'; + } catch (error) { + const code = (error as { code?: string }).code; + return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown'; + } +} + +/** + * `[legacy, current]` as of one instant. Two separate checks can straddle the + * daemon's rename and see the legacy folder before it and the current one + * after it; reading `legacy` on both sides of `current` and retrying when they + * disagree gives a consistent pair. Bounded, as on the Rust side. + */ +function snapshot(root: AppRoot, presence: (target: string) => Presence): [Presence, Presence] { + let pair: [Presence, Presence] = [presence(root.legacy), presence(root.current)]; + for (let attempt = 0; attempt < 16; attempt++) { + const again = presence(root.legacy); + if (again === pair[0]) break; + pair = [again, presence(root.current)]; + } + return pair; +} + +/** The folder to read and write right now. */ +export function resolveAppRoot( + root: AppRoot, + presence: (target: string) => Presence = presenceOf +): string { + const [legacy, current] = snapshot(root, presence); + return current === 'absent' && legacy !== 'absent' ? root.legacy : root.current; +} + +type Env = Record; + +/** + * The two Windows base directories: `APPDATA` and `LOCALAPPDATA` when set and + * non-empty, else `\AppData\Roaming` and `\AppData\Local`. + * etcetera has a middle rung (the known-folder API) this cannot reproduce; on + * a normal profile both name the same directory, and every Electron process we + * ship has both variables set. + */ +export function windowsBases(env: Env, homedir: string, join: Join = path.join) { + const pick = (value: string | undefined, ...fallback: string[]) => + value && value.length > 0 ? value : join(homedir, ...fallback); + return { + roaming: pick(env.APPDATA, 'AppData', 'Roaming'), + local: pick(env.LOCALAPPDATA, 'AppData', 'Local'), + }; +} + +export interface ProcessInputs { + platform: typeof process.platform; + env: Env; + homedir: string; + presence?: (target: string) => Presence; + join?: Join; +} + +/** Both folders exist for the roaming base, which holds config, chats and + * knowledge bases: the current one is in use, and the legacy one is hidden. */ +export interface LegacyFolderNotice { + current: string; + legacy: string; +} + +/** + * Whether the person must be told about a hidden legacy folder. Only on + * Windows, only outside a sandbox (the same blank-is-absent rule as the + * resolvers), and only when both roaming folders are certainly there: the + * cache's legacy folder holds nothing worth recovering. + */ +export function legacyFolderNotice(inputs: ProcessInputs): LegacyFolderNotice | undefined { + if (inputs.platform !== 'win32') return undefined; + const sandboxRoot = inputs.env.BIOROUTER_PATH_ROOT; + if (sandboxRoot && sandboxRoot.trim().length > 0) return undefined; + const join = inputs.join ?? path.join; + const root = appRootUnder(windowsBases(inputs.env, inputs.homedir, join).roaming, join); + const [legacy, current] = snapshot(root, inputs.presence ?? presenceOf); + if (legacy !== 'present' || current !== 'present') return undefined; + return { current: root.current, legacy: root.legacy }; +} + +/** The words of that notice, for a message box. */ +export function describeLegacyFolderNotice(notice: LegacyFolderNotice) { + return { + title: 'Older Biorouter folder found', + message: 'Biorouter found a second data folder, and is not using it.', + detail: + `Biorouter keeps its settings, chats and knowledge bases in ${notice.current}. ` + + `Earlier releases used ${notice.legacy}, and that folder exists again, so anything ` + + 'saved there since the update (settings, chats, knowledge bases) does not appear in ' + + 'Biorouter.\n\n' + + 'This usually means an older copy of the biorouter command line tool ran after the ' + + 'update, or an earlier release of the app was opened. Update the command line tool ' + + 'when Biorouter offers to, then copy anything you need out of the older folder, and ' + + 'delete it when you no longer need it. Biorouter never moves or deletes it on its own.', + buttons: ['Show older folder', 'OK'], + checkboxLabel: 'Do not show this again for this folder', + }; +} diff --git a/ui/desktop/src/utils/biorouterPaths.ts b/ui/desktop/src/utils/biorouterPaths.ts index 07e04ff1a..5dd3af1a4 100644 --- a/ui/desktop/src/utils/biorouterPaths.ts +++ b/ui/desktop/src/utils/biorouterPaths.ts @@ -19,12 +19,16 @@ * 1. **`BIOROUTER_PATH_ROOT`** — the sandbox seam. `/config`, the shape * `main.ts` already used for `config.yaml`. * 2. Otherwise the **platform** directory, via `etcetera`'s - * `choose_app_strategy` with `{author: "Block", app_name: "biorouter"}`. + * `choose_app_strategy` with the workspace's one `AppStrategyArgs` + * (`biorouter-mcp`'s `APP_STRATEGY`, author `UCSF`, app `biorouter`). * That is the **Xdg** strategy on macOS as well as Linux (verified in * etcetera 0.11's `app_strategy.rs`: `create_strategies!(Apple, Xdg)` puts * Xdg in the `choose_app_strategy` slot), and the **Windows** strategy on - * Windows. It is NOT `~/.config/biorouter` on either count: a non-default - * `XDG_CONFIG_HOME` moves it, and Windows has never used that layout. + * Windows, where the folder is the one `./appFolder.ts` resolves: the + * current one, or the folder earlier releases used until the daemon has + * moved it. It is NOT + * `~/.config/biorouter` on either count: a non-default `XDG_CONFIG_HOME` + * moves it, and Windows has never used that layout. * `crates/biorouter-server/src/routes/shell.rs` fixed and documented the * same defect on the Rust side. * @@ -59,40 +63,62 @@ import * as os from 'os'; import * as path from 'path'; +import { APP_NAME, appRootUnder, resolveAppRoot, windowsBases, type Presence } from './appFolder'; -/** `{author: "Block", app_name: "biorouter"}` in `paths.rs`'s AppStrategyArgs. */ -const APP_STRATEGY_AUTHOR = 'Block'; -const APP_STRATEGY_APP_NAME = 'biorouter'; +const APP_STRATEGY_APP_NAME = APP_NAME; + +/** What the platform half of the resolver reads; `process` and `os` in the app. */ +export interface PlatformInputs { + platform: typeof process.platform; + env: Record; + homedir: string; + /** `path.win32` lets the suite run the Windows branch on every OS. */ + pathImpl?: Pick; + presence?: (target: string) => Presence; +} /** * The platform config directory — what `choose_app_strategy(...).config_dir()` - * returns when no sandbox root is set. + * returns when no sandbox root is set. Exported for the suite only, which runs + * the Windows branch on every OS; the app calls it through + * `biorouterConfigDir`. * - * Windows: `%APPDATA%\Block\biorouter\config`. ⚠ etcetera's ladder has a rung - * this cannot reproduce — an unset `APPDATA` sends it to the known-folder API - * before it falls back to `\AppData\Roaming` — so this skips the middle - * rung and joins the home dir directly. On a normal profile the two name the - * same directory, and `APPDATA` is set in every Electron process we ship. + * Windows: `%APPDATA%\UCSF\biorouter\config`, or the same folder under the + * legacy author while the daemon has not moved it yet (see `./appFolder.ts`; + * this process never moves it). ⚠ + * etcetera's ladder has a rung this cannot reproduce — an unset `APPDATA` + * sends it to the known-folder API before it falls back to + * `\AppData\Roaming` — so this skips the middle rung and joins the home + * dir directly. On a normal profile the two name the same directory, and + * `APPDATA` is set in every Electron process we ship. * * Elsewhere (macOS included): `$XDG_CONFIG_HOME/biorouter`, or * `~/.config/biorouter` when that variable is unset or **not absolute** — the * XDG spec says a relative value is ignored, and etcetera implements exactly * that (`env_var_or_none` returns `None` unless `path.is_absolute()`). */ -function platformConfigDir(): string { - if (process.platform === 'win32') { - const appData = process.env.APPDATA; - const roaming = - appData && appData.length > 0 ? appData : path.join(os.homedir(), 'AppData', 'Roaming'); - return path.join(roaming, APP_STRATEGY_AUTHOR, APP_STRATEGY_APP_NAME, 'config'); +export function platformConfigDirFor(inputs: PlatformInputs): string { + const p = inputs.pathImpl ?? path; + if (inputs.platform === 'win32') { + const { roaming } = windowsBases(inputs.env, inputs.homedir, p.join); + const root = appRootUnder(roaming, p.join); + return p.join(resolveAppRoot(root, inputs.presence), 'config'); } - const xdgConfigHome = process.env.XDG_CONFIG_HOME; + const xdgConfigHome = inputs.env.XDG_CONFIG_HOME; const base = - xdgConfigHome && path.isAbsolute(xdgConfigHome) + xdgConfigHome && p.isAbsolute(xdgConfigHome) ? xdgConfigHome - : path.join(os.homedir(), '.config'); - return path.join(base, APP_STRATEGY_APP_NAME); + : p.join(inputs.homedir, '.config'); + return p.join(base, APP_STRATEGY_APP_NAME); +} + +function platformConfigDir(): string { + return platformConfigDirFor({ + platform: process.platform, + env: process.env, + homedir: os.homedir(), + }); } /** @@ -108,9 +134,15 @@ function platformConfigDir(): string { * the very thing the sandbox root exists to prevent. */ export function unsandboxedConfigDirCandidates(): string[] { - const platform = platformConfigDir(); - const homeJoin = path.join(os.homedir(), '.config', APP_STRATEGY_APP_NAME); - return platform === homeJoin ? [platform] : [platform, homeJoin]; + const candidates = [platformConfigDir()]; + if (process.platform === 'win32') { + // Both Windows folders are real, whichever one is in use: a path naming + // the other must be redirected into the sandbox too. + const root = appRootUnder(windowsBases(process.env, os.homedir()).roaming); + candidates.push(path.join(root.current, 'config'), path.join(root.legacy, 'config')); + } + candidates.push(path.join(os.homedir(), '.config', APP_STRATEGY_APP_NAME)); + return [...new Set(candidates)]; } /** diff --git a/ui/desktop/src/utils/chatDateBuckets.test.ts b/ui/desktop/src/utils/chatDateBuckets.test.ts new file mode 100644 index 000000000..3293eba90 --- /dev/null +++ b/ui/desktop/src/utils/chatDateBuckets.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { chatDateBucket, CHAT_DATE_BUCKET_LABELS, groupByChatDate } from './chatDateBuckets'; + +// Local time, so the calendar-day arithmetic is the one a person sees. +const NOW = new Date(2026, 9, 9, 12, 0, 0).getTime(); // Fri 9 Oct 2026, noon +const at = (year: number, month: number, day: number, hour = 12) => + new Date(year, month, day, hour, 0, 0).toISOString(); + +describe('chatDateBucket', () => { + it('names today and yesterday by local calendar day', () => { + expect(chatDateBucket(at(2026, 9, 9, 0), NOW).label).toBe('Today'); + expect(chatDateBucket(at(2026, 9, 8, 23), NOW).label).toBe('Yesterday'); + }); + + it('puts days 2 to 7 in Previous 7 days and 8 to 30 in Previous 30 days', () => { + expect(chatDateBucket(at(2026, 9, 7), NOW).label).toBe(CHAT_DATE_BUCKET_LABELS.week); + expect(chatDateBucket(at(2026, 9, 2), NOW).label).toBe(CHAT_DATE_BUCKET_LABELS.week); + expect(chatDateBucket(at(2026, 9, 1), NOW).label).toBe(CHAT_DATE_BUCKET_LABELS.month30); + expect(chatDateBucket(at(2026, 8, 9), NOW).label).toBe(CHAT_DATE_BUCKET_LABELS.month30); + }); + + it('names older months, adding the year outside the current one', () => { + expect(chatDateBucket(at(2026, 8, 8), NOW).label).toBe('September'); + expect(chatDateBucket(at(2026, 7, 1), NOW).label).toBe('August'); + expect(chatDateBucket(at(2025, 7, 1), NOW).label).toBe('August 2025'); + }); + + it('never makes a header per day', () => { + const labels = new Set( + [2, 3, 4, 5, 6, 7].map((back) => chatDateBucket(at(2026, 9, 9 - back), NOW).label) + ); + expect([...labels]).toEqual(['Previous 7 days']); + }); + + it('treats a future timestamp as today and an unreadable one as older', () => { + expect(chatDateBucket(at(2026, 9, 12), NOW).label).toBe('Today'); + expect(chatDateBucket('not a date', NOW).label).toBe('Older'); + }); +}); + +describe('groupByChatDate', () => { + it('orders buckets newest first and keeps the caller order inside each', () => { + const rows = [ + { id: 'b', when: at(2026, 9, 9) }, + { id: 'old', when: at(2025, 0, 3) }, + { id: 'a', when: at(2026, 9, 9) }, + { id: 'y', when: at(2026, 9, 8) }, + { id: 'sep', when: at(2026, 8, 1) }, + ]; + const groups = groupByChatDate(rows, (row) => row.when, NOW); + expect(groups.map((group) => [group.label, group.items.map((row) => row.id)])).toEqual([ + ['Today', ['b', 'a']], + ['Yesterday', ['y']], + ['September', ['sep']], + ['January 2025', ['old']], + ]); + }); + + it('builds the month formatter once per shape, not once per old chat (R-28)', () => { + // A construction costs about 25 µs, and the sidebar groups every loaded chat by date on a + // view switch: 5,500 chats older than 30 days took about 115 ms of formatter builds alone. + const rows = Array.from({ length: 3000 }, (_, index) => ({ + id: String(index), + // Spread over the last six years, all older than 30 days: both shapes (with and without + // the year) are asked for many times. + when: at(2026 - (index % 6), index % 12, 1 + (index % 28)), + })); + // Counted by a subclass: a spy on the constructor would not build the formatter. + const Real = Intl.DateTimeFormat; + let constructed = 0; + class Counting extends Real { + constructor(...args: ConstructorParameters) { + super(...args); + constructed += 1; + } + } + Intl.DateTimeFormat = Counting as typeof Intl.DateTimeFormat; + let labels: string[] = []; + try { + labels = groupByChatDate(rows, (row) => row.when, NOW).map((group) => group.label); + } finally { + Intl.DateTimeFormat = Real; + } + expect(constructed).toBeLessThanOrEqual(2); + // The labels are still the ones a fresh formatter gives, in both shapes. + expect(labels).toContain('July'); + expect(labels).toContain('April 2023'); + }); +}); diff --git a/ui/desktop/src/utils/chatDateBuckets.ts b/ui/desktop/src/utils/chatDateBuckets.ts new file mode 100644 index 000000000..d05c17f2f --- /dev/null +++ b/ui/desktop/src/utils/chatDateBuckets.ts @@ -0,0 +1,147 @@ +/** + * Coarse date buckets for chat lists, shared by the sidebar and History so the + * two never disagree about where a chat sits. + * + * Today, Yesterday, Previous 7 days, Previous 30 days, then the month name + * ("September"), with the year added outside the current year ("August 2025"). + * It replaces one header per day ("Oct 7", "Oct 6", …) in the sidebar and the + * long weekday format History used. + * + * Days are local calendar days, so a chat from 23:50 yesterday is "Yesterday" + * at 00:10 today. A timestamp in the future (clock skew between windows and the + * daemon) counts as today. + */ + +export type ChatDateBucketKind = 'today' | 'yesterday' | 'week' | 'month30' | 'month' | 'unknown'; + +export interface ChatDateBucket { + /** Stable key, for React keys and tests: `today`, `yesterday`, `week`, `month30`, `2026-08`, `unknown`. */ + key: string; + kind: ChatDateBucketKind; + /** Sentence-case label shown above the bucket's rows. */ + label: string; + /** Sort rank: smaller is newer. Buckets are shown in ascending rank. */ + rank: number; +} + +export interface ChatDateGroup { + key: string; + label: string; + items: T[]; +} + +export const CHAT_DATE_BUCKET_LABELS = { + today: 'Today', + yesterday: 'Yesterday', + week: 'Previous 7 days', + month30: 'Previous 30 days', + unknown: 'Older', +} as const; + +const DAY_MS = 86_400_000; + +function toTimestamp(value: string | number | Date | null | undefined): number { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return value; + if (typeof value === 'string') return Date.parse(value); + return Number.NaN; +} + +// One formatter per shape, built on first use. `new Intl.DateTimeFormat` costs about 25 µs and +// `chatDateBucket` runs once per chat older than 30 days: 115 ms for a store of 5,500 chats. +const MONTH_FORMATS = new Map(); +function monthFormat(sameYear: boolean): Intl.DateTimeFormat { + let format = MONTH_FORMATS.get(sameYear); + if (!format) { + format = new Intl.DateTimeFormat('en-US', { + month: 'long', + ...(sameYear ? {} : { year: 'numeric' }), + }); + MONTH_FORMATS.set(sameYear, format); + } + return format; +} + +function localCalendarDay(timestamp: number): number { + const date = new Date(timestamp); + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); +} + +/** The bucket a timestamp falls in, relative to `now`. */ +export function chatDateBucket( + value: string | number | Date | null | undefined, + now: number = Date.now() +): ChatDateBucket { + const timestamp = toTimestamp(value); + if (Number.isNaN(timestamp)) { + return { + key: 'unknown', + kind: 'unknown', + label: CHAT_DATE_BUCKET_LABELS.unknown, + rank: Number.MAX_SAFE_INTEGER, + }; + } + + const days = Math.max( + 0, + Math.round((localCalendarDay(now) - localCalendarDay(timestamp)) / DAY_MS) + ); + if (days === 0) + return { key: 'today', kind: 'today', label: CHAT_DATE_BUCKET_LABELS.today, rank: 0 }; + if (days === 1) { + return { + key: 'yesterday', + kind: 'yesterday', + label: CHAT_DATE_BUCKET_LABELS.yesterday, + rank: 1, + }; + } + if (days <= 7) return { key: 'week', kind: 'week', label: CHAT_DATE_BUCKET_LABELS.week, rank: 2 }; + if (days <= 30) { + return { key: 'month30', kind: 'month30', label: CHAT_DATE_BUCKET_LABELS.month30, rank: 3 }; + } + + const date = new Date(timestamp); + const current = new Date(now); + const sameYear = date.getFullYear() === current.getFullYear(); + const label = monthFormat(sameYear).format(date); + const monthsAgo = + (current.getFullYear() - date.getFullYear()) * 12 + (current.getMonth() - date.getMonth()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return { + key: `${date.getFullYear()}-${month}`, + kind: 'month', + label, + rank: 4 + Math.max(0, monthsAgo), + }; +} + +/** + * Group items into date buckets, newest bucket first. Items keep their input + * order inside a bucket, so the caller decides the order within a group (by + * activity, by creation or by name). + */ +export function groupByChatDate( + items: readonly T[], + timeOf: (item: T) => string | number | Date | null | undefined, + now: number = Date.now() +): ChatDateGroup[] { + const groups = new Map & { rank: number }>(); + for (const item of items) { + const bucket = chatDateBucket(timeOf(item), now); + const existing = groups.get(bucket.key); + if (existing) { + existing.items.push(item); + } else { + groups.set(bucket.key, { + key: bucket.key, + label: bucket.label, + rank: bucket.rank, + items: [item], + }); + } + } + return [...groups.values()] + .sort((left, right) => left.rank - right.rank) + .map(({ key, label, items: groupItems }) => ({ key, label, items: groupItems })); +} diff --git a/ui/desktop/src/utils/composerCommand.ts b/ui/desktop/src/utils/composerCommand.ts index 4161d772d..d483de1b7 100644 --- a/ui/desktop/src/utils/composerCommand.ts +++ b/ui/desktop/src/utils/composerCommand.ts @@ -23,6 +23,8 @@ * that adds a character the user did not type moves the caret. */ +import { COMPOSER_COPY } from '../components/composer/copy'; + export interface ChipCommandDef { /** What the chip reads as. */ label: string; @@ -32,13 +34,12 @@ export interface ChipCommandDef { placeholder: string; } -/** The commands drawn as a chip, keyed by name (no leading `/`). */ +/** + * The commands drawn as a chip, keyed by name (no leading `/`). The words live + * in the composer's copy registry (`COMPOSER_COPY.chipCommands`). + */ export const CHIP_COMMANDS = { - bug: { - label: 'Report a bug', - title: '/bug: Biorouter investigates this chat and drafts an issue for you to approve', - placeholder: 'Describe what went wrong (optional)', - }, + bug: COMPOSER_COPY.chipCommands.bug, } as const satisfies Record; export type ChipCommand = keyof typeof CHIP_COMMANDS; diff --git a/ui/desktop/src/utils/dateUtils.test.ts b/ui/desktop/src/utils/dateUtils.test.ts new file mode 100644 index 000000000..218eb6def --- /dev/null +++ b/ui/desktop/src/utils/dateUtils.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '../api'; +import { groupByChatDate } from './chatDateBuckets'; +import { formatChatDay, formatChatTimestamp, groupSessionsByDate } from './dateUtils'; + +const NOW = new Date(2026, 9, 9, 15, 0, 0).getTime(); // 9 Oct 2026, 15:00 local + +function session(id: string, updated: Date): Session { + return { + id, + name: id, + working_dir: '/tmp', + created_at: updated.toISOString(), + updated_at: updated.toISOString(), + message_count: 1, + extension_data: {}, + } as Session; +} + +describe('groupSessionsByDate', () => { + it('uses the coarse buckets the sidebar uses, newest first', () => { + const groups = groupSessionsByDate( + [ + session('a', new Date(2026, 9, 9, 9)), + session('b', new Date(2026, 9, 8, 23, 50)), + session('c', new Date(2026, 9, 5)), + session('d', new Date(2026, 8, 20)), + session('e', new Date(2026, 7, 2)), + session('f', new Date(2025, 7, 2)), + ], + NOW + ); + expect(groups.map((group) => group.label)).toEqual([ + 'Today', + 'Yesterday', + 'Previous 7 days', + 'Previous 30 days', + 'August', + 'August 2025', + ]); + expect(groups.map((group) => group.sessions.map((s) => s.id))).toEqual([ + ['a'], + ['b'], + ['c'], + ['d'], + ['e'], + ['f'], + ]); + }); + + it('keeps the arrival order inside a bucket and never makes one heading per day', () => { + const groups = groupSessionsByDate( + [ + session('x', new Date(2026, 9, 6)), + session('y', new Date(2026, 9, 4)), + session('z', new Date(2026, 9, 3)), + ], + NOW + ); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe('week'); + expect(groups[0].sessions.map((s) => s.id)).toEqual(['x', 'y', 'z']); + }); + + /** + * A store of 5,500 chats, most of them older than 30 days (vision V1-R3-2). Seeded, so a + * failure reproduces; instants straddle midnight, the DST changes, the year line, the + * future, and values that are not dates. + */ + function manyChats(count: number): Session[] { + let seed = 20261011; + const random = () => { + seed = (seed * 1_664_525 + 1_013_904_223) % 4_294_967_296; + return seed / 4_294_967_296; + }; + return Array.from({ length: count }, (_, index) => { + const roll = random(); + if (roll < 0.01) return { ...session(`bad-${index}`, new Date(NOW)), updated_at: 'never' }; + const hoursAgo = roll < 0.15 ? random() * 24 * 30 : 24 * 30 + random() * 24 * 450; + const instant = new Date(NOW - hoursAgo * 3_600_000 + (roll > 0.995 ? 6 * 3_600_000 : 0)); + return session(`chat-${index}`, instant); + }); + } + + it('draws the same groups as the sidebar helper, whatever the instants', () => { + const chats = manyChats(3000); + // The date the grouping reads, then the dates a day on either side of "now". + for (const now of [NOW, NOW + 20 * 3_600_000, new Date(2026, 2, 8, 2, 30).getTime()]) { + const expected = groupByChatDate(chats, (chat) => chat.updated_at, now).map( + ({ key, label, items }) => ({ key, label, sessions: items }) + ); + expect(groupSessionsByDate(chats, now)).toEqual(expected); + } + }); + + it('asks for a date label once per calendar day, not once per chat', () => { + // `chatDateBucket` builds an Intl.DateTimeFormat for every day older than 30; per chat + // that was 115 ms of the time History took to draw 5,500 rows. + const chats = manyChats(5500); + const days = new Set( + chats.map((chat) => { + const date = new Date(chat.updated_at); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + }) + ); + // Counted by a subclass: a spy on the constructor would not build the formatter. + const Real = Intl.DateTimeFormat; + let constructed = 0; + class Counting extends Real { + constructor(...args: ConstructorParameters) { + super(...args); + constructed += 1; + } + } + Intl.DateTimeFormat = Counting as typeof Intl.DateTimeFormat; + try { + groupSessionsByDate(chats, NOW); + } finally { + Intl.DateTimeFormat = Real; + } + expect(constructed).toBeLessThanOrEqual(days.size); + expect(constructed).toBeLessThan(chats.length / 5); + }); + + it('puts every unreadable date in one Older group, last', () => { + const groups = groupSessionsByDate( + [ + { ...session('a', new Date(NOW)), updated_at: '' }, + session('b', new Date(2026, 9, 9, 9)), + { ...session('c', new Date(NOW)), updated_at: 'sometime' }, + ], + NOW + ); + expect(groups.map((group) => [group.label, group.sessions.map((s) => s.id)])).toEqual([ + ['Today', ['b']], + ['Older', ['a', 'c']], + ]); + }); +}); + +/** ICU may put a narrow no-break space before AM/PM; the words are what is pinned. */ +const plain = (text: string) => text.replace(/\s/g, ' '); + +describe('formatChatDay', () => { + it('reads the time for today, the day this year, and the day with its year before', () => { + expect(plain(formatChatDay(new Date(2026, 9, 9, 14, 39).toISOString(), NOW))).toBe('2:39 PM'); + expect(formatChatDay(new Date(2026, 9, 8, 23, 50).toISOString(), NOW)).toBe('Oct 8'); + expect(formatChatDay(new Date(2026, 8, 5, 10, 49).toISOString(), NOW)).toBe('Sep 5'); + expect(formatChatDay(new Date(2025, 8, 5, 10, 49).toISOString(), NOW)).toBe('Sep 5, 2025'); + }); + + it('never prints a numeric US date, and says nothing for a value that is not a date', () => { + expect(formatChatDay(new Date(2026, 8, 5, 10, 49).toISOString(), NOW)).not.toMatch(/\//); + expect(formatChatDay('not a date', NOW)).toBe(''); + }); +}); + +describe('formatChatTimestamp', () => { + it('gives the full stamp in the same words, from an ISO string or epoch milliseconds', () => { + const when = new Date(2026, 8, 5, 10, 49); + expect(plain(formatChatTimestamp(when.toISOString()))).toBe('Sep 5, 2026, 10:49 AM'); + expect(plain(formatChatTimestamp(when.getTime()))).toBe('Sep 5, 2026, 10:49 AM'); + expect(formatChatTimestamp('not a date')).toBe(''); + }); +}); diff --git a/ui/desktop/src/utils/dateUtils.ts b/ui/desktop/src/utils/dateUtils.ts index 1902e057e..ef53dd31b 100644 --- a/ui/desktop/src/utils/dateUtils.ts +++ b/ui/desktop/src/utils/dateUtils.ts @@ -1,66 +1,102 @@ import { Session } from '../api'; +import { chatDateBucket, type ChatDateBucket } from './chatDateBuckets'; export interface DateGroup { + /** Stable bucket key (`today`, `yesterday`, `week`, `month30`, `2026-08`, `unknown`). */ + key: string; + /** Sentence-case label: Today, Yesterday, Previous 7 days, Previous 30 days, a month. */ label: string; sessions: Session[]; - date: Date; } -export function groupSessionsByDate(sessions: Session[]): DateGroup[] { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const groups: { [key: string]: DateGroup } = {}; - - sessions.forEach((session) => { - const sessionDate = new Date(session.updated_at); - const sessionDateStart = new Date(sessionDate); - sessionDateStart.setHours(0, 0, 0, 0); - - let label: string; - let groupKey: string; - - if (sessionDateStart.getTime() === today.getTime()) { - label = 'Today'; - groupKey = 'today'; - } else if (sessionDateStart.getTime() === yesterday.getTime()) { - label = 'Yesterday'; - groupKey = 'yesterday'; +/** + * Chat history's date groups: the same coarse buckets the sidebar uses + * (`chatDateBuckets.ts`), so a chat sits under the same heading in both places. + * + * This replaced one heading per day in a third format ("Tuesday, October 7"), + * which made a long history a column of near-identical headings. Buckets come + * newest first; inside one, sessions keep the order they arrive in (the list + * route's `updated_at` descending). + * + * ⚠ The bucket is asked for once per LOCAL CALENDAR DAY, not once per chat. A bucket + * is a function of the day alone (how many days ago, and the month's name), and for a + * day older than 30 `chatDateBucket` builds an `Intl.DateTimeFormat` for that name, + * about 25 µs each. A store of 5,500 chats has about 4,700 such rows, which was 115 ms + * of the 0.7 s History took to draw its rows (vision V1-R3-2, measured in Chromium: + * 310 ms from the list landing to the first row, 4x that on a busy machine). Per day + * it is a few hundred constructions, and the result is the same groups + * (`dateUtils.test.ts` holds it equal to `groupByChatDate` over random instants). + */ +export function groupSessionsByDate(sessions: Session[], now: number = Date.now()): DateGroup[] { + const bucketOfDay = new Map(); + const groups = new Map(); + for (const session of sessions) { + const timestamp = Date.parse(session.updated_at); + // NaN is its own key (Map compares with SameValueZero), so unreadable dates share + // the one 'unknown' bucket. + const date = new Date(timestamp); + const day = date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate(); + let bucket = bucketOfDay.get(day); + if (!bucket) { + bucket = chatDateBucket(timestamp, now); + bucketOfDay.set(day, bucket); + } + const group = groups.get(bucket.key); + if (group) { + group.sessions.push(session); } else { - // Format as "Monday, January 1" or "January 1" if it's not this year - const currentYear = today.getFullYear(); - const sessionYear = sessionDateStart.getFullYear(); - - if (sessionYear === currentYear) { - label = sessionDateStart.toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - }); - } else { - label = sessionDateStart.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); - } - groupKey = sessionDateStart.toISOString().split('T')[0]; + groups.set(bucket.key, { + key: bucket.key, + label: bucket.label, + rank: bucket.rank, + sessions: [session], + }); } + } + return [...groups.values()] + .sort((left, right) => left.rank - right.rank) + .map(({ key, label, sessions: groupSessions }) => ({ key, label, sessions: groupSessions })); +} - if (!groups[groupKey]) { - groups[groupKey] = { - label, - sessions: [], - date: sessionDateStart, - }; - } +const TIME_FORMAT = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }); +const DAY_FORMAT = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }); +const DAY_YEAR_FORMAT = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); - groups[groupKey].sessions.push(session); - }); +/** + * A chat's "when" on a row: the time for a chat from today ("3:39 PM"), the day + * for an older one this year ("Sep 5"), and the day with its year before that + * ("Sep 5, 2025"). The bucket heading above the row already names the period, + * so an older row's time of day was noise; the full stamp is in the row's + * tooltip ({@link formatChatTimestamp}). + * + * This replaced a US numeric date with a time ("09/05/2026 10:49 AM") under a + * "September" heading, beside today's rows in a second format (vision V1-L3). + * Returns '' for a value that is not a date. + */ +export function formatChatDay(value: string, now: number = Date.now()): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return ''; + const date = new Date(timestamp); + const today = new Date(now); + if (date.getFullYear() !== today.getFullYear()) return DAY_YEAR_FORMAT.format(date); + if (date.getMonth() === today.getMonth() && date.getDate() === today.getDate()) { + return TIME_FORMAT.format(date); + } + return DAY_FORMAT.format(date); +} - // Convert to array and sort by date (newest first) - return Object.values(groups).sort((a, b) => b.date.getTime() - a.date.getTime()); +/** + * The full stamp, in the same words as {@link formatChatDay}: "Sep 5, 2026, + * 10:49 AM". For a row's tooltip and a transcript's band. Returns '' for a value + * that is not a date. + */ +export function formatChatTimestamp(value: string | number): string { + const timestamp = typeof value === 'number' ? value : Date.parse(value); + if (Number.isNaN(timestamp)) return ''; + const date = new Date(timestamp); + return `${DAY_YEAR_FORMAT.format(date)}, ${TIME_FORMAT.format(date)}`; } diff --git a/ui/desktop/src/utils/exportConversation.test.ts b/ui/desktop/src/utils/exportConversation.test.ts new file mode 100644 index 000000000..2a4f2382c --- /dev/null +++ b/ui/desktop/src/utils/exportConversation.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + exportSession: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock('../api', () => ({ exportSession: mocks.exportSession })); +vi.mock('../toasts', () => ({ toastSuccess: mocks.toastSuccess, toastError: mocks.toastError })); +vi.mock('./userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + +import { chatRowCopy } from '../components/chats/copy'; +import { + EXPORT_FAILED_TOAST_TITLE, + chatExportFileName, + exportConversation, + exportFailureMessage, + savedFileParts, +} from './exportConversation'; +import { BROWSER_SURFACE_MARKER } from './surface'; + +const copy = chatRowCopy.export; + +describe('exportConversation', () => { + const showSaveDialog = vi.fn(); + const writeFile = vi.fn(); + let anchorClicks: string[]; + + beforeEach(() => { + mocks.exportSession.mockReset(); + mocks.toastSuccess.mockClear(); + mocks.toastError.mockClear(); + showSaveDialog.mockReset(); + writeFile.mockReset(); + URL.createObjectURL = vi.fn(() => 'blob:chat'); + URL.revokeObjectURL = vi.fn(); + anchorClicks = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + anchorClicks.push(this.download); + }); + Object.assign(window, { electron: { showSaveDialog, writeFile } }); + mocks.exportSession.mockResolvedValue({ data: '{"id":"s1"}' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete document.documentElement.dataset.biorouterSurface; + }); + + it('reads the chat with the user proof, saves it where the person chose, then says where', async () => { + showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/Users/me/Downloads/Cohort pull.json', + }); + writeFile.mockResolvedValue(true); + + await expect(exportConversation('s1', 'Cohort pull')).resolves.toBe(true); + + expect(mocks.exportSession).toHaveBeenCalledWith({ + path: { session_id: 's1' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }); + expect(showSaveDialog).toHaveBeenCalledWith({ + title: copy.dialogTitle, + defaultPath: 'Cohort pull.json', + filters: [{ name: copy.fileType, extensions: ['json'] }], + }); + expect(writeFile).toHaveBeenCalledWith('/Users/me/Downloads/Cohort pull.json', '{"id":"s1"}'); + expect(mocks.toastSuccess).toHaveBeenCalledWith({ + title: copy.saved, + msg: 'Saved "Cohort pull.json" to Downloads.', + }); + expect(anchorClicks, 'no browser download in the app').toEqual([]); + }); + + /** + * Vision V4-R5-2: the toast said "was downloaded" while the Save window was still open, and + * stayed after Cancel. Nothing is announced until the file is written, and a cancel says nothing. + */ + it('announces nothing until the file is written', async () => { + let choose: (value: { canceled: boolean; filePath?: string }) => void = () => {}; + showSaveDialog.mockReturnValue(new Promise((resolve) => (choose = resolve))); + writeFile.mockResolvedValue(true); + + const done = exportConversation('s1', 'Cohort pull'); + await vi.waitFor(() => expect(showSaveDialog).toHaveBeenCalled()); + expect(mocks.toastSuccess, 'nothing while the Save window is open').not.toHaveBeenCalled(); + + choose({ canceled: true }); + await expect(done).resolves.toBe(false); + expect(writeFile).not.toHaveBeenCalled(); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it('says the file was not saved when the write fails', async () => { + showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/Volumes/Gone/Cohort.json' }); + writeFile.mockResolvedValue(false); + + await expect(exportConversation('s1', 'Cohort', { scope: 'screen' })).resolves.toBe(false); + + expect(mocks.toastError).toHaveBeenCalledWith({ + title: EXPORT_FAILED_TOAST_TITLE, + msg: copy.writeFailed('Gone'), + scope: 'screen', + }); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + }); + + it('hands the file to the browser as a download on a served page, and claims nothing', async () => { + document.documentElement.dataset.biorouterSurface = BROWSER_SURFACE_MARKER; + + await expect(exportConversation('s1', 'Cohort pull')).resolves.toBe(true); + + expect(anchorClicks).toEqual(['Cohort pull.json']); + expect(showSaveDialog, 'the shim would save on the server').not.toHaveBeenCalled(); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + }); + + it("shows the daemon's sentence when the export is refused", async () => { + const refusal = 'Crew context cannot be exported without its channel permissions.'; + mocks.exportSession.mockRejectedValue(refusal); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(exportConversation('s1', 'Cohort pull', { scope: 'screen' })).resolves.toBe(false); + + expect(mocks.toastError).toHaveBeenCalledWith({ + title: EXPORT_FAILED_TOAST_TITLE, + msg: refusal, + scope: 'screen', + }); + expect(showSaveDialog, 'no Save window for a refused read').not.toHaveBeenCalled(); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + }); + + it('keeps an app-scoped toast when asked', async () => { + mocks.exportSession.mockRejectedValue(new Error('offline')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + await exportConversation('s1', 'Cohort pull', { scope: 'app' }); + expect(mocks.toastError).toHaveBeenCalledWith({ + title: EXPORT_FAILED_TOAST_TITLE, + msg: 'offline', + }); + }); +}); + +describe('chatExportFileName', () => { + it('keeps a plain name and adds .json', () => { + expect(chatExportFileName('Cohort pull')).toBe('Cohort pull.json'); + }); + + it('makes one safe file name of any chat name', () => { + // A slash would name a folder in the Save window; the rest are refused on Windows. + expect(chatExportFileName('Scheduled job: a/b\\c *?"<>|')).toBe( + 'Scheduled job- a-b-c ------.json' + ); + expect(chatExportFileName('..hidden')).toBe('hidden.json'); + expect(chatExportFileName(' line\none ')).toBe('lineone.json'); + expect(chatExportFileName(' ')).toBe(`${copy.untitled}.json`); + expect(chatExportFileName('x'.repeat(300))).toBe(`${'x'.repeat(120)}.json`); + }); +}); + +describe('savedFileParts', () => { + it('names the file and its folder', () => { + expect(savedFileParts('/Users/me/Downloads/Cohort.json')).toEqual({ + file: 'Cohort.json', + folder: 'Downloads', + }); + expect(savedFileParts('C:\\Users\\me\\Desktop\\Cohort.json')).toEqual({ + file: 'Cohort.json', + folder: 'Desktop', + }); + }); +}); + +describe('exportFailureMessage', () => { + it('falls back to a sentence when the failure carries no words', () => { + expect(exportFailureMessage({})).toBe( + 'The chat could not be read for export. Nothing was downloaded.' + ); + }); +}); diff --git a/ui/desktop/src/utils/exportConversation.ts b/ui/desktop/src/utils/exportConversation.ts new file mode 100644 index 000000000..ddc5f1b0e --- /dev/null +++ b/ui/desktop/src/utils/exportConversation.ts @@ -0,0 +1,152 @@ +import { exportSession } from '../api'; +import { chatRowCopy } from '../components/chats/copy'; +import { toastError, toastSuccess } from '../toasts'; +import { isBrowserSurface } from './surface'; +import { userActionHeaders } from './userAction'; + +const copy = chatRowCopy.export; + +/** The title of the toast a failed export shows; its message says why. */ +export const EXPORT_FAILED_TOAST_TITLE = copy.failed; + +/** + * What to tell the person when an export fails. + * + * A refusal from the daemon arrives as its plain-text body (the generated + * client throws the parsed body under `throwOnError`), and that sentence is + * written for a person: it says why and what to do instead. Anything else (an + * empty 404 body, a network error) gets a sentence of its own rather than a + * raw status or `[object Object]`. + */ +export function exportFailureMessage(error: unknown): string { + if (typeof error === 'string' && error.trim()) return error.trim(); + if (error instanceof Error && error.message.trim()) return error.message.trim(); + return copy.readFailed; +} + +/** + * The file name an export suggests: the chat's name, made safe as one file + * name on every system (no folder separators, nothing Windows refuses, no + * leading dot that would hide it), then `.json`. + */ +export function chatExportFileName(name: string): string { + const base = name + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[/\\:*?"<>|]/g, '-') + .replace(/\s+/g, ' ') + .trim() + .replace(/^\.+/, '') + .slice(0, 120) + .trim(); + return `${base || copy.untitled}.json`; +} + +/** The saved file's name and the name of the folder it went to, for the toast. */ +export function savedFileParts(filePath: string): { file: string; folder: string } { + const parts = filePath.split(/[\\/]+/).filter(Boolean); + const file = parts.pop() ?? filePath; + return { file, folder: parts.pop() ?? filePath }; +} + +export interface ExportConversationOptions { + /** + * Which toasts go when the person leaves the screen. History's export is + * about that screen's row, so it passes `'screen'` (T3-SH-11); the sidebar is + * on every screen, so its toasts are app-scoped. + */ + scope?: 'screen' | 'app'; +} + +/** + * Export one chat as JSON, named after the chat. The one export path, shared by + * History and the sidebar row menu. + * + * The read carries the user's proof, like every read of a chat's transcript: + * the export route refuses a private chat to a caller without it. A refusal is + * shown as the daemon's own sentence. + * + * In the app the file is saved through the main process: its Save window, then + * a write of the file, and only a written file is announced, naming the file + * and its folder (vision V4-R5-2). A Save window the person cancels says + * nothing. It used to be a browser download, which in Electron opens the same + * Save window but tells the page nothing, so "was downloaded" showed while the + * window was still open and stayed after a cancel. + * + * In a browser (`biorouter serve`) the app has no Save window of its own (the + * shim's would write on the server), so the file is handed to the browser as a + * download, and the browser's own download list says whether and where it was + * saved; the page cannot know, so it claims nothing. + * + * @returns true when the file was written (or handed to the browser), false + * when it was not: the read failed or the write failed (a toast has said + * why), or the person cancelled the Save window. + */ +export async function exportConversation( + sessionId: string, + name: string, + options: ExportConversationOptions = {} +): Promise { + const scope = options.scope === 'app' ? undefined : options.scope; + let json: string; + try { + const response = await exportSession({ + path: { session_id: sessionId }, + headers: await userActionHeaders(), + throwOnError: true, + }); + json = response.data; + } catch (error) { + console.error('Failed to export session:', error); + toastError({ + title: EXPORT_FAILED_TOAST_TITLE, + msg: exportFailureMessage(error), + ...(scope ? { scope } : {}), + }); + return false; + } + + const fileName = chatExportFileName(name); + const electron = window.electron; + if (isBrowserSurface() || !electron?.showSaveDialog || !electron?.writeFile) { + downloadInBrowser(json, fileName); + return true; + } + + const choice = await electron.showSaveDialog({ + title: copy.dialogTitle, + defaultPath: fileName, + filters: [{ name: copy.fileType, extensions: ['json'] }], + }); + if (choice.canceled || !choice.filePath) return false; + + const { file, folder } = savedFileParts(choice.filePath); + let written = false; + try { + written = await electron.writeFile(choice.filePath, json); + } catch (error) { + console.error('Failed to write the exported chat:', error); + } + if (!written) { + toastError({ + title: EXPORT_FAILED_TOAST_TITLE, + msg: copy.writeFailed(folder), + ...(scope ? { scope } : {}), + }); + return false; + } + toastSuccess({ title: copy.saved, msg: copy.savedTo(file, folder) }); + return true; +} + +function downloadInBrowser(json: string, fileName: string) { + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} diff --git a/ui/desktop/src/utils/extensionUpdater.test.ts b/ui/desktop/src/utils/extensionUpdater.test.ts index cc4a8a80e..247f284a7 100644 --- a/ui/desktop/src/utils/extensionUpdater.test.ts +++ b/ui/desktop/src/utils/extensionUpdater.test.ts @@ -112,13 +112,22 @@ async function seedExtension(extensionsDir: string, name: string) { let originalPathRoot: string | undefined; let originalXdgConfigHome: string | undefined; +let originalAppData: string | undefined; +let originalLocalAppData: string | undefined; let redirectRoot: string; beforeAll(() => { originalPathRoot = process.env.BIOROUTER_PATH_ROOT; originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalAppData = process.env.APPDATA; + originalLocalAppData = process.env.LOCALAPPDATA; }); +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + beforeEach(async () => { sentEvents.length = 0; delete process.env.BIOROUTER_PATH_ROOT; @@ -128,6 +137,11 @@ beforeEach(async () => { delete process.env.XDG_CONFIG_HOME; fakeHome = await fs.mkdtemp(path.join(TMP_BASE, 'br-extupd-home-')); redirectRoot = await fs.mkdtemp(path.join(TMP_BASE, 'br-extupd-root-')); + // R-31: on Windows the resolver reads both bases (and the folder earlier + // releases used under each). Point them into the fake home, so no test here + // ever looks at the developer's real profile, whatever it unsets. + process.env.APPDATA = path.join(fakeHome, 'AppData', 'Roaming'); + process.env.LOCALAPPDATA = path.join(fakeHome, 'AppData', 'Local'); }); afterEach(async () => { @@ -135,6 +149,8 @@ afterEach(async () => { else process.env.BIOROUTER_PATH_ROOT = originalPathRoot; if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + restoreEnv('APPDATA', originalAppData); + restoreEnv('LOCALAPPDATA', originalLocalAppData); await fs.rm(fakeHome, { recursive: true, force: true }); await fs.rm(redirectRoot, { recursive: true, force: true }); }); @@ -228,20 +244,38 @@ describe('extension updater directory resolution (#146)', () => { */ it.runIf(IS_WINDOWS)('uses the Windows layout on Windows', async () => { const appData = await fs.mkdtemp(path.join(TMP_BASE, 'br-extupd-appdata-')); - const originalAppData = process.env.APPDATA; try { process.env.APPDATA = appData; const { biorouterExtensionsDir } = await loadUpdater(); expect(biorouterExtensionsDir()).toBe( - path.join(appData, 'Block', 'biorouter', 'config', 'extensions') + path.join(appData, 'UCSF', 'biorouter', 'config', 'extensions') ); } finally { - if (originalAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = originalAppData; await fs.rm(appData, { recursive: true, force: true }); } }); + /** + * R-31. Resolving never touches either Windows base: the folder earlier + * releases used stays exactly where it is, and nothing is created beside + * it. (The desktop used to move it on the first resolution, so this suite + * moved a developer's real profile on Windows.) Runs on every OS through + * the Windows branch the suite can reach, `platformConfigDirFor`, against + * real directories. + */ + it('resolving the Windows layout moves nothing', async () => { + const roaming = path.join(fakeHome, 'AppData', 'Roaming'); + const { APP_AUTHOR, APP_NAME, LEGACY_APP_AUTHOR } = await import('./appFolder'); + const legacyConfig = path.join(roaming, LEGACY_APP_AUTHOR, APP_NAME, 'config'); + await fs.mkdir(legacyConfig, { recursive: true }); + const { platformConfigDirFor } = await import('./biorouterPaths'); + expect(platformConfigDirFor({ platform: 'win32', env: process.env, homedir: fakeHome })).toBe( + legacyConfig + ); + expect(existsSync(legacyConfig)).toBe(true); + expect(existsSync(path.join(roaming, APP_AUTHOR))).toBe(false); + }); + /** * The empty-string decision, and the reason it is not cosmetic. * diff --git a/ui/desktop/src/utils/findClearance.browser.test.ts b/ui/desktop/src/utils/findClearance.browser.test.ts new file mode 100644 index 000000000..d9afbb571 --- /dev/null +++ b/ui/desktop/src/utils/findClearance.browser.test.ts @@ -0,0 +1,569 @@ +// @vitest-environment node +/** + * Find keeps the match it shows clear of its own bar, measured in a real layout + * engine (vision V2-R5-4). + * + * The bar floats over the top of the transcript (`.br-find-overlay`, 8px under + * the cell top, 16px in from its right edge). A scroller cannot scroll above its + * own top, so a match in the first lines of a chat, which is where "kdps" sat in + * "Build kdps R package" (84..100 behind a bar ending at 94), stayed under the + * bar however the target was computed. jsdom lays nothing out, so this holds + * the rules where they are decided: the real `searchHighlighter.ts` (transpiled + * and run as a classic script, like `searchHighlighter.browser.test.ts`) and + * the real `styles/search.css` positioning the bar, over markup shaped like + * BaseChat's transcript cell. + * + * What it pins, each with the control that shows the instrument can see it: + * - the first match under the bar ends up below it, 8px clear (control: without + * a clearance it stays under it, which is the finding); + * - the room is made in a way nobody sees: the text is where it was the instant + * the inset lands, and opening the bar made none (lazy); + * - a match the bar does not cover moves nothing; a centred one centres below + * the bar; + * - closing gives the room back, unseen where the reader has scrolled past it + * and as a glide where they have not. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Browser, type Page } from 'playwright'; +import ts from 'typescript'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SEARCH_CSS = readFileSync(resolve(here, '../styles/search.css'), 'utf8'); + +const launchChromium = async (): Promise => { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { + try { + return await chromium.launch(options); + } catch { + // try the next build + } + } + return null; +}; + +/** `searchHighlighter.ts` as a classic script that sets `SearchHighlighter` and `FindClearance`. */ +const highlighterScript = (): string => { + const source = readFileSync(resolve(here, 'searchHighlighter.ts'), 'utf-8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }); + return ( + `(() => { const exports = {}; ${outputText}; ` + + `window.SearchHighlighter = exports.SearchHighlighter; ` + + `window.FindClearance = exports.FindClearance; })();` + ); +}; + +const TOKENS = `:root { + --dur-fast: 125ms; + --dur-fast-max: 175ms; + --ease-out: cubic-bezier(0.24, 1, 0.4, 1); + --icon-row: 16px; + --text-secondary: 13px; + --text-secondary--line-height: 18px; + --text-supporting: 12px; + --text-supporting--line-height: 16px; + --border-subtle: rgb(200, 200, 200); + --background-default: rgb(255, 255, 255); + --background-medium: rgb(235, 235, 235); + --text-default: rgb(20, 20, 20); + --text-muted: rgb(110, 110, 110); + --font-sans: Arial, sans-serif; +} +@keyframes br-fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes br-fade-out { from { opacity: 1; } to { opacity: 0; } } +* { box-sizing: border-box; } +body { margin: 0; padding-top: 44px; font: 14px/20px Arial, sans-serif; } +/* BaseChat's cell: 44px under the window top (the band), the bar 8px under it. */ +.cell { position: relative; overflow: hidden; width: 1100px; height: 600px; } +.viewport { height: 100%; width: 100%; overflow-y: scroll; } +.wrap { padding: 0 24px; } +.column { max-width: 760px; margin: 0 auto; padding-top: 20px; } +.user { text-align: right; padding: 12px 16px; height: 60px; } +.left { text-align: left; } +.message { height: 120px; padding: 12px 16px; border-bottom: 1px solid #eee; } +.bar { width: 100%; height: 42px; border: 1px solid rgb(200, 200, 200); + border-radius: 12px; background: #fff; }`; + +const MIN_GAP = 8; + +interface SceneOptions { + /** `right`: the first message's match sits under the bar's span; `left`: clear of it. */ + firstAt?: 'right' | 'left'; + /** How many 120px messages follow the first one. */ + messages?: number; + /** `none`: no bar in the cell at all. */ + bar?: boolean; +} + +const scene = ({ firstAt = 'right', messages = 20, bar = true }: SceneOptions = {}) => + `` + + `
      ` + + `
      ` + + `
      Build the R package at github.com/UCSD/kdps
      ` + + Array.from( + { length: messages }, + (_, i) => + `
      Message ${i}${i === 9 ? ' mentions kdps here' : ''}
      ` + ).join('') + + `
      ` + + (bar + ? `
      ` + : '') + + `
      `; + +type Box = { top: number; bottom: number; left: number; right: number }; + +describe('find keeps the current match clear of its bar', () => { + let browser: Browser | null = null; + let script = ''; + + beforeAll(async () => { + browser = await launchChromium(); + if (!browser) { + console.warn( + 'No Chromium found, skipping the find clearance geometry tests. ' + + 'Run `npx playwright install chromium` to enable them.' + ); + } + script = highlighterScript(); + }, 180_000); + + afterAll(async () => { + await browser?.close(); + }, 120_000); + + const withScene = async ( + options: SceneOptions, + run: (page: Page) => Promise, + reducedMotion: 'reduce' | 'no-preference' = 'no-preference' + ) => { + const page = await browser!.newPage({ + viewport: { width: 1100, height: 700 }, + reducedMotion, + }); + try { + await page.setContent(scene(options)); + await page.addScriptTag({ content: script }); + // The bar's entrance, over, so it is measured at rest. + await page.waitForTimeout(250); + await run(page); + } finally { + await page.close(); + } + }; + + /** Start a search for `kdps` the way `SearchView` does (a highlighter, the session's clearance). */ + const startSearch = (page: Page, { clearance = true } = {}) => + page.evaluate((withClearance) => { + const w = window as unknown as { + SearchHighlighter: new ( + el: HTMLElement, + onChange?: (count: number) => void, + clearance?: unknown + ) => { + highlight(term: string): HTMLElement[]; + setCurrentMatch(index: number, shouldScroll?: boolean): void; + }; + FindClearance: new (bar: () => Element | null) => unknown; + __h: unknown; + __clearance: unknown; + }; + const clearance = withClearance + ? new w.FindClearance(() => document.getElementById('overlay')) + : undefined; + w.__clearance = clearance ?? null; + const highlighter = new w.SearchHighlighter( + document.getElementById('content')!, + undefined, + clearance + ); + w.__h = highlighter; + return highlighter.highlight('kdps').length; + }, clearance); + + /** + * Navigate to match `index` as the bar's arrows do, then wait until the + * scroll position has held still for 150ms. Not `scrollend`: making room is an + * instant scroll of its own, and its `scrollend` arrives while the smooth + * scroll that follows is still going. + */ + const goTo = async (page: Page, index: number) => { + await page.evaluate( + (i) => + new Promise((resolveRun) => { + const w = window as unknown as { + __h: { setCurrentMatch(index: number, shouldScroll?: boolean): void }; + }; + const viewport = document.getElementById('viewport')!; + w.__h.setCurrentMatch(i, true); + const startedAt = performance.now(); + let last = viewport.scrollTop; + let since = startedAt; + const tick = () => { + const now = performance.now(); + if (viewport.scrollTop !== last) { + last = viewport.scrollTop; + since = now; + } + if (now - since > 150 || now - startedAt > 1500) resolveRun(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), + index + ); + }; + + const box = (page: Page, selector: string): Promise => + page.evaluate((sel) => { + const r = document.querySelector(sel)!.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; + }, selector); + + const viewportState = (page: Page) => + page.evaluate(() => { + const viewport = document.getElementById('viewport')!; + return { + scrollTop: viewport.scrollTop, + paddingTop: getComputedStyle(viewport).paddingTop, + inlinePaddingTop: viewport.style.paddingTop, + }; + }); + + // The finding, exactly: the first match is in the first lines of the chat and + // under the bar's span, the scroller is at its top. + it('control: without a clearance the first match stays behind the bar', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + expect(await startSearch(page, { clearance: false })).toBe(2); + await goTo(page, 0); + const bar = await box(page, '#bar'); + const mark = await box(page, '.search-highlight.current'); + // Behind the bar: it starts above the bar's lower edge. (The instrument.) + expect(mark.top).toBeLessThan(bar.bottom); + expect(mark.bottom).toBeGreaterThan(bar.top); + expect((await viewportState(page)).scrollTop).toBe(0); + }); + }); + + it('puts the first match below the bar, with the gap, where it could not scroll', async (ctx) => { + if (!browser) return ctx.skip(); + for (const motion of ['no-preference', 'reduce'] as const) { + await withScene( + {}, + async (page) => { + await startSearch(page); + await goTo(page, 0); + const bar = await box(page, '#bar'); + const mark = await box(page, '.search-highlight.current'); + expect(mark.top).toBeGreaterThanOrEqual(bar.bottom + MIN_GAP - 0.5); + + // The mark is still painted over its own text, not over the shifted one. + const text = await page.evaluate(() => { + const range = document.createRange(); + const node = document.getElementById('first')!.firstChild as Text; + const at = node.data.indexOf('kdps'); + range.setStart(node, at); + range.setEnd(node, at + 4); + const r = range.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom }; + }); + expect(Math.abs(mark.top - text.top)).toBeLessThan(1); + expect(Math.abs(mark.bottom - text.bottom)).toBeLessThan(1); + + // The room was made above the content, only as much as it took: the + // first line was 84..100, 8px clear of a bar ending at 94 is 102. + const state = await viewportState(page); + expect(state.scrollTop).toBe(0); + const inset = parseFloat(state.paddingTop); + expect(inset).toBeGreaterThan(0); + expect(inset).toBeLessThan(50); + }, + motion + ); + } + }); + + // Making room is an instant scroll, and its `scrollend` is dispatched after + // the smooth scroll that follows has been asked for. If the highlighter took + // that first `scrollend` for the end of its own scroll it would start + // re-measuring every mark on each frame of the glide it is still running. + it('keeps ignoring its own scroll until the smooth scroll has landed', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + const run = await page.evaluate( + () => + new Promise<{ heldFrames: number; releasedAt: number | null; scrolled: boolean }>( + (resolveRun) => { + const w = window as unknown as { + __h: { + setCurrentMatch(index: number, shouldScroll?: boolean): void; + isScrollingProgrammatically: boolean; + }; + }; + const viewport = document.getElementById('viewport')!; + w.__h.setCurrentMatch(0, true); + let heldFrames = 0; + let scrolled = false; + const startedAt = performance.now(); + const tick = () => { + if (viewport.scrollTop > 1) scrolled = true; + if (!w.__h.isScrollingProgrammatically) { + resolveRun({ heldFrames, releasedAt: viewport.scrollTop, scrolled }); + return; + } + heldFrames += 1; + if (performance.now() - startedAt > 2000) { + resolveRun({ heldFrames, releasedAt: null, scrolled }); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + } + ) + ); + // It scrolled down the room it made (so the smooth scroll really ran), and + // the hold was let go only with the scroller at its landing. + expect(run.scrolled).toBe(true); + expect(run.releasedAt).not.toBeNull(); + expect(run.releasedAt!).toBeLessThanOrEqual(1); + expect(run.heldFrames).toBeGreaterThan(3); + }); + }); + + it('makes no room until a match needs it: opening the bar moves nothing', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + const before = await box(page, '#first'); + await startSearch(page); + const after = await box(page, '#first'); + expect(after.top).toBe(before.top); + expect((await viewportState(page)).inlinePaddingTop).toBe(''); + }); + }); + + // The text is where it was in the frame the inset lands; the scroll that + // follows is what moves it, and that is the usual smooth one. + it('holds the text still when the room is made, wherever the reader is scrolled', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + for (const scrollTop of [0, 130]) { + const moved = await page.evaluate((top) => { + const w = window as unknown as { + __clearance: { makeRoom(scroller: Element, by: number, reference: Element): boolean }; + }; + const viewport = document.getElementById('viewport')!; + viewport.scrollTop = top; + const content = document.getElementById('content')!; + const at = viewport.scrollTop; + const before = content.getBoundingClientRect().top; + const grew = w.__clearance.makeRoom(viewport, 26, content); + return { + grew, + moved: content.getBoundingClientRect().top - before, + scrolledBy: viewport.scrollTop - at, + }; + }, scrollTop); + expect(moved.grew).toBe(true); + // Absorbed by the scroll position, at the very top too (the padding is + // scrollable room above the content): nothing on screen moved. + expect(Math.abs(moved.moved)).toBeLessThan(0.5); + expect(moved.scrolledBy).toBeCloseTo(26, 0); + await page.evaluate(() => { + (window as unknown as { __clearance: { release(): void } }).__clearance.release(); + }); + } + }); + }); + + it('moves nothing for a match the bar does not cover', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({ firstAt: 'left' }, async (page) => { + await startSearch(page); + const bar = await box(page, '#bar'); + const mark0 = await box(page, '.search-highlight.current'); + // The control: it IS in the bar's vertical band, just not under it. + expect(mark0.top).toBeLessThan(bar.bottom); + expect(mark0.right).toBeLessThan(bar.left); + await goTo(page, 0); + const state = await viewportState(page); + expect(state.inlinePaddingTop).toBe(''); + expect(state.scrollTop).toBe(0); + }); + }); + + it('centres a match in the part of the scroller below the bar', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + await goTo(page, 1); + const bar = await box(page, '#bar'); + const cell = await box(page, '#cell'); + const mark = await box(page, '.search-highlight.current'); + const freeTop = bar.bottom + MIN_GAP; + const centre = (mark.top + mark.bottom) / 2; + expect(centre).toBeCloseTo((freeTop + cell.bottom) / 2, 0); + expect(mark.top).toBeGreaterThanOrEqual(freeTop); + // A match in the middle of a chat needs no room above it. + expect((await viewportState(page)).inlinePaddingTop).toBe(''); + }); + }); + + it('only grows the room while the bar stays, and steps back to the first match cleanly', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + await goTo(page, 0); + const inset = parseFloat((await viewportState(page)).paddingTop); + await goTo(page, 1); + expect(parseFloat((await viewportState(page)).paddingTop)).toBe(inset); + await goTo(page, 0); + const bar = await box(page, '#bar'); + const mark = await box(page, '.search-highlight.current'); + expect(mark.top).toBeGreaterThanOrEqual(bar.bottom + MIN_GAP - 0.5); + expect(parseFloat((await viewportState(page)).paddingTop)).toBe(inset); + }); + }); + + it('gives the room back unseen when the reader has scrolled past it', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + await goTo(page, 0); + const inset = parseFloat((await viewportState(page)).paddingTop); + expect(inset).toBeGreaterThan(0); + + const run = await page.evaluate(() => { + const w = window as unknown as { __clearance: { release(): void } }; + const viewport = document.getElementById('viewport')!; + viewport.scrollTop = 500; + const probe = document.getElementById('m5')!; + const before = probe.getBoundingClientRect().top; + w.__clearance.release(); + return { + moved: probe.getBoundingClientRect().top - before, + inline: viewport.style.paddingTop, + padding: getComputedStyle(viewport).paddingTop, + }; + }); + expect(Math.abs(run.moved)).toBeLessThan(0.5); + expect(run.inline).toBe(''); + expect(run.padding).toBe('0px'); + }); + }); + + it('gives the room back as a glide when the text sits below its resting place', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({}, async (page) => { + await startSearch(page); + await goTo(page, 0); + const resting = (await box(page, '#cell')).top + 20; + + const frames = await page.evaluate( + () => + new Promise((resolveRun) => { + const w = window as unknown as { __clearance: { release(): void } }; + const first = document.getElementById('first')!; + const tops: number[] = [first.getBoundingClientRect().top]; + w.__clearance.release(); + tops.push(first.getBoundingClientRect().top); + const startedAt = performance.now(); + const tick = () => { + tops.push(first.getBoundingClientRect().top); + if (performance.now() - startedAt < 400) requestAnimationFrame(tick); + else resolveRun(tops); + }; + requestAnimationFrame(tick); + }) + ); + + // The first frame after the release is where the text was (no jump)... + expect(Math.abs(frames[1] - frames[0])).toBeLessThan(0.5); + // ...it ends in its resting place, 20px under the cell top... + expect(frames[frames.length - 1]).toBeCloseTo(resting, 0); + // ...and the way there is a glide: every step up, none of them the whole way. + const steps = frames.slice(1); + for (let i = 1; i < steps.length; i += 1) expect(steps[i]).toBeLessThanOrEqual(steps[i - 1]); + expect(steps.some((top) => top < steps[0] - 0.5 && top > resting + 0.5)).toBe(true); + + const state = await viewportState(page); + expect(state.inlinePaddingTop).toBe(''); + expect(state.paddingTop).toBe('0px'); + expect(await page.evaluate(() => document.getElementById('viewport')!.style.transition)).toBe( + '' + ); + }); + }); + + it('jumps to the resting place under reduced motion', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene( + {}, + async (page) => { + await startSearch(page); + await goTo(page, 0); + const resting = (await box(page, '#cell')).top + 20; + await page.evaluate(() => { + const w = window as unknown as { __clearance: { release(): void } }; + w.__clearance.release(); + }); + expect((await box(page, '#first')).top).toBeCloseTo(resting, 0); + expect((await viewportState(page)).inlinePaddingTop).toBe(''); + }, + 'reduce' + ); + }); + + it('makes the room in a chat shorter than its viewport, where nothing can scroll', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({ messages: 2 }, async (page) => { + await startSearch(page); + const room = await page.evaluate(() => { + const viewport = document.getElementById('viewport')!; + return viewport.scrollHeight - viewport.clientHeight; + }); + expect(room).toBeLessThanOrEqual(0); + + await goTo(page, 0); + const bar = await box(page, '#bar'); + const mark = await box(page, '.search-highlight.current'); + expect(mark.top).toBeGreaterThanOrEqual(bar.bottom + MIN_GAP - 0.5); + expect(parseFloat((await viewportState(page)).paddingTop)).toBeGreaterThan(0); + + // And closing puts it back where it was, still short of overflow. + const resting = (await box(page, '#cell')).top + 20; + await page.evaluate(() => { + const w = window as unknown as { __clearance: { release(): void } }; + w.__clearance.release(); + }); + await page.waitForTimeout(400); + expect((await box(page, '#first')).top).toBeCloseTo(resting, 0); + expect((await viewportState(page)).inlinePaddingTop).toBe(''); + }); + }); + + it('is a no-op when there is no bar over the transcript', async (ctx) => { + if (!browser) return ctx.skip(); + await withScene({ bar: false }, async (page) => { + await startSearch(page); + const before = await box(page, '#first'); + await goTo(page, 0); + expect((await box(page, '#first')).top).toBe(before.top); + expect((await viewportState(page)).inlinePaddingTop).toBe(''); + const barBox = await page.evaluate(() => { + const w = window as unknown as { + __clearance: { barBox(scroller: Element): unknown } | null; + }; + return w.__clearance?.barBox(document.getElementById('viewport')!) ?? null; + }); + expect(barBox).toBeNull(); + }); + }); +}); diff --git a/ui/desktop/src/utils/formatSupport.test.ts b/ui/desktop/src/utils/formatSupport.test.ts index 740a57fe2..ee5931b8e 100644 --- a/ui/desktop/src/utils/formatSupport.test.ts +++ b/ui/desktop/src/utils/formatSupport.test.ts @@ -95,7 +95,11 @@ describe('the fidelity ceiling is stated, not discovered', () => { 'utf8' ); expect(source).toContain('describeUnsupportedFormat'); - // The old one-size-fits-all sentence must only survive as the fallback. - expect(source).toContain('unsupported\n ? unsupported.reason'); + // The old one-size-fits-all sentence must only survive as the fallback: + // the false arm of the ternary whose true arm is the format's own reason. + // Whitespace-free, so a Prettier line break does not decide the verdict. + expect(source).toMatch( + /unsupported\s*\?\s*unsupported\.reason\s*:\s*PREVIEW_COPY\.binaryFallbackReason\b/ + ); }); }); diff --git a/ui/desktop/src/utils/homeInsightsCache.test.ts b/ui/desktop/src/utils/homeInsightsCache.test.ts index a2da8bb2b..c0db863ba 100644 --- a/ui/desktop/src/utils/homeInsightsCache.test.ts +++ b/ui/desktop/src/utils/homeInsightsCache.test.ts @@ -37,15 +37,41 @@ describe('homeInsightsCache', () => { expect(restoredCache.getCachedHomeActivity()).toEqual(activity); }); - it('ignores the retired recentSessions key in a persisted v1 blob', async () => { - // Blobs written before the Home recent-chats section was removed carry an - // extra key; reading them must neither crash nor resurrect it. + it('never reads a v1 blob, whose 155-day window would understate the year', async () => { + // v1 held a 155-day window (and, before that, a retired `recentSessions` + // list). Reading it would show `1y` figures for five months. localStorage.setItem( 'biorouter-home-insights-v1', JSON.stringify({ activity, recentSessions: [{ id: 'stale' }] }) ); const cache = await import('./homeInsightsCache'); - expect(cache.getCachedHomeActivity()).toEqual(activity); + expect(cache.getCachedHomeActivity()).toBeNull(); + + // The next persist drops it. + cache.cacheHomeActivity(activity); + expect(localStorage.getItem('biorouter-home-insights-v1')).toBeNull(); + expect(localStorage.getItem('biorouter-home-insights-v2')).not.toBeNull(); + }); + + it('survives a corrupt or foreign blob without crashing', async () => { + localStorage.setItem('biorouter-home-insights-v2', JSON.stringify({ activity: { days: 3 } })); + const cache = await import('./homeInsightsCache'); + expect(cache.getCachedHomeActivity()).toBeNull(); + + vi.resetModules(); + localStorage.setItem('biorouter-home-insights-v2', 'not json'); + const again = await import('./homeInsightsCache'); + expect(again.getCachedHomeActivity()).toBeNull(); + }); + + it('asks the daemon for its full 371-day window', async () => { + mocks.getSessionActivity.mockResolvedValue({ data: activity }); + const cache = await import('./homeInsightsCache'); + expect(cache.HOME_ACTIVITY_DAYS).toBe(371); + await cache.refreshHomeActivity(); + expect(mocks.getSessionActivity).toHaveBeenCalledWith( + expect.objectContaining({ query: { days: 371 } }) + ); }); it('deduplicates overlapping activity prefetch and view requests', async () => { diff --git a/ui/desktop/src/utils/homeInsightsCache.ts b/ui/desktop/src/utils/homeInsightsCache.ts index f2a818a44..454742e0d 100644 --- a/ui/desktop/src/utils/homeInsightsCache.ts +++ b/ui/desktop/src/utils/homeInsightsCache.ts @@ -1,12 +1,20 @@ import { userActionHeaders } from './userAction'; import { getSessionActivity, type ActivityWindow } from '../api'; -export const HOME_ACTIVITY_DAYS = 155; - -// v1 blobs also carried a `recentSessions` list for the Home recent-chats -// section; that section is gone (the sidebar's Recents owns the job), so the -// stray key is simply ignored and dropped on the next persist. -const HOME_INSIGHTS_STORAGE_KEY = 'biorouter-home-insights-v1'; +/** + * 53 weeks: the daemon's maximum (`get_activity` clamps to 371), so the usage + * card's `1y` range (365 days) is always inside the window and the heatmap + * draws one column per week of it. Owner decision 6.4: no backend change. + */ +export const HOME_ACTIVITY_DAYS = 371; + +/** + * v2 since the window grew from 155 to 371 days. A v1 blob holds a 155-day + * window that would understate every `1y` figure, so it is never read: it is + * removed on the next persist, and Home shows its loading state once. + */ +const HOME_INSIGHTS_STORAGE_KEY = 'biorouter-home-insights-v2'; +const RETIRED_STORAGE_KEYS = ['biorouter-home-insights-v1']; type HomeInsightsCache = { activity: ActivityWindow | null; @@ -15,6 +23,17 @@ type HomeInsightsCache = { let memoryCache: HomeInsightsCache | undefined; let inFlightActivity: Promise | null = null; +/** A blob from another build, or a corrupt one, must not crash Home. */ +function isActivityWindow(value: unknown): value is ActivityWindow { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.start === 'string' && + typeof candidate.end === 'string' && + Array.isArray(candidate.days) + ); +} + function readCache(): HomeInsightsCache { if (memoryCache) return memoryCache; @@ -31,8 +50,9 @@ function readCache(): HomeInsightsCache { return memoryCache; } - const parsed = JSON.parse(stored) as Partial; - memoryCache = { activity: parsed.activity ?? null }; + const parsed = JSON.parse(stored) as Partial | null; + const activity = parsed?.activity; + memoryCache = { activity: isActivityWindow(activity) ? activity : null }; } catch { memoryCache = emptyCache; } @@ -44,6 +64,7 @@ function persistCache(): void { if (typeof localStorage === 'undefined') return; try { + for (const key of RETIRED_STORAGE_KEYS) localStorage.removeItem(key); localStorage.setItem(HOME_INSIGHTS_STORAGE_KEY, JSON.stringify(readCache())); } catch { // Rendering from the in-memory cache still provides the warm-navigation path. @@ -90,6 +111,11 @@ export function clearHomeInsightsCache(): void { memoryCache = undefined; inFlightActivity = null; if (typeof localStorage !== 'undefined') { - localStorage.removeItem(HOME_INSIGHTS_STORAGE_KEY); + try { + localStorage.removeItem(HOME_INSIGHTS_STORAGE_KEY); + for (const key of RETIRED_STORAGE_KEYS) localStorage.removeItem(key); + } catch { + // Storage can be blocked; the in-memory cache is already cleared. + } } } diff --git a/ui/desktop/src/utils/htmlSecurity.test.ts b/ui/desktop/src/utils/htmlSecurity.test.ts index 3f7f5687e..21fc32a82 100644 --- a/ui/desktop/src/utils/htmlSecurity.test.ts +++ b/ui/desktop/src/utils/htmlSecurity.test.ts @@ -52,12 +52,12 @@ describe('HTML Security Detection', () => { it('ignores auto-links', () => { expect(containsHTML('')).toBe(false); expect(containsHTML('')).toBe(false); - expect(containsHTML('')).toBe(false); + expect(containsHTML('')).toBe(false); }); it('ignores email addresses', () => { expect(containsHTML('')).toBe(false); - expect(containsHTML('')).toBe(false); + expect(containsHTML('')).toBe(false); expect(containsHTML('')).toBe(false); }); diff --git a/ui/desktop/src/utils/keyboardShortcuts.ts b/ui/desktop/src/utils/keyboardShortcuts.ts index 4313064fe..c1b98ead1 100644 --- a/ui/desktop/src/utils/keyboardShortcuts.ts +++ b/ui/desktop/src/utils/keyboardShortcuts.ts @@ -2,16 +2,12 @@ function isMac(): boolean { return window.electron?.platform === 'darwin'; } -export function getNavigationShortcutText(): string { - return isMac() ? '⌘↑/⌘↓ to navigate messages' : 'Ctrl+↑/Ctrl+↓ to navigate messages'; -} - export function getSearchShortcutText(): string { return isMac() ? '⌘F' : 'Ctrl+F'; } /** - * The BR-61 steer chord, as a glyph — `⌘↵` on a Mac, `Ctrl+↵` elsewhere. + * The BR-61 steer chord, as a glyph: `⌘↵` on a Mac, `Ctrl+↵` elsewhere. * * The binding has existed since BR-61 (`ChatInput`'s Enter handler: Cmd/Ctrl+Enter * while a turn is running hands the composer's text to that turn instead of @@ -21,3 +17,11 @@ export function getSearchShortcutText(): string { export function getSteerShortcutText(): string { return isMac() ? '⌘↵' : 'Ctrl+↵'; } + +/** + * The same chord in `aria-keyshortcuts` syntax, so a control that the chord + * also reaches can announce it to a screen reader without a visible hint. + */ +export function getSteerAriaKeyShortcuts(): string { + return isMac() ? 'Meta+Enter' : 'Control+Enter'; +} diff --git a/ui/desktop/src/utils/navigationUtils.test.ts b/ui/desktop/src/utils/navigationUtils.test.ts index 9642796e9..bbc962ba8 100644 --- a/ui/desktop/src/utils/navigationUtils.test.ts +++ b/ui/desktop/src/utils/navigationUtils.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NavigateFunction } from 'react-router-dom'; -import { createNavigationHandler, navigateWithViewTransition } from './navigationUtils'; +import { renderHook } from '@testing-library/react'; +import { + createNavigationHandler, + navigateToRoute, + ROUTE_SURFACE_SELECTOR, + useRouteFade, +} from './navigationUtils'; describe('navigation helpers', () => { const navigate = vi.fn() as unknown as NavigateFunction; @@ -20,7 +26,7 @@ describe('navigation helpers', () => { value: startViewTransition, }); - navigateWithViewTransition(navigate, '/settings', { section: 'models' }); + navigateToRoute(navigate, '/settings', { section: 'models' }); expect(startViewTransition).not.toHaveBeenCalled(); expect(navigate).toHaveBeenCalledWith('/settings', { @@ -30,9 +36,9 @@ describe('navigation helpers', () => { }); it('preserves every rapid navigation intent in order', () => { - navigateWithViewTransition(navigate, '/settings'); - navigateWithViewTransition(navigate, '/workflows'); - navigateWithViewTransition(navigate, '/knowledge'); + navigateToRoute(navigate, '/settings'); + navigateToRoute(navigate, '/workflows'); + navigateToRoute(navigate, '/knowledge'); expect(navigate).toHaveBeenNthCalledWith(1, '/settings', { replace: undefined, @@ -52,7 +58,7 @@ describe('navigation helpers', () => { const setView = createNavigationHandler(navigate); setView('chat', { disableAnimation: true }); - navigateWithViewTransition(navigate, '/sessions', undefined, { replace: true }); + navigateToRoute(navigate, '/sessions', undefined, { replace: true }); expect(navigate).toHaveBeenNthCalledWith(1, '/', { replace: undefined, @@ -64,3 +70,70 @@ describe('navigation helpers', () => { }); }); }); + +/** + * Spec 3.16, Routes: the main column fades in once when the path changes, and + * nowhere else. jsdom has no WAAPI, so `animate` is a stub on the surface. + */ +describe('useRouteFade', () => { + let surface: HTMLElement; + let animate: ReturnType; + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + surface = document.createElement('main'); + surface.className = 'route-container biorouter-route-surface'; + animate = vi.fn(); + Object.assign(surface, { animate }); + document.body.appendChild(surface); + }); + + afterEach(() => { + surface.remove(); + window.matchMedia = originalMatchMedia; + document.body.classList.remove('biorouter-window-resizing'); + }); + + it('targets the shell surface', () => { + expect(document.querySelector(ROUTE_SURFACE_SELECTOR)).toBe(surface); + }); + + it('does nothing on the first mount: a reload or a new window appears at rest', () => { + renderHook(({ path }) => useRouteFade(path), { initialProps: { path: '/settings' } }); + expect(animate).not.toHaveBeenCalled(); + }); + + it('fades the column in once, opacity only, when the path changes', () => { + const { rerender } = renderHook(({ path }) => useRouteFade(path), { + initialProps: { path: '/settings' }, + }); + rerender({ path: '/workflows' }); + + expect(animate).toHaveBeenCalledTimes(1); + expect(animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { + duration: 125, + easing: 'cubic-bezier(0.24, 1, 0.4, 1)', + }); + + // The same path again (a query-string change, a re-render) fades nothing. + rerender({ path: '/workflows' }); + expect(animate).toHaveBeenCalledTimes(1); + }); + + it('stays still under reduced motion and while the window is being resized', () => { + window.matchMedia = ((query: string) => + ({ + matches: query === '(prefers-reduced-motion: reduce)', + }) as MediaQueryList) as typeof window.matchMedia; + const { rerender } = renderHook(({ path }) => useRouteFade(path), { + initialProps: { path: '/settings' }, + }); + rerender({ path: '/skills' }); + expect(animate).not.toHaveBeenCalled(); + + window.matchMedia = originalMatchMedia; + document.body.classList.add('biorouter-window-resizing'); + rerender({ path: '/knowledge' }); + expect(animate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index 8f8e7ee31..9c2bedd9f 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -1,4 +1,6 @@ +import { useLayoutEffect, useRef } from 'react'; import { NavigateFunction } from 'react-router-dom'; +import { DUR, EASE_OUT, isWindowResizing, prefersReducedMotion } from '../styles/motion'; import { Workflow, type SessionClassification } from '../api/types.gen'; import type { UserAttachment } from '../types/message'; @@ -54,7 +56,16 @@ export type ViewOptions = { heldChatTabId?: string; }; -export const navigateWithViewTransition = ( +/** + * Go to a route. It is a plain `navigate`: the change is synchronous, with no + * View Transition and no document snapshot (spec 3.16, "no exit, no slide and + * no View Transitions"), and the only motion is the main column's one fade in + * {@link useRouteFade}. + * + * It was called `navigateWithViewTransition`, a name that promised a motion the + * function never made. + */ +export const navigateToRoute = ( navigate: NavigateFunction, to: string, state?: unknown, @@ -63,11 +74,41 @@ export const navigateWithViewTransition = ( navigate(to, { replace: options.replace, state }); }; +/** The app shell's route surface: the `
      ` that holds every routed view (`AppLayout`). */ +export const ROUTE_SURFACE_SELECTOR = 'main.biorouter-route-surface'; + +/** + * Routes (spec 3.16): when the path changes, the main column fades in once, + * opacity only, over `--dur-fast` on `--ease-out`. No exit, no slide, no View + * Transition, and nothing on the first mount (a reload, a window opening), on a + * change of query string alone, under reduced motion or while the window is + * being resized. + * + * A layout effect, so the new route's first frame is already at opacity 0 and + * never flashes in at full ink first. WAAPI rather than a keyed wrapper: the + * surface is not remounted and gains no box, so no routed view's layout moves. + */ +export function useRouteFade(pathname: string): void { + const shown = useRef(pathname); + useLayoutEffect(() => { + if (shown.current === pathname) return; + shown.current = pathname; + if (prefersReducedMotion() || isWindowResizing()) return; + if (typeof document === 'undefined') return; + const surface = document.querySelector(ROUTE_SURFACE_SELECTOR); + if (!surface || typeof surface.animate !== 'function') return; + surface.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: DUR.fast, + easing: EASE_OUT, + }); + }, [pathname]); +} + export const createNavigationHandler = (navigate: NavigateFunction) => { return (view: View, options?: ViewOptions) => { switch (view) { case 'crew': - navigateWithViewTransition( + navigateToRoute( navigate, options?.resumeSessionId ? `/crew?sessionId=${encodeURIComponent(options.resumeSessionId)}` @@ -76,7 +117,7 @@ export const createNavigationHandler = (navigate: NavigateFunction) => { ); break; case 'chat': - navigateWithViewTransition(navigate, '/', options); + navigateToRoute(navigate, '/', options); break; case 'pair': // The session id MUST ride in the query string, not only in location.state. @@ -96,7 +137,7 @@ export const createNavigationHandler = (navigate: NavigateFunction) => { // The options object still travels as location.state, which is where // initialMessage / initialAttachments / title are read from once the // param has opened the gate. - navigateWithViewTransition( + navigateToRoute( navigate, options?.resumeSessionId ? `/pair?resumeSessionId=${encodeURIComponent(options.resumeSessionId)}` @@ -105,38 +146,38 @@ export const createNavigationHandler = (navigate: NavigateFunction) => { ); break; case 'settings': - navigateWithViewTransition(navigate, '/settings', options); + navigateToRoute(navigate, '/settings', options); break; case 'sessions': - navigateWithViewTransition(navigate, '/sessions', options); + navigateToRoute(navigate, '/sessions', options); break; case 'schedules': - navigateWithViewTransition(navigate, '/schedules', options); + navigateToRoute(navigate, '/schedules', options); break; case 'workflows': - navigateWithViewTransition(navigate, '/workflows', options); + navigateToRoute(navigate, '/workflows', options); break; case 'skills': - navigateWithViewTransition(navigate, '/skills', options); + navigateToRoute(navigate, '/skills', options); break; case 'permission': - navigateWithViewTransition(navigate, '/permission', options); + navigateToRoute(navigate, '/permission', options); break; case 'ConfigureProviders': - navigateWithViewTransition(navigate, '/configure-providers', options); + navigateToRoute(navigate, '/configure-providers', options); break; case 'sharedSession': - navigateWithViewTransition(navigate, '/shared-session', options); + navigateToRoute(navigate, '/shared-session', options); break; case 'welcome': - navigateWithViewTransition(navigate, '/welcome', options); + navigateToRoute(navigate, '/welcome', options); break; case 'extensions': - navigateWithViewTransition(navigate, '/extensions', options); + navigateToRoute(navigate, '/extensions', options); break; default: - navigateWithViewTransition(navigate, '/', options); + navigateToRoute(navigate, '/', options); } }; }; diff --git a/ui/desktop/src/utils/openInBrowser.test.ts b/ui/desktop/src/utils/openInBrowser.test.ts index 74936692c..c770ddcce 100644 --- a/ui/desktop/src/utils/openInBrowser.test.ts +++ b/ui/desktop/src/utils/openInBrowser.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../toasts', () => ({ toastError: vi.fn() })); import { toastError } from '../toasts'; -import { openInBrowser, previewOpenTarget } from './openInBrowser'; +import { OPEN_IN_BROWSER_COPY, openInBrowser, previewOpenTarget } from './openInBrowser'; const openExternal = vi.fn(); @@ -20,7 +20,7 @@ describe('openInBrowser', () => { await openInBrowser('http://127.0.0.1:51851/apps/cohort-explorer/'); expect(openExternal).toHaveBeenCalledWith('http://127.0.0.1:51851/apps/cohort-explorer/'); expect(toastError).toHaveBeenCalledWith({ - title: 'Could not open the app', + title: OPEN_IN_BROWSER_COPY.failedTitle, msg: 'This app no longer exists.', }); }); @@ -37,8 +37,8 @@ describe('openInBrowser', () => { openExternal.mockRejectedValueOnce(new Error('ipc gone')); await openInBrowser('http://127.0.0.1:1/apps/a/'); expect(vi.mocked(toastError).mock.calls.map(([toast]) => toast.msg)).toEqual([ - 'Could not open the app in your browser.', - 'Biorouter could not open that link.', + OPEN_IN_BROWSER_COPY.failed, + OPEN_IN_BROWSER_COPY.bridgeFailed, ]); }); }); diff --git a/ui/desktop/src/utils/openInBrowser.ts b/ui/desktop/src/utils/openInBrowser.ts index 23b2acedc..b7eec38ee 100644 --- a/ui/desktop/src/utils/openInBrowser.ts +++ b/ui/desktop/src/utils/openInBrowser.ts @@ -9,6 +9,19 @@ import { localAppLinkId } from './managedAppPreviewPolicy'; */ export type OpenExternalResult = { ok: true } | { ok: false; message: string } | void; +/** + * The failure toast's words, shared by the three surfaces that open a link in the browser (the + * transcript's artifact card, the preview panel's header and external-page card, a live page's + * toolbar). They are the words Built apps' Launch uses for the same failure, so an app that will + * not open reads the same wherever it was clicked. The title is the one line; the main process's + * own sentence, when it gave one, is the message. + */ +export const OPEN_IN_BROWSER_COPY = { + failedTitle: 'Couldn’t open the app', + failed: 'Couldn’t open the app in your browser.', + bridgeFailed: 'Biorouter couldn’t open that link.', +} as const; + /** * Open `url` in the person's browser from a control they clicked, and say why when an app could * not be opened. A failure used to end in the main process's log, so "Open in browser" on a live @@ -19,16 +32,13 @@ export async function openInBrowser(url: string): Promise { try { result = await window.electron.openExternal(url); } catch { - result = { ok: false, message: 'Biorouter could not open that link.' }; + result = { ok: false, message: OPEN_IN_BROWSER_COPY.bridgeFailed }; } if (!result || typeof result !== 'object' || (result as { ok?: unknown }).ok !== false) return; const message = (result as { message?: unknown }).message; toastError({ - title: 'Could not open the app', - msg: - typeof message === 'string' && message.trim() - ? message - : 'Could not open the app in your browser.', + title: OPEN_IN_BROWSER_COPY.failedTitle, + msg: typeof message === 'string' && message.trim() ? message : OPEN_IN_BROWSER_COPY.failed, }); } diff --git a/ui/desktop/src/utils/searchHighlighter.browser.test.ts b/ui/desktop/src/utils/searchHighlighter.browser.test.ts index ff3a8c706..7d35a6453 100644 --- a/ui/desktop/src/utils/searchHighlighter.browser.test.ts +++ b/ui/desktop/src/utils/searchHighlighter.browser.test.ts @@ -27,7 +27,7 @@ const here = dirname(fileURLToPath(import.meta.url)); /** Launch whichever Chromium build this machine downloaded (full or headless shell). */ const launchChromium = async (): Promise => { - for (const options of [{}, { channel: 'chromium' }] as const) { + for (const options of [{}, { channel: 'chromium' }, { channel: 'chrome' }] as const) { try { return await chromium.launch(options); } catch { @@ -83,6 +83,13 @@ const expectInside = (mark: Box, box: Box) => { interface PageHighlighter { highlight(term: string): HTMLElement[]; setCurrentMatch(index: number, shouldScroll?: boolean): void; + /** Private: read to pin how long the highlighter ignores the scrolls it caused. */ + isScrollingProgrammatically: boolean; +} + +/** `prefers-reduced-motion` for a page: `no-preference` is what a headless Chromium has by default. */ +interface PageOptions { + reducedMotion?: 'reduce' | 'no-preference'; } describe('SearchHighlighter in a real layout engine', () => { @@ -107,8 +114,15 @@ describe('SearchHighlighter in a real layout engine', () => { await browser?.close(); }, 120_000); - const withPage = async (body: string, run: (page: Page) => Promise) => { - const page = await browser!.newPage({ viewport: { width: 800, height: 600 } }); + const withPage = async ( + body: string, + run: (page: Page) => Promise, + options: PageOptions = {} + ) => { + const page = await browser!.newPage({ + viewport: { width: 800, height: 600 }, + reducedMotion: options.reducedMotion, + }); try { await page.setContent( `` + @@ -255,6 +269,11 @@ describe('SearchHighlighter in a real layout engine', () => { /// The transcript's own scroll container is not a clip. A match below the /// fold is one scroll away, counts, and navigation brings it into view. + /// + /// ⚠ Run under reduced motion on purpose. Without it the scroll is smooth + /// (spec 3.16), so `navigate` reads `scrollTop` the moment the scroll has + /// started and finds the page where it was. The smooth case is the next test, + /// which waits for `scrollend`. it('keeps counting a match the transcript has scrolled out of view', async (ctx) => { if (!browser) return ctx.skip(); await withPage( @@ -271,7 +290,105 @@ describe('SearchHighlighter in a real layout engine', () => { expect(after.current!.bottom).toBeLessThanOrEqual(after.host.bottom); // Scrolling the transcript neither drops nor adds a match. expect(after.marks).toHaveLength(2); - } + }, + { reducedMotion: 'reduce' } + ); + }); + + /// Spec 3.16: the current match scrolls into view smoothly. It is still + /// where it was the moment navigation returns, arrives over more than one + /// scroll event, and the highlighter stops ignoring scrolls at `scrollend` + /// rather than on a guess at how long the scroll takes. + it('scrolls to the match smoothly, and ends its hold on scrollend', async (ctx) => { + if (!browser) return ctx.skip(); + await withPage( + `
      okapi at the top
      okapi far below
      `, + async (page) => { + await search(page, 'okapi'); + const run = await page.evaluate( + () => + new Promise<{ + timedOut: boolean; + startScrollTop: number; + heldAtStart: boolean; + scrollEvents: number; + heldAtEnd: boolean; + endScrollTop: number; + current: { top: number; bottom: number } | null; + host: { top: number; bottom: number }; + marks: number; + }>((resolve) => { + const w = window as unknown as { __highlighter: PageHighlighter }; + const host = document.getElementById('host')!; + const hostBox = host.getBoundingClientRect(); + let scrollEvents = 0; + host.addEventListener('scroll', () => (scrollEvents += 1)); + + w.__highlighter.setCurrentMatch(1, true); + const startScrollTop = host.scrollTop; + const heldAtStart = w.__highlighter.isScrollingProgrammatically; + + const net = setTimeout(() => finish(true), 5000); + const finish = (timedOut: boolean) => { + clearTimeout(net); + const current = document.querySelector('.search-highlight.current'); + const box = current?.getBoundingClientRect(); + resolve({ + timedOut, + startScrollTop, + heldAtStart, + scrollEvents, + // The highlighter's own scrollend listener was added first, so it has run. + heldAtEnd: w.__highlighter.isScrollingProgrammatically, + endScrollTop: host.scrollTop, + current: box ? { top: box.top, bottom: box.bottom } : null, + host: { top: hostBox.top, bottom: hostBox.bottom }, + marks: document.querySelectorAll('.search-highlight').length, + }); + }; + host.addEventListener('scrollend', () => finish(false), { once: true }); + }) + ); + + expect(run.timedOut).toBe(false); + expect(run.startScrollTop).toBe(0); + expect(run.heldAtStart).toBe(true); + expect(run.scrollEvents).toBeGreaterThan(1); + expect(run.heldAtEnd).toBe(false); + expect(run.endScrollTop).toBeGreaterThan(0); + expect(run.current).not.toBeNull(); + expect(run.current!.top).toBeGreaterThanOrEqual(run.host.top); + expect(run.current!.bottom).toBeLessThanOrEqual(run.host.bottom); + expect(run.marks).toBe(2); + }, + { reducedMotion: 'no-preference' } + ); + }); + + /// A smooth scroll that has nowhere to go sends no scroll event and no + /// scrollend. The first match is already at the top of a page that cannot + /// scroll further up, so the highlighter must not sit deaf for the net. + it('does not wait out a smooth scroll that has nowhere to go', async (ctx) => { + if (!browser) return ctx.skip(); + await withPage( + `
      okapi at the top
      okapi far below
      `, + async (page) => { + await search(page, 'okapi'); + const held = await page.evaluate(async () => { + const w = window as unknown as { __highlighter: PageHighlighter }; + const host = document.getElementById('host')!; + w.__highlighter.setCurrentMatch(0, true); + const heldAtStart = w.__highlighter.isScrollingProgrammatically; + await new Promise((resolve) => setTimeout(resolve, 250)); + return { + heldAtStart, + heldLater: w.__highlighter.isScrollingProgrammatically, + scrollTop: host.scrollTop, + }; + }); + expect(held).toEqual({ heldAtStart: true, heldLater: false, scrollTop: 0 }); + }, + { reducedMotion: 'no-preference' } ); }); diff --git a/ui/desktop/src/utils/searchHighlighter.test.ts b/ui/desktop/src/utils/searchHighlighter.test.ts index 7c40bdf28..cb14a6b8e 100644 --- a/ui/desktop/src/utils/searchHighlighter.test.ts +++ b/ui/desktop/src/utils/searchHighlighter.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clipMatchRect, + FIND_BAR_GAP, + FindClearance, glyphPaintedBeforeEllipsis, SearchHighlighter, trimRectToEllipsis, @@ -52,6 +54,340 @@ describe('SearchHighlighter', () => { }); }); +/// Spec 3.16: the current match scrolls into view smoothly, but not under +/// reduced motion. jsdom lays nothing out, so the geometry is stubbed: a 200px +/// viewport over 2000px of content with a match at y = 1000. What a real engine +/// does with the scroll is pinned in `searchHighlighter.browser.test.ts`. +describe('SearchHighlighter scrolling to a match', () => { + const MATCH_TOP = 1000; + const MATCH_HEIGHT = 16; + const VIEWPORT_HEIGHT = 200; + /** The match is centred: its top minus the room left over either side of it. */ + const CENTRED = MATCH_TOP - (VIEWPORT_HEIGHT - MATCH_HEIGHT) / 2; + + const rect = (top: number, height: number) => + ({ top, height, bottom: top + height, left: 0, right: 100, width: 100 }) as DOMRect; + + const setUp = ({ + reducedMotion = false, + scrollTo = true, + scrollTop = 0, + scrollHeight = 2000, + matchTop = MATCH_TOP, + clearance, + }: { + reducedMotion?: boolean; + scrollTo?: boolean; + scrollTop?: number; + scrollHeight?: number; + matchTop?: number; + clearance?: FindClearance; + } = {}) => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + vi.stubGlobal( + 'matchMedia', + (query: string) => ({ matches: reducedMotion, media: query }) as MediaQueryList + ); + document.body.innerHTML = ` +
      +
      alpha beta
      +
      `; + const content = document.getElementById('search-content') as HTMLDivElement; + const viewport = document.querySelector('[data-radix-scroll-area-viewport]') as HTMLDivElement; + Object.defineProperty(viewport, 'clientHeight', { value: VIEWPORT_HEIGHT, configurable: true }); + Object.defineProperty(viewport, 'scrollHeight', { value: scrollHeight, configurable: true }); + viewport.getBoundingClientRect = () => rect(0, VIEWPORT_HEIGHT); + viewport.scrollTop = scrollTop; + // jsdom has no Element.scrollTo, so the smooth path is only taken where the test gives it one. + const scrollToSpy = vi.fn(); + if (scrollTo) viewport.scrollTo = scrollToSpy as unknown as typeof viewport.scrollTo; + + const highlighter = new SearchHighlighter(content, undefined, clearance); + // One measured match, standing where the layout engine would have put it. + const mark = document.createElement('div'); + mark.className = 'search-highlight'; + mark.getBoundingClientRect = () => rect(matchTop, MATCH_HEIGHT); + const container = document.createElement('div'); + container.appendChild(mark); + const internals = highlighter as unknown as { + highlights: HTMLElement[]; + currentTerm: string; + isScrollingProgrammatically: boolean; + }; + internals.highlights = [container]; + internals.currentTerm = 'alpha'; + // A scroll the hold lets through re-measures; count those instead of walking a document. + const remeasure = vi.spyOn(highlighter, 'highlight').mockReturnValue([]); + return { highlighter, viewport, content, scrollToSpy, remeasure, internals }; + }; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('scrolls smoothly to centre the match', () => { + const { highlighter, viewport, scrollToSpy } = setUp(); + highlighter.setCurrentMatch(0, true); + expect(scrollToSpy).toHaveBeenCalledWith({ top: CENTRED, behavior: 'smooth' }); + // Not jumped: the browser moves it, over time. + expect(viewport.scrollTop).toBe(0); + }); + + it('jumps instead under reduced motion', () => { + const { highlighter, viewport, scrollToSpy } = setUp({ reducedMotion: true }); + highlighter.setCurrentMatch(0, true); + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(viewport.scrollTop).toBe(CENTRED); + }); + + it('jumps where the element has no scrollTo', () => { + const { highlighter, viewport } = setUp({ scrollTo: false }); + highlighter.setCurrentMatch(0, true); + expect(viewport.scrollTop).toBe(CENTRED); + }); + + it('does not scroll when only the paint changed', () => { + const { highlighter, viewport, scrollToSpy } = setUp(); + highlighter.setCurrentMatch(0, false); + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(viewport.scrollTop).toBe(0); + }); + + it('ignores its own scroll events until scrollend, then listens again', () => { + const { highlighter, viewport, remeasure, internals } = setUp(); + highlighter.setCurrentMatch(0, true); + expect(internals.isScrollingProgrammatically).toBe(true); + + // Well past the 100ms an instant scroll needs, still scrolling: still ignored. + vi.advanceTimersByTime(600); + viewport.dispatchEvent(new Event('scroll')); + expect(remeasure).not.toHaveBeenCalled(); + + // The scroller has arrived where it was sent, and says so. + viewport.scrollTop = CENTRED; + viewport.dispatchEvent(new Event('scrollend')); + expect(internals.isScrollingProgrammatically).toBe(false); + viewport.dispatchEvent(new Event('scroll')); + expect(remeasure).toHaveBeenCalledTimes(1); + }); + + /// Making room for the match above a chat's first lines is an instant scroll + /// of its own (see FindClearance), and its `scrollend` is dispatched after the + /// smooth scroll has been asked for. It must not end the hold early. + it('is not ended by a scrollend that arrives before the scroller has landed', () => { + const { highlighter, viewport, remeasure, internals } = setUp(); + highlighter.setCurrentMatch(0, true); + + viewport.scrollTop = 24; + viewport.dispatchEvent(new Event('scrollend')); + expect(internals.isScrollingProgrammatically).toBe(true); + viewport.dispatchEvent(new Event('scroll')); + expect(remeasure).not.toHaveBeenCalled(); + + // It is still listening: the scrollend that does end the scroll ends the hold. + viewport.scrollTop = CENTRED; + viewport.dispatchEvent(new Event('scrollend')); + expect(internals.isScrollingProgrammatically).toBe(false); + }); + + it('stops ignoring scrolls after a second even if scrollend never comes', () => { + const { highlighter, internals } = setUp(); + highlighter.setCurrentMatch(0, true); + vi.advanceTimersByTime(999); + expect(internals.isScrollingProgrammatically).toBe(true); + vi.advanceTimersByTime(1); + expect(internals.isScrollingProgrammatically).toBe(false); + }); + + /// A smooth scroll with nowhere to go fires neither scroll nor scrollend, so + /// holding for the net would deafen the bar for a second for nothing. + it('jumps, and holds only briefly, when the match is already as centred as the page allows', () => { + // The page's last line: the clamp is 1800 and the container is already there. + const { highlighter, viewport, scrollToSpy, internals } = setUp({ scrollTop: 1800 }); + highlighter.setCurrentMatch(0, true); + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(viewport.scrollTop).toBe(CENTRED + 1800); + vi.advanceTimersByTime(100); + expect(internals.isScrollingProgrammatically).toBe(false); + }); + + it('removes the pending scrollend listener when destroyed', () => { + const { highlighter, viewport } = setUp(); + const removeEventListener = vi.spyOn(viewport, 'removeEventListener'); + highlighter.setCurrentMatch(0, true); + highlighter.destroy(); + expect(removeEventListener).toHaveBeenCalledWith('scrollend', expect.any(Function)); + }); + + /// Vision V2-R5-4. The bar floats over the scroller's top; where it is comes + /// from the find session's `FindClearance`. Real layout is pinned in + /// `findClearance.browser.test.ts`; here the bar's box is stubbed. + describe('with the find bar floating over the top', () => { + const BAR = { left: 40, top: 8, right: 90, bottom: 50 }; + // `setUp` replaces the document body, so the bar joins it when it is asked for. + const bar = (box = BAR) => { + const element = document.createElement('div'); + element.getBoundingClientRect = () => ({ ...box, width: 50, height: 42 }) as DOMRect; + return new FindClearance(() => { + if (!element.isConnected) document.body.appendChild(element); + return element; + }); + }; + + it('centres the match in the part of the scroller below the bar and its gap', () => { + const { highlighter, scrollToSpy } = setUp({ clearance: bar() }); + highlighter.setCurrentMatch(0, true); + const freeTop = BAR.bottom + FIND_BAR_GAP; + expect(scrollToSpy).toHaveBeenCalledWith({ + top: MATCH_TOP - freeTop - (VIEWPORT_HEIGHT - freeTop - MATCH_HEIGHT) / 2, + behavior: 'smooth', + }); + }); + + it('asks for no room for a match the scroll can put below the bar', () => { + const clearance = bar(); + const makeRoom = vi.spyOn(clearance, 'makeRoom'); + const { highlighter } = setUp({ clearance }); + highlighter.setCurrentMatch(0, true); + expect(makeRoom).not.toHaveBeenCalled(); + }); + + it('asks for exactly the shortfall when the scroll cannot lift a match clear of the bar', () => { + const clearance = bar(); + const makeRoom = vi.spyOn(clearance, 'makeRoom').mockReturnValue(true); + // 40..56 at the top of the scroller, under the bar's 40..90 span: the + // free edge is 58, and `scrollTop` cannot go below 0. + const { highlighter, viewport, content } = setUp({ clearance, matchTop: 40 }); + highlighter.setCurrentMatch(0, true); + expect(makeRoom).toHaveBeenCalledWith(viewport, BAR.bottom + FIND_BAR_GAP - 40, content); + }); + + it('asks for nothing for a match beside the bar, however high it sits', () => { + const clearance = bar({ left: 300, top: 8, right: 350, bottom: 50 }); + const makeRoom = vi.spyOn(clearance, 'makeRoom'); + const { highlighter } = setUp({ clearance, matchTop: 40 }); + highlighter.setCurrentMatch(0, true); + expect(makeRoom).not.toHaveBeenCalled(); + }); + }); +}); + +describe('FindClearance', () => { + const scrollerWith = (rect: Partial = {}) => { + const scroller = document.createElement('div'); + document.body.appendChild(scroller); + scroller.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 100, bottom: 200, width: 100, height: 200, ...rect }) as DOMRect; + return scroller; + }; + const boxed = (box: { left: number; top: number; right: number; bottom: number }) => { + const element = document.createElement('div'); + document.body.appendChild(element); + element.getBoundingClientRect = () => + ({ ...box, width: box.right - box.left, height: box.bottom - box.top }) as DOMRect; + return element; + }; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + it('knows no bar when there is none, when it is gone, or when it has no size', () => { + const scroller = scrollerWith(); + expect(new FindClearance(() => null).barBox(scroller)).toBeNull(); + const gone = boxed({ left: 0, top: 0, right: 50, bottom: 40 }); + gone.remove(); + expect(new FindClearance(() => gone).barBox(scroller)).toBeNull(); + // jsdom's own rect for an element: all zeros. + expect(new FindClearance(() => document.createElement('div')).barBox(scroller)).toBeNull(); + }); + + it('reports the bar only while it overlaps the scroller', () => { + const scroller = scrollerWith(); + const inside = boxed({ left: 10, top: 8, right: 60, bottom: 50 }); + expect(new FindClearance(() => inside).barBox(scroller)).toEqual({ + left: 10, + top: 8, + right: 60, + bottom: 50, + }); + const beside = boxed({ left: 300, top: 8, right: 350, bottom: 50 }); + expect(new FindClearance(() => beside).barBox(scroller)).toBeNull(); + }); + + it('adds no room for less than a pixel', () => { + const scroller = scrollerWith(); + const content = boxed({ left: 0, top: 20, right: 100, bottom: 400 }); + const clearance = new FindClearance(() => null); + expect(clearance.makeRoom(scroller, 0.4, content)).toBe(false); + expect(clearance.makeRoom(scroller, Number.NaN, content)).toBe(false); + expect(scroller.style.paddingTop).toBe(''); + }); + + it('grows its inset by the whole pixels asked and puts the scroller back on release', () => { + const scroller = scrollerWith(); + scroller.style.paddingTop = '3px'; + scroller.scrollTop = 500; + const content = boxed({ left: 0, top: 20, right: 100, bottom: 400 }); + const clearance = new FindClearance(() => null); + + expect(clearance.makeRoom(scroller, 17.2, content)).toBe(true); + expect(clearance.makeRoom(scroller, 6, content)).toBe(true); + // 17.2 rounds up to 18; 18 + 6 on top of the 3px the scroller already had. + expect(scroller.style.paddingTop).toBe('27px'); + + // Scrolled past the inset: the padding goes at once, no glide to wait for. + clearance.release(); + expect(scroller.style.paddingTop).toBe('3px'); + expect(scroller.style.transition).toBe(''); + }); + + it('glides the room away when the reader has not scrolled past it, then drops the transition', () => { + const scroller = scrollerWith(); + scroller.scrollTop = 0; + const content = boxed({ left: 0, top: 20, right: 100, bottom: 400 }); + const clearance = new FindClearance(() => null); + clearance.makeRoom(scroller, 24, content); + + clearance.release(); + expect(scroller.style.transition).toContain('padding-top'); + expect(scroller.style.paddingTop).toBe(''); + + vi.advanceTimersByTime(400); + expect(scroller.style.transition).toBe(''); + }); + + it('jumps under reduced motion', () => { + vi.stubGlobal( + 'matchMedia', + (query: string) => ({ matches: true, media: query }) as MediaQueryList + ); + const scroller = scrollerWith(); + const content = boxed({ left: 0, top: 20, right: 100, bottom: 400 }); + const clearance = new FindClearance(() => null); + clearance.makeRoom(scroller, 24, content); + + clearance.release(); + expect(scroller.style.transition).toBe(''); + expect(scroller.style.paddingTop).toBe(''); + }); + + it('is a no-op to release a clearance that made no room, twice over', () => { + const scroller = scrollerWith(); + scroller.style.paddingTop = '5px'; + const clearance = new FindClearance(() => null); + clearance.release(); + clearance.release(); + expect(scroller.style.paddingTop).toBe('5px'); + }); +}); + /// The rule itself, free of layout: which rects count and what gets painted. /// Where the rule meets real CSS (line-clamp, overflow, scrolling) is pinned in /// `searchHighlighter.browser.test.ts`, because jsdom lays nothing out. diff --git a/ui/desktop/src/utils/searchHighlighter.ts b/ui/desktop/src/utils/searchHighlighter.ts index ff42c4d5a..395521b08 100644 --- a/ui/desktop/src/utils/searchHighlighter.ts +++ b/ui/desktop/src/utils/searchHighlighter.ts @@ -45,7 +45,8 @@ * * ⚠ **An attribute this module exports, not a class name each side spells.** * The walker used to skip `.search-bar, .search-results`, and no element carried - * either class: `SearchBar`'s root is `search-bar-enter` / `search-bar-exit`. So + * either class: `SearchBar`'s root was `search-bar-enter` / `search-bar-exit` + * (it is `.br-find-overlay` now, and carries this attribute). So * the bar's "Aa" toggle was walked, counted and marked under the button, where * nobody sees a mark — ⌘F then `aa` in a chat read "1/1" with nothing * highlighted, and on Settings → Skills `a` read 1/18 with the first two ⌘G @@ -518,6 +519,173 @@ function glyphsOnLine(node: Text, start: number, end: number, rect: ViewportRect return glyphs; } +/** How long the scroll events of our own instant scroll are ignored. */ +const INSTANT_SCROLL_HOLD_MS = 100; + +/** + * How long a smooth scroll of ours is waited out when `scrollend` never comes. + * Chromium ends a programmatic smooth scroll within about 750ms whatever the + * distance; this is a net, not the usual way the hold ends. + */ +const SMOOTH_SCROLL_NET_MS = 1000; + +/** + * Whether the person asked the OS to reduce motion. A private copy of + * `styles/motion.ts`'s check: this module imports nothing, because + * `searchHighlighter.browser.test.ts` transpiles it alone and runs it as a + * classic script in Chromium, where there is no `require`. + */ +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +/** The gap find keeps between the bar floating over the transcript and the match it shows. */ +export const FIND_BAR_GAP = 8; + +/** + * Keeps the match find is showing clear of the bar that floats over the top of + * the transcript (vision V2-R5-4). + * + * ⚠ **Scrolling alone cannot do it, because the bar covers the first lines of + * the content and a scroller cannot scroll above its own top.** A match in the + * first 50px of a chat ("kdps" in the first message: 84..100 behind a bar that + * ends at 94) is already as high as `scrollTop = 0` can put it, so the target + * clamped to 0 and the current match stayed under the bar. The room has to be + * made, as an editor's find widget makes it: an inset above the content. + * + * ⚠ **The inset is lazy, and it never moves the text it leaves in view.** + * Opening the bar must not shift the conversation (spec 3.16; + * `findOverlay.browser.test.ts` holds it at zero pixels), so nothing is + * reserved up front. The inset grows only when the match find is about to show + * would land under the bar, by exactly the shortfall, and in the same frame the + * scroll position is raised by what the padding added, so the text is where it + * was; `scrollToMark` then scrolls it down into the clear the usual (smooth) + * way. Where the scroller cannot absorb it (a chat shorter than its viewport), + * the text moves down once by the shortfall. + * + * It belongs to the find session, not to one highlighter: `SearchView` makes a + * new highlighter for every term, and a highlighter that dropped the inset on + * `destroy` would pull the text up and push it down again on each keystroke. + * `SearchView` releases it when the bar closes. + * + * The bar is whatever `bar()` returns; the layout is measured, never assumed, + * because the bar's height changes with its note row and its box is moved by its + * own entrance animation. + */ +export class FindClearance { + private readonly bar: () => Element | null; + private inset = 0; + private scroller: HTMLElement | null = null; + /** The text's top edge as it moved with the padding: the one box this keeps still. */ + private reference: Element | null = null; + /** The scroller's own padding before the inset, and what its inline style said. */ + private basePadding = 0; + private inlinePadding = ''; + private finishGlide: (() => void) | null = null; + + constructor(bar: () => Element | null) { + this.bar = bar; + } + + /** The bar's box when it floats over `scroller`, in viewport coordinates; otherwise null. */ + barBox(scroller: HTMLElement): ViewportRect | null { + const bar = this.bar(); + if (!bar || !bar.isConnected) return null; + const box = bar.getBoundingClientRect(); + if (box.width === 0 || box.height === 0) return null; + const area = scroller.getBoundingClientRect(); + const overlaps = + box.bottom > area.top && + box.top < area.bottom && + box.right > area.left && + box.left < area.right; + return overlaps ? { left: box.left, top: box.top, right: box.right, bottom: box.bottom } : null; + } + + /** + * Add `by` px above the content of `scroller` and hold `reference` (the + * content's own box, which moves with the padding) where it is on screen. + * Returns whether the inset grew. + */ + makeRoom(scroller: HTMLElement, by: number, reference: Element): boolean { + if (!(by >= 0.5)) return false; + const grow = Math.ceil(by); + this.finishGlide?.(); + if (this.scroller && this.scroller !== scroller) this.release(); + if (!this.scroller) { + this.scroller = scroller; + this.inlinePadding = scroller.style.paddingTop; + this.basePadding = parseFloat(getComputedStyle(scroller).paddingTop) || 0; + } + this.reference = reference; + const before = reference.getBoundingClientRect().top; + this.inset += grow; + scroller.style.paddingTop = `${this.basePadding + this.inset}px`; + // Read back rather than assume: where the browser's scroll anchoring has + // already moved the scroll position for the padding this adds nothing, and + // where the scroller has no overflow to scroll into the text stays lower. + scroller.scrollTop += reference.getBoundingClientRect().top - before; + return true; + } + + /** + * Take the inset away when the bar closes. Scrolled past it, that is + * invisible: the padding goes and the scroll position drops by as much. + * Short of it the text sits lower than its resting place and glides up over + * `--dur-fast` (it jumps under reduced motion). + */ + release() { + this.finishGlide?.(); + const { scroller, reference, inset } = this; + this.scroller = null; + this.reference = null; + this.inset = 0; + if (!scroller || inset === 0) return; + const restore = this.inlinePadding; + if (!scroller.isConnected) { + scroller.style.paddingTop = restore; + return; + } + + const anchor = reference?.isConnected ? reference : null; + const before = anchor?.getBoundingClientRect().top ?? 0; + const pastInset = scroller.scrollTop >= inset - 0.5; + if (pastInset || prefersReducedMotion() || !anchor) { + scroller.style.paddingTop = restore; + if (anchor && pastInset) scroller.scrollTop += anchor.getBoundingClientRect().top - before; + return; + } + + // Hold the text where it is on screen at the smallest padding that does it + // (everything above the reading position, nothing of the scroll offset)... + scroller.style.paddingTop = `${this.basePadding + inset - scroller.scrollTop}px`; + scroller.scrollTop += anchor.getBoundingClientRect().top - before; + void scroller.offsetHeight; + // ...then let it settle into its resting place. + const done = () => { + window.clearTimeout(net); + scroller.removeEventListener('transitionend', onEnd); + scroller.style.transition = ''; + this.finishGlide = null; + }; + const onEnd = (event: TransitionEvent) => { + if (event.target === scroller && event.propertyName === 'padding-top') done(); + }; + const net = window.setTimeout(done, 400); + scroller.addEventListener('transitionend', onEnd); + this.finishGlide = () => { + scroller.style.paddingTop = restore; + done(); + }; + scroller.style.transition = 'padding-top var(--dur-fast, 125ms) var(--ease-out, ease-out)'; + scroller.style.paddingTop = restore; + } +} + interface MatchRecord { node: Text; startOffset: number; @@ -536,6 +704,7 @@ export class SearchHighlighter { private currentTerm: string = ''; private caseSensitive: boolean = false; private onMatchesChange?: (count: number) => void; + private readonly clearance: FindClearance | null; private currentMatchIndex: number = -1; private isScrollingProgrammatically: boolean = false; private highlightTimeout?: ReturnType; @@ -545,10 +714,41 @@ export class SearchHighlighter { this.updateHighlightPositions(); } }; + /** Where the smooth scroll of ours that is under way will land; null when none is. */ + private smoothLanding: number | null = null; + /** + * A smooth scroll of ours ended: stop ignoring the scroll events the user + * makes. ⚠ `scrollend` also ends the INSTANT scroll that made room for the + * match (see {@link FindClearance.makeRoom}), and that one is dispatched after + * the smooth scroll has been asked for, so the first `scrollend` is not + * proof the second is over. It is over when the scroller is where it was sent. + */ + private readonly handleScrollEnd = () => { + const scroller = this.scrollContainer; + if ( + scroller && + this.smoothLanding !== null && + Math.abs(scroller.scrollTop - this.smoothLanding) > 1 + ) { + scroller.addEventListener('scrollend', this.handleScrollEnd, { once: true }); + return; + } + this.releaseScrollHold(); + }; - constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) { + /** + * `clearance`: the find session's hold on the bar floating over the + * transcript. Without it (a list that filters, a bare mount) the current + * match is centred in the whole scroller, as it always was. + */ + constructor( + container: HTMLElement, + onMatchesChange?: (count: number) => void, + clearance?: FindClearance + ) { this.container = container; this.onMatchesChange = onMatchesChange; + this.clearance = clearance ?? null; // Create overlay this.overlay = document.createElement('div'); @@ -816,33 +1016,99 @@ export class SearchHighlighter { // Only scroll if explicitly requested if (shouldScroll && this.scrollContainer) { const firstHighlight = highlightElements[0] as HTMLElement; - if (firstHighlight) { - // Calculate the target scroll position - const containerRect = this.scrollContainer.getBoundingClientRect(); - const highlightRect = firstHighlight.getBoundingClientRect(); - - // Calculate the target position that would center the highlight - const targetScrollTop = - this.scrollContainer.scrollTop + - (highlightRect.top - containerRect.top) - - (containerRect.height - highlightRect.height) / 2; + if (firstHighlight) this.scrollToMark(this.scrollContainer, firstHighlight); + } + } + /** + * Scroll `scroller` so the current match sits in the middle of the part of it + * the find bar leaves free, and clear of the bar even where the scroller + * cannot scroll far enough up to put it there (see {@link FindClearance}). + */ + private scrollToMark(scroller: HTMLElement, mark: HTMLElement) { + const area = scroller.getBoundingClientRect(); + let box = mark.getBoundingClientRect(); + + // The free part starts under the bar (and its gap), but never past the + // scroller's middle: a cell too short for the bar still centres somewhere. + const bar = this.clearance?.barBox(scroller) ?? null; + const freeTop = bar + ? Math.min(Math.max(area.top, bar.bottom + FIND_BAR_GAP), area.top + area.height / 2) + : area.top; + const centred = (rect: DOMRect) => + scroller.scrollTop + (rect.top - freeTop) - (area.bottom - freeTop - rect.height) / 2; + + let target = centred(box); + if (bar) { + // Where the match will sit once the scroll has landed, clamped as the + // scroller clamps it. Short of the free edge AND under the bar's span: + // make the room above the content and measure again. + const farthest = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const lands = Math.min(Math.max(target, 0), farthest); + const sits = box.top - (lands - scroller.scrollTop); + const underBar = box.right > bar.left && box.left < bar.right; + const short = freeTop - sits; + if (underBar && short >= 0.5 && this.clearance?.makeRoom(scroller, short, this.container)) { + // The inset moved the text in the layout; the marks are painted from + // the old one. this.holdScrollUpdates(); - - // Perform the scroll - this.scrollContainer.scrollTop = targetScrollTop; + this.updateHighlightPositions(); + const fresh = this.highlights[this.currentMatchIndex]?.querySelector('.search-highlight'); + if (fresh) { + box = fresh.getBoundingClientRect(); + target = centred(box); + } } } + + this.scrollContainerTo(scroller, target); + } + + /** + * Bring the current match into view: smoothly, except under reduced motion + * (spec 3.16), where it jumps as it always did. + * + * It also jumps when the smooth scroll would have nowhere to go (the + * container is already at the clamped target, so no scroll event and no + * `scrollend` would ever arrive to end the hold) and where the element has no + * `scrollTo` (jsdom). + */ + private scrollContainerTo(scroller: HTMLElement, top: number) { + const farthest = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const lands = Math.min(Math.max(top, 0), farthest); + const smooth = + typeof scroller.scrollTo === 'function' && + !prefersReducedMotion() && + Math.abs(lands - scroller.scrollTop) >= 1; + + if (!smooth) { + this.holdScrollUpdates(); + scroller.scrollTop = top; + return; + } + + // A smooth scroll fires scroll events for as long as it runs, which is + // longer than the 100ms an instant one needs. `scrollend` ends the hold + // exactly; the timer is only the net for a browser that never sends it. + this.holdScrollUpdates(SMOOTH_SCROLL_NET_MS); + this.smoothLanding = lands; + scroller.addEventListener('scrollend', this.handleScrollEnd, { once: true }); + scroller.scrollTo({ top, behavior: 'smooth' }); } /** Ignore the scroll events our own scrolling is about to fire. */ - private holdScrollUpdates() { + private holdScrollUpdates(ms = INSTANT_SCROLL_HOLD_MS) { + this.releaseScrollHold(); this.isScrollingProgrammatically = true; + this.scrollEndTimeout = setTimeout(() => this.releaseScrollHold(), ms); + } + + private releaseScrollHold() { if (this.scrollEndTimeout) clearTimeout(this.scrollEndTimeout); - this.scrollEndTimeout = setTimeout(() => { - this.scrollEndTimeout = undefined; - this.isScrollingProgrammatically = false; - }, 100); + this.scrollEndTimeout = undefined; + this.smoothLanding = null; + this.scrollContainer?.removeEventListener('scrollend', this.handleScrollEnd); + this.isScrollingProgrammatically = false; } /** @@ -901,9 +1167,7 @@ export class SearchHighlighter { if (this.highlightTimeout) { clearTimeout(this.highlightTimeout); } - if (this.scrollEndTimeout) { - clearTimeout(this.scrollEndTimeout); - } + this.releaseScrollHold(); this.overlayHost.removeEventListener('scroll', this.handleScroll, { capture: true }); this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); diff --git a/ui/desktop/src/utils/sessionListCache.head.test.ts b/ui/desktop/src/utils/sessionListCache.head.test.ts new file mode 100644 index 000000000..70f66aa0b --- /dev/null +++ b/ui/desktop/src/utils/sessionListCache.head.test.ts @@ -0,0 +1,350 @@ +/** + * V1-R3-2: History opened on the full listing of every chat on the machine, 0.7 + * to 1 s on a store of 5,542, to show sixteen rows. It now reads the newest page + * and lays it over the cache (`refreshSessionListHead`); the rows after the page + * keep what the cache held. + * + * What these pin is what makes that safe: the page is merged by the daemon's own + * order, a chat that is gone from the stretch the page covers goes with it, a + * rename or a raise that outran the page is not undone, a head read and a full + * read never settle against the same facts at once, and a cold cache is never + * given a partial list. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../api'; +import { + SESSION_LIST_HEAD_SIZE, + SESSION_LIST_STALE_AFTER_MS, + clearSessionListCache, + getCachedSessionList, + mergeSessionListHead, + refreshSessionList, + refreshSessionListHead, + subscribeSessionList, +} from './sessionListCache'; +import { announceSessionName } from './sessionNameSync'; +import { announceSessionRowChanged } from './sessionRowSync'; + +const mocks = vi.hoisted(() => ({ + listSessions: vi.fn(), + updateSessionName: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock('../api', () => ({ + listSessions: mocks.listSessions, + updateSessionName: mocks.updateSessionName, + getSession: mocks.getSession, +})); + +vi.mock('./userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + +/** `n` seconds after a fixed instant: a larger `n` is a NEWER chat. */ +const at = (n: number) => new Date(Date.UTC(2026, 9, 11, 12, 0, n)).toISOString(); +const chat = (id: string, n: number, extra: Partial = {}): Session => + ({ + id, + name: `Chat ${id}`, + user_set_name: false, + working_dir: '/x', + created_at: at(n), + updated_at: at(n), + message_count: 2, + privacy_tier: 'public', + privacy_reason: null, + ...extra, + }) as Session; + +const ids = (rows: Session[] | null | undefined) => (rows ?? []).map((row) => row.id); +const page = (sessions: Session[], hasMore: boolean) => ({ + data: hasMore ? { sessions, has_more: true, next_cursor: 'next' } : { sessions }, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + clearSessionListCache(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('mergeSessionListHead', () => { + const cached = [chat('a', 50), chat('b', 40), chat('c', 30), chat('d', 20), chat('e', 10)]; + + it('lays the page over the cache and keeps the rows after it', () => { + const head = [chat('c', 60, { name: 'Renamed' }), chat('a', 50)]; + const merged = mergeSessionListHead(cached, head); + expect(ids(merged)).toEqual(['c', 'a', 'b', 'd', 'e']); + expect(merged[0].name).toBe('Renamed'); + // The rows after the page are the cache's own objects, so a row that did not + // change does not redraw. + expect(merged[2]).toBe(cached[1]); + expect(merged[4]).toBe(cached[4]); + }); + + it('drops a cached row the page covers but does not hold', () => { + // 'b' sorts before the page's last row (c, at 30) yet is not in the page: + // the daemon no longer lists it (deleted, or private to this caller). + const merged = mergeSessionListHead(cached, [chat('a', 50), chat('x', 45), chat('c', 30)]); + expect(ids(merged)).toEqual(['a', 'x', 'c', 'd', 'e']); + }); + + it('keeps a row this window changed while the page was out, though the page lacks it', () => { + // 'b' was renamed after the page was issued, so its cached time is past the page. + const renamed = [chat('b', 90, { name: 'Renamed' }), ...cached.filter((s) => s.id !== 'b')]; + const merged = mergeSessionListHead(renamed, [chat('a', 50), chat('c', 30)], new Set(['b'])); + expect(ids(merged)).toEqual(['a', 'c', 'b', 'd', 'e']); + expect(ids(mergeSessionListHead(renamed, [chat('a', 50), chat('c', 30)]))).toEqual([ + 'a', + 'c', + 'd', + 'e', + ]); + }); + + it('breaks a tie on the last row by id, the way the daemon orders', () => { + const tied = [chat('b1', 40), chat('b3', 40), chat('b5', 40), chat('z', 10)]; + // The page ends at b3: b5 sorts after it, b1 before it (so it is gone). + const merged = mergeSessionListHead(tied, [chat('b3', 40, { name: 'new' })]); + expect(ids(merged)).toEqual(['b3', 'b5', 'z']); + }); + + it('keeps a cached row whose date cannot be read', () => { + const odd = [chat('a', 50), chat('odd', 0, { updated_at: 'garbage' }), chat('z', 5)]; + expect(ids(mergeSessionListHead(odd, [chat('a', 50)]))).toEqual(['a', 'odd', 'z']); + }); + + it('is the page alone when the page is the whole list', () => { + expect(mergeSessionListHead(cached, [])).toEqual([]); + }); +}); + +describe('refreshSessionListHead', () => { + it('asks for the newest page only, with the proof, and lays it over a warm cache', async () => { + mocks.listSessions.mockResolvedValueOnce( + page([chat('a', 50), chat('b', 40), chat('c', 30), chat('d', 20)], false) + ); + await refreshSessionList(); + const tail = getCachedSessionList()!.slice(1); + + mocks.listSessions.mockResolvedValueOnce( + page([chat('c', 70, { name: 'Moved up' }), chat('a', 50)], true) + ); + const heard = vi.fn(); + const unsubscribe = subscribeSessionList(heard); + const head = await refreshSessionListHead(); + unsubscribe(); + + expect(mocks.listSessions).toHaveBeenLastCalledWith( + expect.objectContaining({ + headers: { 'X-User-Action': 'test-proof' }, + query: { include_subagents: false, limit: SESSION_LIST_HEAD_SIZE }, + }) + ); + expect(head.partial).toBe(false); + expect(ids(head.sessions)).toEqual(['c', 'a', 'b', 'd']); + expect(head.sessions[0].name).toBe('Moved up'); + // …in the cache too, and announced once, so every reader sees the same list. + expect(getCachedSessionList()).toBe(head.sessions); + expect(heard).toHaveBeenCalledTimes(1); + expect(head.sessions[2]).toBe(tail[0]); + expect(head.sessions[3]).toBe(tail[2]); + }); + + it('takes a page that is the whole list as a full read, whatever the daemon says of paging', async () => { + // A short store, or a daemon that has never heard of `limit`: no `has_more`. + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + mocks.listSessions.mockResolvedValueOnce(page([chat('b', 60)], false)); + + const head = await refreshSessionListHead(); + expect(ids(head.sessions)).toEqual(['b']); + expect(ids(getCachedSessionList())).toEqual(['b']); + expect(head.partial).toBe(false); + }); + + it('never hands a cold cache a partial list, and starts the read that fills it', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], true)); + const fullList = [chat('a', 50), chat('b', 40), chat('c', 30), chat('d', 20)]; + mocks.listSessions.mockResolvedValueOnce(page(fullList, false)); + + const head = await refreshSessionListHead(); + expect(head).toMatchObject({ partial: true }); + expect(ids(head.sessions)).toEqual(['a', 'b']); + // The cache is the full list's to fill: Home, the tab strip and the sidebar + // read it and expect every chat. + expect(getCachedSessionList()).toBeNull(); + + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + expect(mocks.listSessions.mock.calls[1][0].query).toEqual({ include_subagents: false }); + await vi.waitFor(() => expect(ids(getCachedSessionList())).toEqual(ids(fullList))); + }); + + it('reads the whole list when sub-agent runs are asked for, and when the identity changes', async () => { + mocks.listSessions.mockResolvedValue(page([chat('a', 50), chat('child', 45)], false)); + + const withChildren = await refreshSessionListHead(true); + expect(withChildren.partial).toBe(false); + expect(mocks.listSessions).toHaveBeenLastCalledWith( + expect.objectContaining({ query: { include_subagents: true } }) + ); + + // Back to the default list: a change of identity, so the full read decides it. + await refreshSessionListHead(false); + expect(mocks.listSessions).toHaveBeenLastCalledWith( + expect.objectContaining({ query: { include_subagents: false } }) + ); + }); + + it('waits for a full read already in flight instead of racing it', async () => { + const full = deferred(); + mocks.listSessions.mockReturnValueOnce(full.promise); + const inFlight = refreshSessionList(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(1)); + + const head = refreshSessionListHead(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mocks.listSessions).toHaveBeenCalledTimes(1); + + full.resolve(page([chat('a', 50)], false)); + await inFlight; + expect(ids((await head).sessions)).toEqual(['a']); + expect(mocks.listSessions).toHaveBeenCalledTimes(1); + }); + + it('puts a full read behind a head read in flight, so the two never share one set of facts', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + + const headAnswer = deferred(); + mocks.listSessions.mockReturnValueOnce(headAnswer.promise); + const head = refreshSessionListHead(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + + // A membership change asks for a full read while the head read is out. + mocks.listSessions.mockResolvedValueOnce( + page([chat('n', 90), chat('a', 50), chat('b', 40)], false) + ); + const full = refreshSessionList(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mocks.listSessions).toHaveBeenCalledTimes(2); + + headAnswer.resolve(page([chat('a', 55), chat('b', 40)], false)); + await head; + expect(ids(await full)).toEqual(['n', 'a', 'b']); + expect(mocks.listSessions).toHaveBeenCalledTimes(3); + // The later read is the one the cache holds. + expect(ids(getCachedSessionList())).toEqual(['n', 'a', 'b']); + }); + + it('keeps a rename announced while the page was in flight, and moves the chat up', async () => { + mocks.listSessions.mockResolvedValueOnce( + page([chat('a', 50), chat('b', 40), chat('c', 30)], false) + ); + await refreshSessionList(); + + const answer = deferred(); + mocks.listSessions.mockReturnValueOnce(answer.promise); + const head = refreshSessionListHead(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + + // A saved rename of a chat that is NOT in the page (it sits in the tail). + announceSessionName({ + sessionId: 'c', + name: 'Saved rename', + userSetName: true, + origin: 'user', + activityAt: at(80), + }); + answer.resolve(page([chat('a', 50), chat('b', 40)], true)); + const result = await head; + + expect(ids(result.sessions)).toEqual(['c', 'a', 'b']); + expect(result.sessions[0]).toMatchObject({ name: 'Saved rename', user_set_name: true }); + }); + + it('does not draw a chat public that a raise announced while the page was in flight made private', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + + const answer = deferred(); + mocks.listSessions.mockReturnValueOnce(answer.promise); + const head = refreshSessionListHead(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + + mocks.getSession.mockResolvedValue({ + data: { id: 'a', privacy_tier: 'private', privacy_reason: 'turn:versa_azure' }, + }); + announceSessionRowChanged('a'); + await vi.waitFor(() => + expect(getCachedSessionList()?.[0]).toMatchObject({ privacy_tier: 'private' }) + ); + + // The page was issued before the raise and still says public. + answer.resolve(page([chat('a', 50), chat('b', 40)], false)); + const result = await head; + expect(result.sessions[0]).toMatchObject({ id: 'a', privacy_tier: 'private' }); + }); + + it('publishes nothing when the cache was cleared while the page was in flight', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + + const answer = deferred(); + mocks.listSessions.mockReturnValueOnce(answer.promise); + const head = refreshSessionListHead(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + + clearSessionListCache(); + answer.resolve(page([chat('z', 99)], true)); + await head; + expect(getCachedSessionList()).toBeNull(); + }); + + describe('the rows after the page', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + vi.setSystemTime(new Date('2026-10-11T12:00:00Z')); + }); + + it('are re-read in the background once they have gone unchecked for a minute', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + + vi.setSystemTime(Date.now() + SESSION_LIST_STALE_AFTER_MS + 1); + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 55)], true)); + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 55)], false)); + await refreshSessionListHead(); + expect(mocks.listSessions).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(2_000); + expect(mocks.listSessions).toHaveBeenCalledTimes(3); + // The quiet read is the whole listing: no `limit`. + expect(mocks.listSessions.mock.calls[2][0].query).toEqual({ include_subagents: false }); + expect(ids(getCachedSessionList())).toEqual(['a']); + }); + + it('are left alone on a quick revisit', async () => { + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 50), chat('b', 40)], false)); + await refreshSessionList(); + + vi.setSystemTime(Date.now() + SESSION_LIST_STALE_AFTER_MS - 5_000); + mocks.listSessions.mockResolvedValueOnce(page([chat('a', 55)], true)); + await refreshSessionListHead(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(mocks.listSessions).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/ui/desktop/src/utils/sessionListCache.ts b/ui/desktop/src/utils/sessionListCache.ts index c65f48311..10c3ca3a0 100644 --- a/ui/desktop/src/utils/sessionListCache.ts +++ b/ui/desktop/src/utils/sessionListCache.ts @@ -1,6 +1,6 @@ import { listSessions, type Session } from '../api'; import { userActionHeaders } from './userAction'; -import { subscribeSessionNameChanges } from './sessionNameSync'; +import { laterActivity, subscribeSessionNameChanges } from './sessionNameSync'; import { settleRowsReadDuringFetch, subscribeSessionRowChanges, @@ -9,6 +9,16 @@ import { let cachedSessions: Session[] | null = null; let inFlightRequest: Promise | null = null; +// The newest-rows read (`refreshSessionListHead`). It never overlaps a full +// read: a head read waits for one in flight, and a full read waits for a head +// read, because both settle against the same "published while in flight" maps +// below and a read that cleared them under the other would lose a rename or a +// raise the other still has to apply. +let inFlightHead: Promise | null = null; +// When a full read last landed (epoch ms). A head read leaves the rows after +// its page as the cache held them, so this is how long they have gone unchecked. +let lastFullReadAt = 0; +let staleReadTimer: ReturnType | null = null; // BR-71: the `include_subagents` query key is part of the cache identity, or a // toggle serves the stale list and never refetches. let cachedIncludeSubagents = false; @@ -43,22 +53,62 @@ function emitChange(): void { * with thousands of chats the list is easily the slower of the two. * * Recorded only while a request is outstanding, and cleared by the request that - * consumes them, so the map cannot grow. + * consumes them, so the map cannot grow. A saved rename's `activityAt` is kept + * with its name, because a list issued before the rename also carries the old + * place in the order (R-30). */ -const namesPublishedDuringFetch = new Map(); +const namesPublishedDuringFetch = new Map< + string, + { name: string; userSetName: boolean; activityAt?: string } +>(); + +/** + * Put a renamed chat where its activity now puts it (R-30: a saved rename is + * activity). The list is in the daemon's order, `updated_at` newest first, and + * History keeps that order inside each date bucket, so the row is lifted to + * just above the first row that is not newer than it, with its `updated_at` + * raised to the rename. A row the list does not hold is left to the re-read. + */ +export function placeRenamedSession( + sessions: Session[], + sessionId: string, + activityAt: string +): Session[] { + const index = sessions.findIndex((session) => session.id === sessionId); + if (index === -1) return sessions; + const row = sessions[index]; + const updatedAt = laterActivity(row.updated_at, activityAt); + const at = Date.parse(updatedAt); + if (Number.isNaN(at)) return sessions; + const rest = sessions.filter((_, i) => i !== index); + const before = rest.findIndex((session) => !(Date.parse(session.updated_at) > at)); + const insertAt = before === -1 ? rest.length : before; + return [...rest.slice(0, insertAt), { ...row, updated_at: updatedAt }, ...rest.slice(insertAt)]; +} // A session rename rides the name channel, not the list channel — but the // See-all view and Home recents read THIS cache, so patch the cached name when // one arrives. Without this a rename made in the tab pill or the sidebar never // reached those two surfaces until they remounted, and the same session showed // two different names in two panels at once. -subscribeSessionNameChanges(({ sessionId, name, userSetName }) => { - if (inFlightRequest) namesPublishedDuringFetch.set(sessionId, { name, userSetName }); +// +// A rename the daemon has SAVED (`activityAt`) also moves the chat (R-30): +// History and Home recents show it at the top at once, with no request. A chat +// the cache does not hold yet (a list read before the chat had its first +// message) is brought in by re-reading the list instead. +subscribeSessionNameChanges(({ sessionId, name, userSetName, activityAt }) => { + if (inFlightRequest || inFlightHead) { + namesPublishedDuringFetch.set(sessionId, { name, userSetName, activityAt }); + } if (!cachedSessions) return; const idx = cachedSessions.findIndex((s) => s.id === sessionId); - if (idx === -1) return; - const next = cachedSessions.slice(); + if (idx === -1) { + if (activityAt) void refreshSessionList().catch(() => undefined); + return; + } + let next = cachedSessions.slice(); next[idx] = { ...next[idx], name, user_set_name: userSetName }; + if (activityAt) next = placeRenamedSession(next, sessionId, activityAt); cachedSessions = next; emitChange(); }); @@ -84,7 +134,7 @@ const rowsReadDuringFetch = new Map(); // the list is still right. subscribeSessionRowChanges((facts) => { const { sessionId, privacy_tier, privacy_reason } = facts; - if (inFlightRequest) rowsReadDuringFetch.set(sessionId, facts); + if (inFlightRequest || inFlightHead) rowsReadDuringFetch.set(sessionId, facts); if (!cachedSessions) return; const idx = cachedSessions.findIndex((s) => s.id === sessionId); if (idx === -1) return; @@ -194,10 +244,13 @@ export function notifySessionListChanged(change: SessionListChange = {}): void { /** Re-apply the renames that outran this response. See {@link namesPublishedDuringFetch}. */ function applyNamesPublishedDuringFetch(sessions: Session[]): Session[] { if (namesPublishedDuringFetch.size === 0) return sessions; - const next = sessions.map((session) => { + let next = sessions.map((session) => { const later = namesPublishedDuringFetch.get(session.id); return later ? { ...session, name: later.name, user_set_name: later.userSetName } : session; }); + for (const [sessionId, { activityAt }] of namesPublishedDuringFetch) { + if (activityAt) next = placeRenamedSession(next, sessionId, activityAt); + } namesPublishedDuringFetch.clear(); return next; } @@ -231,6 +284,7 @@ export async function refreshSessionList(includeSubagents?: boolean): Promise + const send = () => { + // This request's answer supersedes every name it is about to carry — except + // the ones announced from HERE on. See {@link namesPublishedDuringFetch}. + namesPublishedDuringFetch.clear(); + rowsReadDuringFetch.clear(); + return userActionHeaders().then((headers) => listSessions({ throwOnError: true, headers, query: { include_subagents: issuedFor }, }) - ) + ); + }; + // Behind a head read in flight, never beside it. See {@link inFlightHead}. + const behindHead = inFlightHead; + inFlightRequest = (behindHead ? behindHead.then(send, send) : send()) .then((response) => { // Superseded while in flight: hand the answer back to whoever awaited // this exact call, but publish nothing — the cache and its subscribers @@ -266,6 +324,7 @@ export async function refreshSessionList(includeSubagents?: boolean): Promise = new Set() +): Session[] { + const last = head[head.length - 1]; + if (!last) return head; + const headIds = new Set(head.map((session) => session.id)); + const boundary = Date.parse(last.updated_at); + const tail = cached.filter((session) => { + if (headIds.has(session.id)) return false; + if (keep.has(session.id)) return true; + const at = Date.parse(session.updated_at); + if (Number.isNaN(at) || Number.isNaN(boundary)) return true; + // The daemon's order is `updated_at` newest first, then `id` ascending. + return at < boundary || (at === boundary && session.id > last.id); + }); + return head.concat(tail); +} + +function scheduleStaleRead(): void { + if (staleReadTimer !== null) return; + if (Date.now() - lastFullReadAt < SESSION_LIST_STALE_AFTER_MS) return; + staleReadTimer = setTimeout(() => { + staleReadTimer = null; + void refreshSessionList().catch(() => undefined); + }, STALE_READ_DELAY_MS); +} + +/** Settle a head page against what was published while it was in flight. */ +function settleHead(page: Session[], more: boolean): SessionListHead { + const settle = (rows: Session[]) => + settleRowsReadDuringFetch(applyNamesPublishedDuringFetch(rows), rowsReadDuringFetch, true); + if (!more) { + // The page IS the list: a short store, or a daemon that ignores `limit`. + cachedSessions = settle(page); + lastFullReadAt = Date.now(); + emitChange(); + return { sessions: cachedSessions, partial: false }; + } + if (cachedSessions === null) { + // Cold (the first sidebar read has not landed, or the cache was cleared). A + // page of the newest rows cannot be the cache that Home, the tab strip and + // the sidebar read, so it is handed back alone and the full read is started. + const rows = settle(page); + void refreshSessionList().catch(() => undefined); + return { sessions: rows, partial: true }; + } + cachedSessions = settle( + mergeSessionListHead(cachedSessions, page, new Set(namesPublishedDuringFetch.keys())) + ); + emitChange(); + scheduleStaleRead(); + return { sessions: cachedSessions, partial: false }; +} + +/** + * Read the newest rows of the listing and lay them over the cache: the fast way + * to open History on a list that is current at the top (V1-R3-2). + * + * The full read is one request for every chat on the machine (5,542 on the + * store this was measured on: 6 MB, 0.5 to 0.8 s before the rows could be + * drawn), and almost all of it is old chats that nothing has touched. A chat + * only moves in the order when something happens in it (R-30), and anything that + * happens in it puts it among the newest, so the newest page is where a change + * shows up. The rows after it keep what the cache held, which is the last full + * read plus every rename, privacy change and removal this window was told of. + * + * Not the head of the identity this cache holds? A list with sub-agent runs + * nests each under its parent, which can sit anywhere in the order, so that and + * any change of identity go to {@link refreshSessionList}. A full read already + * in flight is waited for rather than raced. + */ +export async function refreshSessionListHead(includeSubagents?: boolean): Promise { + const wanted = includeSubagents ?? cachedIncludeSubagents; + if (wanted || wanted !== cachedIncludeSubagents) { + return { sessions: await refreshSessionList(includeSubagents), partial: false }; + } + if (inFlightRequest) return { sessions: await inFlightRequest, partial: false }; + if (inFlightHead) return inFlightHead; + + const generation = requestGeneration; + namesPublishedDuringFetch.clear(); + rowsReadDuringFetch.clear(); + const request: Promise = userActionHeaders() + .then((headers) => + listSessions({ + throwOnError: true, + headers, + query: { include_subagents: false, limit: SESSION_LIST_HEAD_SIZE }, + }) + ) + .then((response) => { + const page = response.data.sessions; + const more = response.data.has_more === true; + // Superseded while in flight: hand the page back, publish nothing. + if (generation !== requestGeneration) return { sessions: page, partial: more }; + return settleHead(page, more); + }) + .finally(() => { + if (inFlightHead === request) inFlightHead = null; + }); + inFlightHead = request; + return request; +} + export function preloadSessionList(): void { if (cachedSessions !== null) return; void refreshSessionList().catch(() => undefined); @@ -284,6 +493,10 @@ export function preloadSessionList(): void { export function clearSessionListCache(): void { cachedSessions = null; inFlightRequest = null; + inFlightHead = null; + lastFullReadAt = 0; + if (staleReadTimer !== null) clearTimeout(staleReadTimer); + staleReadTimer = null; cachedIncludeSubagents = false; // Same orphaning as a flag change: a request issued before the clear must not // repopulate the cache we just emptied. diff --git a/ui/desktop/src/utils/sessionNameSync.optimistic.test.ts b/ui/desktop/src/utils/sessionNameSync.optimistic.test.ts new file mode 100644 index 000000000..2f7fae1bf --- /dev/null +++ b/ui/desktop/src/utils/sessionNameSync.optimistic.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../api', () => ({ + updateSessionName: vi.fn(async () => ({ data: {} })), +})); + +vi.mock('./userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + +vi.mock('../toasts', () => ({ + toastError: vi.fn(), +})); + +import { updateSessionName } from '../api'; +import { toastError } from '../toasts'; +import { + normalizeSessionName, + RENAME_FAILED_TOAST_TITLE, + renameSessionOptimistically, + SESSION_NAME_MAX_LENGTH, + subscribeSessionNameChanges, + type SessionNameChange, +} from './sessionNameSync'; + +const previous = { name: 'Old name', userSetName: false }; + +function recordChanges() { + const changes: SessionNameChange[] = []; + const unsubscribe = subscribeSessionNameChanges((change) => changes.push(change)); + return { changes, unsubscribe }; +} + +describe('renameSessionOptimistically', () => { + beforeEach(() => { + vi.mocked(updateSessionName).mockReset(); + vi.mocked(updateSessionName).mockResolvedValue({ data: {} } as never); + vi.mocked(toastError).mockClear(); + }); + + it('announces the new name first, then saves it with the user proof', async () => { + const { changes, unsubscribe } = recordChanges(); + const outcome = await renameSessionOptimistically('s1', ' New name ', previous); + unsubscribe(); + + expect(outcome).toBe('renamed'); + expect(changes[0]).toEqual({ + sessionId: 's1', + name: 'New name', + userSetName: true, + origin: 'user', + }); + expect(updateSessionName).toHaveBeenCalledWith({ + path: { session_id: 's1' }, + body: { name: 'New name' }, + headers: { 'X-User-Action': 'test-proof' }, + throwOnError: true, + }); + expect(toastError).not.toHaveBeenCalled(); + }); + + it('makes no call for an empty or unchanged name', async () => { + expect(await renameSessionOptimistically('s1', ' ', previous)).toBe('unchanged'); + expect(await renameSessionOptimistically('s1', ' Old name ', previous)).toBe('unchanged'); + expect(updateSessionName).not.toHaveBeenCalled(); + }); + + it('rolls back to the previous name and shows the daemon sentence on a refusal', async () => { + const refusal = 'This chat is private. Rename it from a Biorouter window.'; + vi.mocked(updateSessionName).mockRejectedValueOnce(refusal); + const { changes, unsubscribe } = recordChanges(); + + const outcome = await renameSessionOptimistically('s1', 'New name', previous); + unsubscribe(); + + expect(outcome).toBe('failed'); + expect(changes.map((change) => [change.name, change.origin])).toEqual([ + ['New name', 'user'], + ['Old name', 'sync'], + ]); + expect(toastError).toHaveBeenCalledWith({ title: RENAME_FAILED_TOAST_TITLE, msg: refusal }); + }); + + it('uses the error message when the failure is an Error', async () => { + vi.mocked(updateSessionName).mockRejectedValueOnce(new Error('http 500')); + await renameSessionOptimistically('s1', 'New name', previous); + expect(toastError).toHaveBeenCalledWith({ title: RENAME_FAILED_TOAST_TITLE, msg: 'http 500' }); + }); +}); + +describe('normalizeSessionName', () => { + it('turns pasted newlines into spaces and trims', () => { + expect(normalizeSessionName(' one\ntwo\r\nthree ')).toBe('one two three'); + }); + + it('caps the name at the server limit', () => { + expect(normalizeSessionName('x'.repeat(SESSION_NAME_MAX_LENGTH + 20))).toHaveLength( + SESSION_NAME_MAX_LENGTH + ); + }); +}); diff --git a/ui/desktop/src/utils/sessionNameSync.ts b/ui/desktop/src/utils/sessionNameSync.ts index 522ddc3a4..0e9bfb374 100644 --- a/ui/desktop/src/utils/sessionNameSync.ts +++ b/ui/desktop/src/utils/sessionNameSync.ts @@ -21,13 +21,17 @@ // data continues to read correctly. // 3. `renameSession` — optimistic API call + cache update + cross-window // broadcast. Callers do not need to coordinate cache invalidation or -// sibling-window updates themselves. +// sibling-window updates themselves. Once the daemon has saved the name +// the broadcast carries `activityAt`, and the lists sorted by activity +// move the chat to the top at once (R-30: a rename is activity). // 4. `subscribeSessionNameChanges` — fan-out for sibling windows // (history list, other open windows, the chat tab) to receive // updates without re-fetching. import type { Message, Session } from '../api'; import { updateSessionName } from '../api'; +import { toastError } from '../toasts'; +import { errorMessage } from './conversionUtils'; import { userActionHeaders } from './userAction'; export const DEFAULT_SESSION_NAME = 'New chat'; @@ -112,6 +116,29 @@ export interface SessionNameChange { /** Where the rename originated. Used so the originating window can ignore * its own broadcast and avoid a no-op re-render. */ origin: 'user' | 'llm' | 'sync'; + /** + * Set only once the daemon has SAVED the rename, to the moment it did (an ISO + * string). A saved rename is activity in the chat (R-30): the daemon raises + * its `updated_at`, so under "Last activity" the chat belongs at the top. The + * lists that sort by activity (the sidebar, History, Home recents) move the + * row when they see this, in this window and in every sibling window, instead + * of waiting for an unrelated refresh. The optimistic announcement and a + * rollback carry none: neither is a rename the daemon saved. + */ + activityAt?: string; +} + +/** + * The later of a row's `updated_at` and a saved rename's `activityAt`, as the + * string that carries it. A row a re-read already brought with the daemon's + * own, newer time keeps it; anything unreadable leaves the row as it was. + */ +export function laterActivity(updatedAt: string, activityAt: string | undefined): string { + if (!activityAt) return updatedAt; + const current = Date.parse(updatedAt); + const renamed = Date.parse(activityAt); + if (Number.isNaN(renamed)) return updatedAt; + return Number.isNaN(current) || renamed > current ? activityAt : updatedAt; } type Listener = (change: SessionNameChange) => void; @@ -171,11 +198,16 @@ export async function renameSession( // A user rename always sets user_set_name=true on the backend; an LLM // rename leaves it false. `origin` tells the broadcast which is which so // listeners can decide whether to allow future LLM auto-renames. + // + // `activityAt`: the daemon has saved the name, and a saved rename moved the + // chat's `updated_at` (R-30). The response carries no timestamp, so this is + // the renderer's clock; a list's next re-read carries the daemon's own. broadcast({ sessionId, name: trimmed, userSetName: origin === 'user', origin, + activityAt: new Date().toISOString(), }); return trimmed; @@ -188,3 +220,86 @@ export async function renameSession( export function announceSessionName(change: SessionNameChange): void { broadcast(change); } + +// ── Rename from a person (the one optimistic path) ──────────────────────── + +/** The server caps a chat name at 200 characters (`routes/session.rs`). */ +export const SESSION_NAME_MAX_LENGTH = 200; + +/** The title of the toast a failed rename shows; its message is the daemon's own text. */ +export const RENAME_FAILED_TOAST_TITLE = "Couldn't rename chat"; + +/** What the chat was called before the rename, so a failure can put it back. */ +export interface PreviousSessionName { + name: string; + userSetName: boolean; +} + +/** `renamed`: the name was saved. `unchanged`: nothing to do, no call made. `failed`: rolled back. */ +export type RenameOutcome = 'renamed' | 'unchanged' | 'failed'; + +/** + * Normalise a typed or pasted name: newlines become spaces, the ends are + * trimmed, and the server's length cap is applied so a long paste is saved + * rather than refused. + */ +export function normalizeSessionName(raw: string): string { + return raw + .replace(/[\r\n]+/g, ' ') + .trim() + .slice(0, SESSION_NAME_MAX_LENGTH) + .trim(); +} + +/** + * Rename a chat the way a person expects: the new name shows everywhere at + * once, and a refusal puts the old one back. + * + * Every rename surface (the chat title, a sidebar row, History) calls this one + * function, so they cannot drift: + * 1. Normalise the name. An empty or unchanged name returns `'unchanged'` and + * makes no request. + * 2. Announce it optimistically with origin `'user'`, so the row, the tab and + * every other window update before the round trip. + * 3. Save it through {@link renameSession}, which carries the user's proof + * (`userActionHeaders()`): renaming a private chat without it is refused. + * 4. On failure, announce the previous name with origin `'sync'` and show a + * toast whose message is the daemon's own sentence, so a privacy refusal + * stays readable. There is no success toast: the row is the confirmation. + */ +export async function renameSessionOptimistically( + sessionId: string, + newName: string, + previous: PreviousSessionName | null | undefined +): Promise { + const name = normalizeSessionName(newName); + if (!sessionId || !name || name === previous?.name) return 'unchanged'; + + announceSessionName({ sessionId, name, userSetName: true, origin: 'user' }); + try { + await renameSession(sessionId, name, 'user'); + return 'renamed'; + } catch (err) { + if (previous?.name) { + announceSessionName({ + sessionId, + name: previous.name, + userSetName: previous.userSetName, + origin: 'sync', + }); + } + toastError({ title: RENAME_FAILED_TOAST_TITLE, msg: renameFailureMessage(err) }); + return 'failed'; + } +} + +/** + * The daemon's refusal arrives as its parsed body, a plain string, under + * `throwOnError`; an `Error` carries its own message. Anything else gets a + * sentence rather than `[object Object]`. + */ +function renameFailureMessage(err: unknown): string { + const fallback = 'The chat kept its old name.'; + if (typeof err === 'string') return err.trim() || fallback; + return errorMessage(err, fallback).trim() || fallback; +} diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 3096115e3..facfec26c 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -37,6 +37,12 @@ export interface Settings { * launch, when the window follows the OS like the page does. */ windowCanvasMode?: WindowCanvasMode; + /** + * R-31, Windows: legacy folders whose "older Biorouter folder found" notice + * the person asked not to see again (`noticeHiddenLegacyFolder` in main.ts). + * Keyed by the folder, so a different one is still shown. + */ + dismissedFolderNotices?: string[]; } function getSettingsFile(): string { diff --git a/ui/desktop/src/utils/toolGlyph.test.ts b/ui/desktop/src/utils/toolGlyph.test.ts new file mode 100644 index 000000000..612a83a4f --- /dev/null +++ b/ui/desktop/src/utils/toolGlyph.test.ts @@ -0,0 +1,527 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ENTITY_ICONS } from '../components/icons/entity-icons'; +import { + BUILT_IN_EXTENSION_KEYS, + TOOL_GLYPHS, + extensionGlyphFor, + isBuiltInExtension, + parseToolName, + toolGlyphFor, + toolGlyphKind, + type ToolGlyphKind, +} from './toolGlyph'; + +/** + * Every tool a built-in, platform or in-process extension registers, as + * enumerated from `crates/` on 2026-10-09, with the glyph it must draw. The + * point of the table is the row count: a family that silently falls back to + * the wrench is exactly the defect this module replaced. + */ +const REAL_TOOLS: Record = { + shell: ['developer__shell', 'developer__shell_status', 'developer__shell_kill'], + read: ['files__files_read', 'platform__read_session_blob', 'code_execution__read_module'], + write: ['files__files_write'], + list: ['files__files_list'], + search: ['developer__analyze', 'code_execution__search_modules'], + web: ['webdocuments__web_scrape'], + image: ['developer__image_processor'], + document: ['webdocuments__pdf_tool', 'webdocuments__docx_tool'], + spreadsheet: ['webdocuments__xlsx_tool'], + archive: ['webdocuments__cache'], + code: ['code_execution__execute_code', 'compute__compute_python', 'compute__compute_run'], + knowledge: [ + 'knowledge__kb_search', + 'knowledge__kb_read_page', + 'knowledge__kb_write_page', + 'knowledge__kb_list_bases', + 'knowledge__kb_get_active', + 'knowledge__kb_set_active', + 'knowledge__kb_lint', + 'knowledge__kb_merge', + 'knowledge__kb_begin_txn', + 'knowledge__kb_commit_txn', + 'platform__ingest_conversation', + 'platform__ingest_source', + ], + crew: ['crew__connections', 'crew__request'], + workflow: ['platform__manage_workflow'], + schedule: ['platform__manage_schedule'], + skill: [ + 'skills__searchSkills', + 'skills__loadSkill', + 'skills__searchMarketplaceSkills', + 'skills__installMarketplaceSkill', + 'skills__importSkillPackage', + 'skills__removeSkillPackage', + 'skills__setSkillEnabled', + ], + extension: [ + 'extensionmanager__manage_extensions', + 'extensionmanager__search_available_extensions', + 'extensionmanager__install_extension', + 'extensionmanager__browse_marketplace_extensions', + 'extensionmanager__search_marketplace_extensions', + 'extensionmanager__delete_extension_package', + 'extensionmanager__remove_extension', + 'extensionmanager__list_resources', + 'extensionmanager__read_resource', + ], + app: [ + 'agent_drafter__create_app', + 'agent_drafter__build_app', + 'agent_drafter__list_apps', + 'agent_drafter__preview_app', + 'agent_drafter__export_app', + 'appcontrol__ui_render', + 'appcontrol__ui_ask', + 'evidence__report_evidence', + ], + agent: ['workspace__subagent', 'agent_drafter__consult'], + workspace: [ + 'workspace__workspace_list', + 'workspace__workspace_read_conversation', + 'workspace__workspace_send_prompt', + 'workspace__workspace_set_tools', + 'workspace__workspace_close', + 'workspace__workspace_watch', + 'workspace__workspace_open', + 'workspace__workspace_read_panel', + 'workspace__workspace_capture_panel', + ], + figure: [ + 'autovisualiser__render_figure', + 'autovisualiser__describe_figure', + 'autovisualiser__render_dashboard', + ], + copilot: [ + 'computercontroller__list_apps', + 'computercontroller__get_app_state', + 'computercontroller__click', + 'computercontroller__perform_secondary_action', + 'computercontroller__scroll', + 'computercontroller__drag', + 'computercontroller__type_text', + 'computercontroller__press_key', + 'computercontroller__set_value', + 'computercontroller__screen_capture', + ], + database: ['datasql__data_query', 'datasql__data_sources', 'datasql__data_table'], + memory: [ + 'memory__remember_memory', + 'memory__retrieve_memories', + 'memory__remove_specific_memory', + 'memory__remove_memory_category', + ], + plan: [ + 'todo__todo_write', + 'todo__todo_add', + 'todo__todo_expand', + 'todo__todo_update', + 'todo__plan_write', + ], + recall: ['chatrecall__chatrecall'], + bug: ['platform__report_bug'], + done: ['workflow__final_output'], + unknown: [], +}; + +describe('toolGlyphKind: every real tool draws its own glyph', () => { + for (const [kind, names] of Object.entries(REAL_TOOLS)) { + for (const name of names) { + it(`${name} -> ${kind}`, () => { + expect(toolGlyphKind(name)).toBe(kind); + }); + } + } +}); + +describe('the ladder', () => { + /** + * ⚠ The defect the full-name match exists for: `toolIconMapping.tsx` keyed on + * the bare name, so Agent Drafter's `list_apps` drew Copilot's Monitor. + */ + it('keeps two extensions with the same tool name apart', () => { + expect(toolGlyphKind('agent_drafter__list_apps')).toBe('app'); + expect(toolGlyphKind('computercontroller__list_apps')).toBe('copilot'); + }); + + it('reads text_editor by its command', () => { + expect(toolGlyphKind('developer__text_editor', { command: 'view', path: 'a.rs' })).toBe('read'); + for (const command of ['write', 'str_replace', 'insert', 'undo_edit']) { + expect(toolGlyphKind('developer__text_editor', { command })).toBe('write'); + } + // A pending call whose arguments have not streamed yet is an edit, not a read. + expect(toolGlyphKind('developer__text_editor')).toBe('write'); + expect(toolGlyphKind('developer__text_editor', null)).toBe('write'); + // An older stored message that carries the bare name. + expect(toolGlyphKind('text_editor', { command: 'view' })).toBe('read'); + }); + + it('strips the coding-agent bridge prefix', () => { + expect(parseToolName('mcp__biorouter__developer__shell')).toEqual({ + extension: 'developer', + tool: 'shell', + }); + expect(toolGlyphKind('mcp__biorouter__knowledge__kb_search')).toBe('knowledge'); + expect(parseToolName('exec')).toEqual({ extension: '', tool: 'exec' }); + expect(parseToolName('a__b__c')).toEqual({ extension: 'a', tool: 'b__c' }); + }); + + it("draws a coding-agent child's own tools by what they do", () => { + const child: Record = { + Bash: 'shell', + exec: 'shell', + exec_command: 'shell', + Read: 'read', + Edit: 'write', + Write: 'write', + MultiEdit: 'write', + apply_patch: 'write', + Grep: 'search', + Glob: 'list', + WebFetch: 'web', + WebSearch: 'web', + web_search: 'web', + view_image: 'image', + Task: 'agent', + TodoWrite: 'plan', + update_plan: 'plan', + }; + for (const [name, kind] of Object.entries(child)) expect(toolGlyphKind(name), name).toBe(kind); + }); + + /** + * A child tool's name is only trusted bare: an extension that happens to + * name a tool `Read` keeps its own glyph. + */ + it('never reads an extension tool by a bare child name', () => { + expect(toolGlyphKind('acme__Read')).toBe('extension'); + }); + + /** + * V2-18. A chat saved before the Copilot rename names the web, document and + * cache tools under `computercontroller`; the family row alone would draw a + * web scrape as desktop control. + */ + it('draws a legacy computercontroller web or document call by what it did', () => { + const legacy: Record = { + computercontroller__web_scrape: 'web', + computercontroller__cache: 'archive', + computercontroller__pdf_tool: 'document', + computercontroller__docx_tool: 'document', + computercontroller__xlsx_tool: 'spreadsheet', + computercontroller__automation_script: 'shell', + }; + for (const [name, kind] of Object.entries(legacy)) { + expect(toolGlyphKind(name), name).toBe(kind); + // The bridge prefix of a coding-agent child changes nothing. + expect(toolGlyphKind(`mcp__biorouter__${name}`), name).toBe(kind); + // And each legacy row draws the glyph its `webdocuments` successor draws. + const successor = name.replace('computercontroller__', 'webdocuments__'); + if (!name.endsWith('automation_script')) + expect(toolGlyphKind(successor), successor).toBe(kind); + } + // The tool that was always desktop control, and the card, still say so. + expect(toolGlyphKind('computercontroller__computer_control')).toBe('copilot'); + expect(toolGlyphKind('computercontroller__click')).toBe('copilot'); + expect(extensionGlyphFor('computercontroller').kind).toBe('copilot'); + }); + + /** + * V2-R5-3. The Developer server used to carry `screen_capture` and a window + * list; both left it when Biorouter Copilot became the one desktop surface. + * A chat saved before that names them under `developer`, and the toolbox + * fallback drew both as code, beside "Ran computer control" with the Copilot + * glyph. + */ + it('draws a legacy developer screen capture or window list as desktop control', () => { + for (const name of ['developer__screen_capture', 'developer__list_windows']) { + expect(toolGlyphKind(name), name).toBe('copilot'); + expect(toolGlyphKind(`mcp__biorouter__${name}`), name).toBe('copilot'); + expect(toolGlyphFor(name).Icon, name).toBe(TOOL_GLYPHS.copilot); + // Arguments do not move it: a capture of a window or of a display is one glyph. + expect(toolGlyphKind(name, { window_title: 'Terminal' }), name).toBe('copilot'); + } + // The successor draws the same figure, so a new and an old row agree. + expect(toolGlyphKind('computercontroller__screen_capture')).toBe('copilot'); + // Only those two names moved: the rest of the toolbox keeps its glyphs, + // and an external extension's same-named tool is still the Puzzle. + expect(toolGlyphKind('developer__shell')).toBe('shell'); + expect(toolGlyphKind('developer__text_editor', { command: 'view' })).toBe('read'); + expect(toolGlyphKind('developer__brand_new_tool')).toBe('code'); + expect(toolGlyphKind('acme__screen_capture')).toBe('extension'); + expect(toolGlyphKind('screen_capture')).toBe('unknown'); + }); + + it('falls back to the extension glyph, then to the wrench', () => { + expect(toolGlyphKind('acme__do_something')).toBe('extension'); + expect(toolGlyphKind('mcp__github__create_issue')).toBe('extension'); + expect(toolGlyphKind('developer__brand_new_tool')).toBe('code'); + expect(toolGlyphKind('mystery_tool')).toBe('unknown'); + expect(toolGlyphKind('')).toBe('unknown'); + expect(TOOL_GLYPHS.unknown).not.toBe(TOOL_GLYPHS.extension); + }); +}); + +/** + * Owner message 11: specific glyphs only for Biorouter's own extensions and + * tools. A marketplace agent is an external extension like any other, and its + * tool rows and its Extensions card draw the one Puzzle, whatever its tools + * are called. + */ +describe('external extensions draw the Puzzle', () => { + it('resolves marketplace and invented extensions to the extension glyph', () => { + for (const name of [ + 'spokeagent__query_graph', + 'playwrightagent__browser_navigate', + 'ucsfomopagent__run_sql', + 'cdwagent__query', + 'codegraphagent__search', + 'biroffice__docx_edit', + 'acme__read_file', + 'acme__shell', + 'acme__text_editor', + ]) { + expect(toolGlyphKind(name, { command: 'view' }), name).toBe('extension'); + expect(toolGlyphFor(name).Icon, name).toBe(ENTITY_ICONS.extension); + } + }); + + it("draws an external extension's card as the Puzzle", () => { + for (const name of ['SPOKEAgent', 'spokeagent', 'PlaywrightAgent', 'Some Third Party']) { + expect(extensionGlyphFor(name).kind, name).toBe('extension'); + expect(isBuiltInExtension(name), name).toBe(false); + } + }); +}); + +describe('one glyph per concept', () => { + /** + * The family glyphs ARE the entity glyphs. A knowledge tool row, the + * Knowledge nav row and the composer's knowledge count draw one figure. + */ + it('takes every component family from ENTITY_ICONS', () => { + expect(TOOL_GLYPHS.knowledge).toBe(ENTITY_ICONS.knowledge); + expect(TOOL_GLYPHS.crew).toBe(ENTITY_ICONS.crew); + expect(TOOL_GLYPHS.workflow).toBe(ENTITY_ICONS.workflow); + expect(TOOL_GLYPHS.schedule).toBe(ENTITY_ICONS.schedule); + expect(TOOL_GLYPHS.skill).toBe(ENTITY_ICONS.skill); + expect(TOOL_GLYPHS.extension).toBe(ENTITY_ICONS.extension); + expect(TOOL_GLYPHS.app).toBe(ENTITY_ICONS.application); + expect(TOOL_GLYPHS.agent).toBe(ENTITY_ICONS.agent); + }); + + /** `read` and `document` share FileText on purpose (a document tool reads or + * writes one); every other kind is its own figure. */ + it('gives every other kind a distinct figure', () => { + const SHARED = new Set(['document']); + const icons = (Object.keys(TOOL_GLYPHS) as ToolGlyphKind[]) + .filter((k) => !SHARED.has(k)) + .map((k) => TOOL_GLYPHS[k]); + expect(new Set(icons).size).toBe(icons.length); + }); + + /** The acceptance transcript: one distinct glyph per family, and the wrench + * only for the unknown tool. */ + it('draws ten families with ten different glyphs', () => { + const calls: Array<[string, Record?]> = [ + ['developer__shell'], + ['developer__text_editor', { command: 'view' }], + ['developer__text_editor', { command: 'str_replace' }], + ['developer__analyze'], + ['knowledge__kb_search'], + ['crew__request'], + ['todo__todo_write'], + ['autovisualiser__render_figure'], + ['workspace__workspace_list'], + ['spokeagent__query_graph'], + ]; + const icons = calls.map(([name, args]) => toolGlyphFor(name, args).Icon); + expect(new Set(icons).size).toBe(icons.length); + expect(icons).not.toContain(TOOL_GLYPHS.unknown); + expect(toolGlyphFor('mystery_tool').Icon).toBe(TOOL_GLYPHS.unknown); + }); + + it("draws an extension's card with the glyph its tool rows draw", () => { + for (const key of ['knowledge', 'computercontroller', 'datasql', 'memory', 'autovisualiser']) { + expect(extensionGlyphFor(key).Icon, key).toBe(TOOL_GLYPHS[toolGlyphKind(`${key}__x`)]); + } + // Toolboxes draw their default action. + expect(extensionGlyphFor('developer').kind).toBe('code'); + expect(extensionGlyphFor('webdocuments').kind).toBe('web'); + }); + + it('finds a built-in card by its display name', () => { + expect(extensionGlyphFor('Biorouter Copilot').kind).toBe('copilot'); + expect(extensionGlyphFor('Web & Documents').kind).toBe('web'); + expect(extensionGlyphFor('Auto Visualiser').kind).toBe('figure'); + expect(extensionGlyphFor('Agent Drafter').kind).toBe('app'); + expect(extensionGlyphFor('Workspace Control').kind).toBe('workspace'); + expect(extensionGlyphFor('Code Execution').kind).toBe('code'); + expect(extensionGlyphFor('Chat Recall').kind).toBe('recall'); + expect(extensionGlyphFor('Extension Manager').kind).toBe('extension'); + }); +}); + +/** What the retired `toolIconMapping.tsx` shim answered, now asked of the module itself. */ +describe('the answers the old shim gave', () => { + it('come from the one ladder', () => { + expect(toolGlyphFor('knowledge__kb_search').Icon).toBe(ENTITY_ICONS.knowledge); + expect(toolGlyphFor('developer__text_editor', { command: 'view' }).Icon).toBe(TOOL_GLYPHS.read); + expect(toolGlyphFor('spokeagent__query_graph').Icon).toBe(ENTITY_ICONS.extension); + expect(extensionGlyphFor(parseToolName('memory__remember_memory').extension).Icon).toBe( + TOOL_GLYPHS.memory + ); + }); +}); + +// --------------------------------------------------------------------------- +// Drift guards: read the Rust source, the same direction +// `autovis_cdn_desktop_contract.rs` reads the desktop source from Rust. +// --------------------------------------------------------------------------- + +const CRATES = join(__dirname, '../../../../crates'); +const read = (path: string) => readFileSync(join(CRATES, path), 'utf8'); +const nameToKey = (name: string) => name.replace(/\s+/g, '').toLowerCase(); + +/** + * `BUILT_IN_EXTENSION_KEYS` is the boundary between "draws its own glyph" and + * "draws the Puzzle", so it must be exactly the set of keys Biorouter ships. + * Collected from every place the crates declare one. + */ +describe('BUILT_IN_EXTENSION_KEYS matches the crates', () => { + function shippedKeys(): Map { + const found = new Map(); // key -> where + + // Bundled MCP servers, with the labels the app shows. + const meta = read('biorouter/src/config/extensions.rs'); + const table = meta.match(/const BUILTIN_EXTENSION_METADATA[\s\S]*?=\s*&\[([\s\S]*?)\];/)?.[1]; + expect(table, 'BUILTIN_EXTENSION_METADATA not found').toBeTruthy(); + for (const m of table!.matchAll(/\(\s*"([^"]+)",\s*"([^"]+)"/g)) { + found.set(m[1], 'BUILTIN_EXTENSION_METADATA'); + } + + // Platform extensions: a literal name or a module's EXTENSION_NAME. + const platform = read('biorouter/src/agents/extension.rs'); + for (const m of platform.matchAll(/PlatformExtensionDef\s*\{\s*name:\s*([^,]+),/g)) { + const expr = m[1].trim(); + let name = expr.match(/^"([^"]+)"$/)?.[1]; + if (!name) { + const module = expr.match(/(\w+)::EXTENSION_NAME$/)?.[1]; + expect(module, `unreadable platform name ${expr}`).toBeTruthy(); + const src = read(`biorouter/src/agents/${module}.rs`); + name = src.match(/EXTENSION_NAME:\s*&(?:'static\s+)?str\s*=\s*"([^"]+)"/)?.[1]; + } + expect(name, `no name for ${expr}`).toBeTruthy(); + found.set(nameToKey(name!), 'PLATFORM_EXTENSIONS'); + } + + // The `platform__*` prefix (not a registered extension). + const tools = read('biorouter/src/agents/platform_tools.rs'); + const prefix = tools.match(/PLATFORM_EXTENSION_NAME:\s*&str\s*=\s*"([^"]+)"/)?.[1]; + expect(prefix).toBeTruthy(); + found.set(prefix!, 'platform_tools.rs'); + + // The workflow's final-output tool prefix. + const finalOutput = read('biorouter/src/agents/final_output_tool.rs'); + const workflow = finalOutput.match(/FINAL_OUTPUT_TOOL_NAME:\s*&str\s*=\s*"(\w+?)__/)?.[1]; + expect(workflow).toBeTruthy(); + found.set(workflow!, 'final_output_tool.rs'); + + // In-process servers an app session injects. + const apps = read('biorouter-server/src/routes/apps.rs'); + for (const m of apps.matchAll(/add_inprocess_server\(\s*"([^"]+)"/g)) { + found.set(m[1], 'routes/apps.rs'); + } + + // Subprocess servers (`biorouter mcp `). + const runner = read('biorouter-mcp/src/mcp_server_runner.rs'); + for (const m of runner.matchAll(/"(\w+)"\s*=>\s*Ok\(McpCommand::/g)) { + found.set(m[1], 'mcp_server_runner.rs'); + } + return found; + } + + it('lists every key the crates ship, and nothing else', () => { + const shipped = shippedKeys(); + // The scan must find a real number of keys, or it proves nothing. + expect(shipped.size).toBeGreaterThanOrEqual(15); + for (const [key, where] of shipped) { + expect(BUILT_IN_EXTENSION_KEYS.has(key), `${key} (${where}) is missing`).toBe(true); + } + for (const key of BUILT_IN_EXTENSION_KEYS) { + expect(shipped.has(key), `${key} is not shipped by any crate`).toBe(true); + } + }); + + it('finds every bundled extension by the label the app shows', () => { + const meta = read('biorouter/src/config/extensions.rs'); + const table = meta.match(/const BUILTIN_EXTENSION_METADATA[\s\S]*?=\s*&\[([\s\S]*?)\];/)![1]; + for (const m of table.matchAll(/\(\s*"([^"]+)",\s*"([^"]+)"/g)) { + expect(extensionGlyphFor(m[2]), m[2]).toEqual(extensionGlyphFor(m[1])); + } + const display = meta.match(/fn bundled_extension_display_name[\s\S]*?\n\}/)?.[0] ?? ''; + for (const m of display.matchAll(/"([^"]+)"\s*=>\s*return Some\("([^"]+)"\)/g)) { + expect(isBuiltInExtension(m[2]), m[2]).toBe(true); + expect(extensionGlyphFor(m[2]), m[2]).toEqual(extensionGlyphFor(m[1])); + } + }); +}); + +/** + * A NEW tool on a built-in server must not ship with the generic Puzzle or the + * wrench. Reads the servers' `#[tool(...)]` attributes and the platform tool + * constants, so adding a tool without a glyph fails here. + */ +describe('every built-in server tool has a real glyph', () => { + const MCP_SRC = join(CRATES, 'biorouter-mcp/src'); + const SERVER_DIRS: Record = { + developer: 'developer', + knowledge: 'knowledge', + memory: 'memory', + webdocuments: 'webdocuments', + autovisualiser: 'autovisualiser', + agent_drafter: 'agent_drafter', + datasql: 'datasql', + files_server: 'files', + compute_server: 'compute', + }; + const TOOL_ATTR = + /#\[tool\(([\s\S]*?)\)\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/g; + + function rustFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return rustFiles(path); + return entry.endsWith('.rs') && !entry.includes('test') ? [path] : []; + }); + } + + for (const [dir, extension] of Object.entries(SERVER_DIRS)) { + it(`${dir}`, () => { + expect(existsSync(join(MCP_SRC, dir)), `${dir} moved`).toBe(true); + const names = new Set(); + for (const file of rustFiles(join(MCP_SRC, dir))) { + for (const m of readFileSync(file, 'utf8').matchAll(TOOL_ATTR)) { + names.add(m[1].match(/name\s*=\s*"([^"]+)"/)?.[1] ?? m[2]); + } + } + expect(names.size, `no #[tool] found under ${dir}: the scan is vacuous`).toBeGreaterThan(0); + for (const tool of names) { + const kind = toolGlyphKind(`${extension}__${tool}`); + expect(['unknown', 'extension'], `${extension}__${tool}`).not.toContain(kind); + } + }); + } + + it('platform tools', () => { + const src = read('biorouter/src/agents/platform_tools.rs'); + const names = [...src.matchAll(/_TOOL_NAME:\s*&str\s*=\s*"(platform__\w+)"/g)].map((m) => m[1]); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expect(['unknown', 'extension'], name).not.toContain(toolGlyphKind(name)); + } + }); +}); diff --git a/ui/desktop/src/utils/toolGlyph.ts b/ui/desktop/src/utils/toolGlyph.ts new file mode 100644 index 000000000..195f4ed25 --- /dev/null +++ b/ui/desktop/src/utils/toolGlyph.ts @@ -0,0 +1,406 @@ +/** + * One answer to "which glyph does this tool call draw", for every surface that + * draws one: the transcript row (`ToolCallWithResponse`), a Code Execution + * script's inner calls (`ExecutedCallRow`), the parked approval card + * (`PendingToolCallCard`), and an extension's own card in Extensions. + * + * WHY IT REPLACED `toolIconMapping.tsx` (deleted once every caller had moved + * here). That module switched on the BARE tool name (everything after the last + * `__`), so: + * - every built-in family it did not list drew the wrench: knowledge, Crew, + * todo, workspace, skills, the extension manager, Auto Visualiser, DataSQL, + * workflows, schedules, code execution. In a normal transcript that is + * most rows; + * - two extensions' same-named tools collided: `agent_drafter__list_apps` + * drew Copilot's Monitor, because `computercontroller__list_apps` exists; + * - `text_editor` drew the edit mark for a `view`. + * + * THE LADDER, first match wins: + * 1. the exact full name (`workspace__subagent`, `platform__manage_schedule`) + * 2. an argument-aware tool (`text_editor` view vs write) + * 3. the extension's family (`knowledge__*`, `crew__*`, `todo__*`, …) + * 4. a per-tool table for the families that are a toolbox, not a concept + * (`developer`, `webdocuments`, `files`, `platform`), then its default + * 5. a coding-agent child's own tool (`Bash`, `Read`, `apply_patch`, `exec`, …) + * 6. any other `extension__tool` -> the extension glyph (Puzzle) + * 7. nothing matched -> the wrench + * + * ⚠ **Rungs 1 to 4 apply only to Biorouter's own extensions** + * ({@link BUILT_IN_EXTENSION_KEYS}). Every installed or external extension + * (SPOKEAgent, CDWAgent, PlaywrightAgent, any MCP server from BAAM or the + * config) draws the one Puzzle, whatever its tools are called, so an external + * `acme__read_file` is not drawn as a file read (owner message 11: specific + * glyphs only for the built-in extensions and tools). + * + * The family glyphs ARE the entity glyphs (`ENTITY_ICONS`), so a knowledge + * tool row, the Knowledge nav row and the composer's knowledge count can never + * disagree (design.md §3.9, "one glyph, one meaning"). + */ +import type { ComponentType } from 'react'; +import { + Archive, + Bookmark, + Bug, + ChartColumn, + CheckCircle2, + Code, + Copilot, + Database, + FilePen, + FileSpreadsheet, + FileText, + FolderOpen, + Globe, + History, + Image, + ListTodo, + MessagesSquare, + Search, + Terminal, + Wrench, +} from '../components/icons/app-icons'; +import { ENTITY_ICONS } from '../components/icons/entity-icons'; + +export type ToolIcon = ComponentType<{ className?: string }>; + +export type ToolGlyphKind = + // what the call DID (toolbox families and coding-agent child tools) + | 'shell' + | 'read' + | 'write' + | 'list' + | 'search' + | 'web' + | 'image' + | 'document' + | 'spreadsheet' + | 'archive' + | 'code' + // which Biorouter component it USED (entity glyphs) + | 'knowledge' + | 'crew' + | 'workflow' + | 'schedule' + | 'skill' + | 'extension' + | 'app' + | 'agent' + // other built-in components + | 'workspace' + | 'figure' + | 'copilot' + | 'database' + | 'memory' + | 'plan' + | 'recall' + | 'bug' + | 'done' + // nothing known + | 'unknown'; + +export const TOOL_GLYPHS: Record = { + shell: Terminal, + read: FileText, + write: FilePen, + list: FolderOpen, + search: Search, + web: Globe, + image: Image, + // A document tool reads or writes one: the same page as a read, on purpose. + document: FileText, + spreadsheet: FileSpreadsheet, + archive: Archive, + code: Code, + knowledge: ENTITY_ICONS.knowledge, + crew: ENTITY_ICONS.crew, + workflow: ENTITY_ICONS.workflow, + schedule: ENTITY_ICONS.schedule, + skill: ENTITY_ICONS.skill, + extension: ENTITY_ICONS.extension, + app: ENTITY_ICONS.application, + agent: ENTITY_ICONS.agent, + workspace: MessagesSquare, + figure: ChartColumn, + copilot: Copilot, + database: Database, + memory: Bookmark, + plan: ListTodo, + recall: History, + bug: Bug, + done: CheckCircle2, + unknown: Wrench, +}; + +/** + * The extension keys Biorouter itself ships: the bundled MCP servers + * (`config/extensions.rs` `BUILTIN_EXTENSION_METADATA`), the platform + * extensions (`agents/extension.rs` `PLATFORM_EXTENSIONS`), the in-process + * servers an app session injects (`routes/apps.rs` `add_inprocess_server`), + * and the two tool prefixes that are not registered extensions (`platform`, + * `workflow`). A key is `name_to_key` of the display name: whitespace + * stripped, lowercased. `toolGlyph.test.ts` checks this set against the crates. + */ +export const BUILT_IN_EXTENSION_KEYS: ReadonlySet = new Set([ + 'developer', + 'knowledge', + 'memory', + 'webdocuments', + 'autovisualiser', + 'computercontroller', + 'agent_drafter', + 'appcontrol', + 'datasql', + 'files', + 'compute', + 'evidence', + 'todo', + 'workspace', + 'skills', + 'extensionmanager', + 'code_execution', + 'chatrecall', + 'crew', + 'platform', + 'workflow', +]); + +/** `name_to_key` (`config/extensions.rs`): whitespace stripped, lowercased. */ +function nameToKey(name: string): string { + return name.replace(/\s+/g, '').toLowerCase(); +} + +/** + * Display names that do not reduce to their key, so an Extensions card that + * only knows its title still finds its glyph. The labels come from + * `BUILTIN_EXTENSION_METADATA` and `bundled_extension_display_name`, and + * `toolGlyph.test.ts` reads both to keep this table complete. + */ +const DISPLAY_KEY_ALIASES: Record = Object.fromEntries( + ( + [ + ['Biorouter Copilot', 'computercontroller'], + ['Web & Documents', 'webdocuments'], + ['Agent Drafter', 'agent_drafter'], + ['Workspace Control', 'workspace'], + ['Code Execution', 'code_execution'], + ] as const + ).map(([label, key]) => [nameToKey(label), key]) +); + +function extensionKey(nameOrKey: string): string { + const key = nameToKey(nameOrKey); + return DISPLAY_KEY_ALIASES[key] ?? key; +} + +/** Is this one of Biorouter's own extensions (by key or display name)? */ +export function isBuiltInExtension(nameOrKey: string): boolean { + return BUILT_IN_EXTENSION_KEYS.has(extensionKey(nameOrKey)); +} + +/** + * The prefix a coding-agent bridge puts on Biorouter's own tools + * (`coding_agent/mirror.rs`, `BRIDGE_TOOL_PREFIX`). The daemon already strips + * it for display; stripping it here too keeps an older stored message correct. + */ +const BRIDGE_PREFIX = 'mcp__biorouter__'; + +export interface ParsedToolName { + /** The extension key (`developer`, `knowledge`, …), or '' for a bare name. */ + extension: string; + /** The tool's own name (`shell`, `kb_search`, `Bash`, …). */ + tool: string; +} + +/** + * `developer__shell` -> developer / shell; `mcp__biorouter__developer__shell` + * -> the same; `mcp__github__create_issue` (a child agent's own MCP server) + * -> github / create_issue; `exec` -> '' / exec. + * + * Splits at the FIRST `__`: extension keys are `name_to_key` output + * (whitespace stripped, lowercased) and never contain `__`, while a tool name + * occasionally does. + */ +export function parseToolName(name: string): ParsedToolName { + let rest = name.startsWith(BRIDGE_PREFIX) ? name.slice(BRIDGE_PREFIX.length) : name; + if (rest.startsWith('mcp__')) rest = rest.slice('mcp__'.length); + const at = rest.indexOf('__'); + return at <= 0 + ? { extension: '', tool: rest } + : { extension: rest.slice(0, at), tool: rest.slice(at + 2) }; +} + +/** 1. Exact full names: where a family default would say the wrong thing. */ +const EXACT: Record = { + workspace__subagent: 'agent', + workflow__final_output: 'done', + platform__manage_schedule: 'schedule', + platform__manage_workflow: 'workflow', + platform__ingest_conversation: 'knowledge', + platform__ingest_source: 'knowledge', + platform__read_session_blob: 'read', + platform__report_bug: 'bug', + code_execution__read_module: 'read', + code_execution__search_modules: 'search', + agent_drafter__consult: 'agent', + // History. Biorouter Copilot used to be the legacy `computercontroller` + // server, and its web, document and cache tools moved to `webdocuments` + // while its scripting tool was removed. A chat saved before that still names + // them under the old key, and the family row below would draw all of them as + // desktop control (V2-18). `computer_control` is not listed: it WAS desktop + // control, and the Copilot family already draws it. + computercontroller__web_scrape: 'web', + computercontroller__cache: 'archive', + computercontroller__pdf_tool: 'document', + computercontroller__docx_tool: 'document', + computercontroller__xlsx_tool: 'spreadsheet', + computercontroller__automation_script: 'shell', + // History, second block. The Developer server used to carry screen capture + // and a window list; both left it when Biorouter Copilot became the only + // desktop surface, but a chat saved before that still names them under + // `developer`, and the Developer toolbox fallback would draw both as code + // (V2-R5-3: "Ran screen capture" beside "Ran computer control"). + developer__screen_capture: 'copilot', + developer__list_windows: 'copilot', +}; + +/** 3. Built-in families that ARE one Biorouter concept: every tool draws it. */ +const FAMILY: Record = { + knowledge: 'knowledge', + crew: 'crew', + skills: 'skill', + extensionmanager: 'extension', + todo: 'plan', + workspace: 'workspace', + autovisualiser: 'figure', + computercontroller: 'copilot', + datasql: 'database', + memory: 'memory', + chatrecall: 'recall', + agent_drafter: 'app', + appcontrol: 'app', + evidence: 'app', + code_execution: 'code', + compute: 'code', + workflow: 'workflow', +}; + +/** 4. Built-in toolbox families: the action decides, then the family's default. */ +const TOOLBOX: Record; fallback: ToolGlyphKind }> = { + developer: { + tools: { + shell: 'shell', + shell_status: 'shell', + shell_kill: 'shell', + analyze: 'search', + image_processor: 'image', + }, + fallback: 'code', + }, + webdocuments: { + tools: { + web_scrape: 'web', + pdf_tool: 'document', + docx_tool: 'document', + xlsx_tool: 'spreadsheet', + cache: 'archive', + }, + fallback: 'web', + }, + files: { + tools: { files_list: 'list', files_read: 'read', files_write: 'write' }, + fallback: 'read', + }, + // Every platform tool is named in EXACT; a new one draws the Puzzle until + // it is, and the drift guard in toolGlyph.test.ts fails on it. + platform: { tools: {}, fallback: 'extension' }, +}; + +/** 5. Tools a coding-agent child runs itself (Claude Code, Codex) and the + * legacy bare names the old `toolIconMapping.tsx` knew. Matched only when the name + * carries NO extension prefix, so `acme__Read` stays the acme extension's. */ +const BARE: Record = { + Bash: 'shell', + BashOutput: 'shell', + KillShell: 'shell', + exec: 'shell', + exec_command: 'shell', + shell: 'shell', + Read: 'read', + NotebookRead: 'read', + read: 'read', + Write: 'write', + Edit: 'write', + MultiEdit: 'write', + NotebookEdit: 'write', + apply_patch: 'write', + create_file: 'write', + update_file: 'write', + Grep: 'search', + search: 'search', + Glob: 'list', + LS: 'list', + WebFetch: 'web', + WebSearch: 'web', + web_search: 'web', + browser_tabs: 'web', + view_image: 'image', + Task: 'agent', + subagent: 'agent', + TodoWrite: 'plan', + update_plan: 'plan', + sheets_tool: 'spreadsheet', + docs_tool: 'document', + final_output: 'done', +}; + +/** `text_editor`'s command decides read versus write; no command (a pending + * call whose arguments have not streamed yet) reads as an edit, which is what + * the tool is for. */ +function textEditorKind(args?: Record | null): ToolGlyphKind { + return args?.command === 'view' ? 'read' : 'write'; +} + +export interface ToolGlyph { + kind: ToolGlyphKind; + Icon: ToolIcon; +} + +export function toolGlyphKind(name: string, args?: Record | null): ToolGlyphKind { + const { extension, tool } = parseToolName(name); + + if (extension) { + // 6. An external extension is the Puzzle, whatever its tools are called. + if (!BUILT_IN_EXTENSION_KEYS.has(extension)) return 'extension'; + + const exact = EXACT[`${extension}__${tool}`]; + if (exact) return exact; + if (extension === 'developer' && tool === 'text_editor') return textEditorKind(args); + const family = FAMILY[extension]; + if (family) return family; + const toolbox = TOOLBOX[extension]; + if (toolbox) return toolbox.tools[tool] ?? toolbox.fallback; + return 'extension'; + } + + if (tool === 'text_editor') return textEditorKind(args); + return BARE[tool] ?? 'unknown'; +} + +export function toolGlyphFor(name: string, args?: Record | null): ToolGlyph { + const kind = toolGlyphKind(name, args); + return { kind, Icon: TOOL_GLYPHS[kind] }; +} + +/** + * The glyph an extension's own card draws in Extensions, from the same tables + * its tool rows use, so the card and the rows agree. Takes the key or the + * display name. A built-in toolbox draws its default action; every external + * extension draws the Puzzle. + */ +export function extensionGlyphFor(nameOrKey: string): ToolGlyph { + const key = extensionKey(nameOrKey); + const kind = BUILT_IN_EXTENSION_KEYS.has(key) + ? (FAMILY[key] ?? TOOLBOX[key]?.fallback ?? 'extension') + : 'extension'; + return { kind, Icon: TOOL_GLYPHS[kind] }; +} diff --git a/ui/desktop/src/utils/toolIconMapping.tsx b/ui/desktop/src/utils/toolIconMapping.tsx deleted file mode 100644 index fe3a31699..000000000 --- a/ui/desktop/src/utils/toolIconMapping.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from 'react'; -import { - Archive, - BookMarked, - Bookmark, - BookmarkPlus, - Camera, - CheckCircle, - Code, - Edit, - Eye, - FilePlus, - FileSpreadsheet, - FileText, - Globe, - Monitor, - Search, - Terminal, - Wrench, -} from '../components/icons/app-icons'; - -export type ToolIconProps = { - className?: string; -}; - -/** - * Maps tool names to their corresponding icon components - * @param toolName - The name of the tool (extracted from toolCall.name) - * @returns React component for the tool icon - */ -export const getToolIcon = (toolName: string): React.ComponentType => { - switch (toolName) { - // Developer Extension Tools - case 'text_editor': - return Edit; - case 'shell': - return Terminal; - - // Memory Extension Tools - case 'remember_memory': - return BookmarkPlus; - case 'retrieve_memories': - return BookMarked; - - // Biorouter Copilot Extension Tools - case 'list_apps': - case 'get_app_state': - case 'click': - case 'perform_secondary_action': - case 'scroll': - case 'drag': - case 'type_text': - case 'press_key': - case 'set_value': - return Monitor; - case 'web_scrape': - return Globe; - case 'screen_capture': - return Camera; - case 'pdf_tool': - return FileText; - case 'docx_tool': - return FileText; - case 'xlsx_tool': - return FileSpreadsheet; - case 'cache': - return Archive; - - // File Operations - case 'search': - return Search; - case 'read': - return Eye; - case 'create_file': - return FilePlus; - case 'update_file': - return Edit; - - // Google Workspace Tools (if still supported) - case 'sheets_tool': - return FileSpreadsheet; - case 'docs_tool': - return FileText; - - // Special Tools - case 'final_output': - return CheckCircle; - - // Default fallback for unknown tools - default: - return Wrench; - } -}; - -/** - * Maps extension names to their corresponding icon components - * @param extensionName - The name of the extension - * @returns React component for the extension icon - */ -export const getExtensionIcon = (extensionName: string): React.ComponentType => { - switch (extensionName) { - case 'developer': - return Code; - case 'memory': - return Bookmark; - case 'computercontroller': - return Monitor; - case 'webdocuments': - return Globe; - default: - return Wrench; - } -}; - -/** - * Helper function to extract tool name from full tool call name - * @param toolCallName - Full tool call name (e.g., "developer__text_editor") - * @returns Extracted tool name (e.g., "text_editor") - */ -export const extractToolName = (toolCallName: string): string => { - return toolCallName.substring(toolCallName.lastIndexOf('__') + 2); -}; - -/** - * Helper function to extract extension name from full tool call name - * @param toolCallName - Full tool call name (e.g., "developer__text_editor") - * @returns Extracted extension name (e.g., "developer") - */ -export const extractExtensionName = (toolCallName: string): string => { - const lastIndex = toolCallName.lastIndexOf('__'); - return lastIndex === -1 ? '' : toolCallName.substring(0, lastIndex); -}; - -/** - * Main function to get the appropriate icon for a tool call - * @param toolCallName - Full tool call name (e.g., "developer__text_editor") - * @param useExtensionIcon - Whether to use extension icon instead of tool icon - * @returns React component for the icon - */ -export const getToolCallIcon = ( - toolCallName: string, - useExtensionIcon: boolean = false -): React.ComponentType => { - if (useExtensionIcon) { - const extensionName = extractExtensionName(toolCallName); - return getExtensionIcon(extensionName); - } - - const toolName = extractToolName(toolCallName); - return getToolIcon(toolName); -}; diff --git a/ui/desktop/src/utils/trailingActivity.test.ts b/ui/desktop/src/utils/trailingActivity.test.ts index df2851f93..10e2fb26c 100644 --- a/ui/desktop/src/utils/trailingActivity.test.ts +++ b/ui/desktop/src/utils/trailingActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { deriveTrailingActivity } from './trailingActivity'; +import { transcriptCopy } from '../components/transcript/copy'; const LAST_MESSAGE_AT = 1_700_000_000_000; const TURN_STARTED_AT = 1_699_999_990_000; @@ -53,7 +54,7 @@ describe('deriveTrailingActivity', () => { expect(activity).not.toBeNull(); expect(activity?.phase).toBe('thinking'); - expect(activity?.label).toBe('Working on the result'); + expect(activity?.label).toBe(transcriptCopy.activity.thinking); expect(activity?.since).toBe(LAST_MESSAGE_AT); }); @@ -291,7 +292,7 @@ describe('pending steer', () => { expect(activity?.phase).not.toBe('steering'); expect(activity?.phase).toBe('steerQueued'); expect(activity?.since).toBeUndefined(); - expect(activity?.label).toBe('Your message will be added after you answer the card'); + expect(activity?.label).toBe(transcriptCopy.activity.waitingForAnswer); }); it('yields to an approval card on screen, with no clock', () => { diff --git a/ui/desktop/src/utils/trailingActivity.ts b/ui/desktop/src/utils/trailingActivity.ts index 19ed462a2..67df3fc5e 100644 --- a/ui/desktop/src/utils/trailingActivity.ts +++ b/ui/desktop/src/utils/trailingActivity.ts @@ -7,6 +7,9 @@ import { getToolRequests, getToolResponses, } from '../types/message'; +import { transcriptCopy } from '../components/transcript/copy'; + +const activityCopy = transcriptCopy.activity; export type TrailingPhase = 'thinking' | 'running' | 'compacting' | 'steering' | 'steerQueued'; @@ -131,7 +134,7 @@ export function deriveTrailingActivity({ if (!pendingSteer) return null; return { phase: 'steerQueued', - label: 'Your message will be added after you answer the card', + label: activityCopy.waitingForAnswer, steerText: pendingSteer.text, since: undefined, }; @@ -150,9 +153,7 @@ export function deriveTrailingActivity({ : outstandingToolName(last); return { phase: 'steering', - label: runningTool - ? `Your message will be added when ${runningTool} finishes` - : 'Steering the current turn', + label: runningTool ? activityCopy.steeringAfter(runningTool) : activityCopy.steering, steerText: pendingSteer.text, since: pendingSteer.since, }; @@ -163,15 +164,17 @@ export function deriveTrailingActivity({ const since = lastMessageAt ?? turnStartedAt; if (chatState === ChatState.Compacting) { - return { phase: 'compacting', label: 'Compacting the chat', since }; + return { phase: 'compacting', label: activityCopy.compacting, since }; } // (c) THE CASE THIS FEATURE EXISTS FOR: a tool just returned, the model is // mid round-trip, and nothing at all is on screen. A tool-response // message is role 'user' with only toolResponse content, which - // ProgressiveMessageList renders as an empty div. + // ProgressiveMessageList renders as an empty div. It reads "Thinking", + // as Codex's does: the model is working on the result, and one word for + // every wait on the model is easier to scan than two. if (last.role === 'user' && isToolResponseOnly(last)) { - return { phase: 'thinking', label: 'Working on the result', since }; + return { phase: 'thinking', label: activityCopy.thinking, since }; } // (d) The assistant message with tool calls is still last and some calls have @@ -186,16 +189,17 @@ export function deriveTrailingActivity({ if (outstanding > 0) { return { phase: 'running', - label: outstanding === 1 ? 'Running the tool' : `Running ${outstanding} tools`, + label: + outstanding === 1 ? activityCopy.runningOne : activityCopy.runningMany(outstanding), since: undefined, }; } } // (e) Assistant prose is streaming in. The visible text IS the feedback. if (hasVisibleText(last)) return null; - return { phase: 'thinking', label: 'Thinking', since }; + return { phase: 'thinking', label: activityCopy.thinking, since }; } // (f) User just submitted; nothing back yet. - return { phase: 'thinking', label: 'Thinking', since }; + return { phase: 'thinking', label: activityCopy.thinking, since }; } diff --git a/ui/desktop/src/utils/usageStats.test.ts b/ui/desktop/src/utils/usageStats.test.ts new file mode 100644 index 000000000..d0867be06 --- /dev/null +++ b/ui/desktop/src/utils/usageStats.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; +import type { ActivityWindow, DailyActivity, UsageReportRow } from '../api'; +import { + DEFAULT_USAGE_RANGE, + USAGE_RANGES, + deriveUsageStats, + expectedHeatmapWeeks, + favoriteModel, + formatShare, + formatTokensCompact, + heatmapWeeks, + isUsageRange, + modelBreakdown, + rangeStart, + rangeStartSeconds, + streakFootnote, +} from './usageStats'; + +const day = ( + date: string, + { sessions = 1, tokens = 1000, messages = 4, tokensComplete = true } = {} +): DailyActivity => ({ + date, + sessions, + tokens, + tokensComplete, + inputTokens: 0, + outputTokens: 0, + messages, + level: 1, +}); + +function windowOf(overrides: Partial = {}): ActivityWindow { + return { + start: '2025-10-04', + end: '2026-10-09', + maxSessions: 0, + maxTokens: 0, + tokensComplete: true, + currentStreak: 0, + longestStreak: 0, + days: [], + ...overrides, + }; +} + +const row = ( + modelId: string | null, + totalTokens: number | null, + turns = 1, + extra: Partial = {} +): UsageReportRow => ({ + date: null, + modelId, + provider: modelId ? 'openai' : null, + inputTokens: totalTokens ?? 0, + outputTokens: 0, + totalTokens, + cacheReadTokens: 0, + cacheCreationTokens: 0, + turns, + cost: null, + hasUnpriced: false, + costExcludesCache: false, + ...extra, +}); + +describe('ranges', () => { + it('offers 1y, 30d and 7d, and never an "All" over a partial window', () => { + expect(USAGE_RANGES).toEqual(['1y', '30d', '7d']); + expect(DEFAULT_USAGE_RANGE).toBe('1y'); + expect(isUsageRange('30d')).toBe(true); + expect(isUsageRange('all')).toBe(false); + expect(isUsageRange(null)).toBe(false); + }); + + it('counts calendar days ending on the window end, inclusive', () => { + expect(rangeStart('2026-10-09', '7d')).toBe('2026-10-03'); + expect(rangeStart('2026-10-09', '30d')).toBe('2026-09-10'); + expect(rangeStart('2026-10-09', '1y')).toBe('2025-10-10'); + // Across a month end and a leap day. + expect(rangeStart('2024-03-02', '7d')).toBe('2024-02-25'); + }); + + it('turns the start into local-midnight unix seconds for the report query', () => { + const expected = Math.floor(new Date(2026, 9, 3).getTime() / 1000); + expect(rangeStartSeconds('2026-10-09', '7d')).toBe(expected); + }); +}); + +describe('heatmap columns', () => { + it('counts one column per week, the first padded back to Sunday', () => { + // Sunday to Saturday: exactly two weeks. + expect(heatmapWeeks('2026-03-01', '2026-03-14')).toBe(2); + // A Saturday start pads six days back, and the ragged last week is a column. + expect(heatmapWeeks('2025-10-04', '2026-10-09')).toBe(54); + // A 371-day window that starts on a Sunday is exactly 53 weeks. + expect(heatmapWeeks('2025-10-05', '2026-10-10')).toBe(53); + }); + + it('predicts the daemon window from today, so the loading grid is as wide as the real one', () => { + // Fri 2026-10-09: the window starts on a Saturday. + expect(expectedHeatmapWeeks(371, new Date(2026, 9, 9))).toBe(54); + // Sat 2026-10-10: it starts on a Sunday. + expect(expectedHeatmapWeeks(371, new Date(2026, 9, 10))).toBe(53); + // A window whose end is a Sunday starts on a Monday. + expect(expectedHeatmapWeeks(371, new Date(2026, 9, 11))).toBe(54); + }); +}); + +describe('deriveUsageStats', () => { + const window = windowOf({ + currentStreak: 2, + longestStreak: 9, + days: [ + day('2025-10-09', { sessions: 9, tokens: 9 }), // outside even 1y + day('2025-12-01', { sessions: 2, tokens: 200, messages: 10 }), + day('2026-09-15', { sessions: 1, tokens: 50, messages: 3 }), + day('2026-10-03', { sessions: 3, tokens: 300, messages: 7 }), + day('2026-10-09', { sessions: 1, tokens: 100, messages: 2 }), + ], + }); + + it('sums chats, messages and tokens inside the range only', () => { + expect(deriveUsageStats(window, '7d')).toMatchObject({ + chats: 4, + messages: 9, + tokens: 400, + activeDays: 2, + rangeDays: 7, + tokensComplete: true, + tokensUnavailable: false, + }); + expect(deriveUsageStats(window, '30d')).toMatchObject({ chats: 5, tokens: 450, activeDays: 3 }); + expect(deriveUsageStats(window, '1y')).toMatchObject({ + chats: 7, + tokens: 650, + activeDays: 4, + rangeDays: 365, + }); + }); + + it('carries the streaks through unchanged', () => { + expect(deriveUsageStats(window, '7d')).toMatchObject({ currentStreak: 2, longestStreak: 9 }); + }); + + it('marks the total a lower bound when any day in range is incomplete', () => { + const partial = windowOf({ + days: [day('2026-10-08', { tokens: 40, tokensComplete: false }), day('2026-10-09')], + }); + expect(deriveUsageStats(partial, '7d')).toMatchObject({ + tokens: 1040, + tokensComplete: false, + tokensUnavailable: false, + }); + }); + + it('ignores an incomplete day outside the range', () => { + const partial = windowOf({ + days: [day('2026-01-01', { tokensComplete: false }), day('2026-10-09')], + }); + expect(deriveUsageStats(partial, '7d').tokensComplete).toBe(true); + expect(deriveUsageStats(partial, '1y').tokensComplete).toBe(false); + }); + + it('calls a zero total over incomplete records unavailable, not zero', () => { + const unknown = windowOf({ days: [day('2026-10-09', { tokens: 0, tokensComplete: false })] }); + expect(deriveUsageStats(unknown, '7d')).toMatchObject({ + tokens: 0, + tokensUnavailable: true, + }); + }); + + it('answers zeros for an empty window', () => { + expect(deriveUsageStats(windowOf(), '30d')).toMatchObject({ + chats: 0, + messages: 0, + tokens: 0, + activeDays: 0, + tokensComplete: true, + tokensUnavailable: false, + }); + }); +}); + +describe('formatTokensCompact', () => { + it('keeps one fraction digit and spells out small numbers', () => { + expect(formatTokensCompact(87_900_000_000)).toBe('87.9B'); + expect(formatTokensCompact(29_350_000)).toBe('29.4M'); + expect(formatTokensCompact(128_402)).toBe('128.4K'); + expect(formatTokensCompact(3_000_000)).toBe('3M'); + expect(formatTokensCompact(9_999)).toBe('9,999'); + expect(formatTokensCompact(0)).toBe('0'); + }); +}); + +describe('streakFootnote', () => { + it('says the current streak and the longest', () => { + expect(streakFootnote(3, 16)).toBe('3 day streak · longest 16 days'); + expect(streakFootnote(1, 1)).toBe('1 day streak · longest 1 day'); + }); + + it('says only the longest when there is no current streak', () => { + expect(streakFootnote(0, 16)).toBe('Longest streak 16 days'); + expect(streakFootnote(0, 1)).toBe('Longest streak 1 day'); + }); + + it('says nothing when both are zero', () => { + expect(streakFootnote(0, 0)).toBe(''); + }); +}); + +describe('favoriteModel', () => { + it('picks the model with the most tokens, then the most turns', () => { + expect(favoriteModel([row('a', 10), row('b', 30), row('c', 20)])?.modelId).toBe('b'); + expect(favoriteModel([row('a', 10, 1), row('b', 10, 4)])?.modelId).toBe('b'); + }); + + it('falls back to the known subtotal when the total is uncertified', () => { + expect( + favoriteModel([row('a', 10), row('b', null, 1, { inputTokens: 50, outputTokens: 5 })]) + ?.modelId + ).toBe('b'); + }); + + it('lets the unattributed bucket win only when it is the only model', () => { + expect(favoriteModel([row(null, 500), row('a', 10)])?.modelId).toBe('a'); + expect(favoriteModel([row(null, 500)])?.modelId).toBeNull(); + }); + + it('answers null when nothing was used', () => { + expect(favoriteModel([])).toBeNull(); + expect(favoriteModel([row('a', 0, 0)])).toBeNull(); + }); +}); + +describe('modelBreakdown', () => { + it('lists up to four models by tokens with their shares', () => { + const models = modelBreakdown([row('a', 100), row('b', 300), row('c', 0, 0)]); + expect(models.map((m) => m.modelId)).toEqual(['b', 'a']); + expect(models.map((m) => m.share)).toEqual([0.75, 0.25]); + expect(models.every((m) => !m.other && !m.unrecorded)).toBe(true); + }); + + it('folds everything past the top three into one Other row', () => { + const models = modelBreakdown([ + row('a', 50), + row('b', 40), + row('c', 30), + row('d', 20, 2), + row('e', 10, 3), + ]); + expect(models).toHaveLength(4); + expect(models.slice(0, 3).map((m) => m.modelId)).toEqual(['a', 'b', 'c']); + expect(models[3]).toMatchObject({ other: true, modelId: null, tokens: 30, turns: 5 }); + expect(models.reduce((sum, m) => sum + (m.share ?? 0), 0)).toBeCloseTo(1); + expect(new Set(models.map((m) => m.key)).size).toBe(4); + }); + + /** + * V1-M7: the unattributed bucket led the tab ("Unknown model 743.8M 89%") + * while Favorite model skipped it, so the two tabs disagreed about the top + * model. It now trails, as one row, with no share, and the named models' + * shares are of their own total. + */ + it('puts usage without a model name last, as one row with no share', () => { + const models = modelBreakdown([ + { ...row(null, 700), provider: 'openai' }, + row('a', 60), + { ...row(null, 40), provider: 'anthropic' }, + row('b', 40), + ]); + expect(models.map((m) => m.modelId)).toEqual(['a', 'b', null]); + expect(models[0].share).toBeCloseTo(0.6); + expect(models[1].share).toBeCloseTo(0.4); + expect(models[2]).toMatchObject({ + key: 'unrecorded', + unrecorded: true, + other: false, + tokens: 740, + turns: 2, + share: null, + }); + }); + + it('keeps four rows when the unrecorded row needs a slot', () => { + const models = modelBreakdown([ + row(null, 900), + row('a', 50), + row('b', 40), + row('c', 30), + row('d', 20), + ]); + expect( + models.map((m) => (m.other ? 'Other' : m.unrecorded ? 'unrecorded' : m.modelId)) + ).toEqual(['a', 'b', 'Other', 'unrecorded']); + expect(models[2]).toMatchObject({ tokens: 50, share: 50 / 140 }); + }); + + it('has only the unrecorded row when no usage names a model', () => { + expect(modelBreakdown([row(null, 500)])).toEqual([ + expect.objectContaining({ unrecorded: true, tokens: 500, share: null }), + ]); + }); + + it('is empty when no model was used', () => { + expect(modelBreakdown([])).toEqual([]); + }); +}); + +describe('formatShare', () => { + it('rounds to whole percents and keeps a sliver visible', () => { + expect(formatShare(0.625)).toBe('63%'); + expect(formatShare(0.001)).toBe('<1%'); + expect(formatShare(0)).toBe('0%'); + }); +}); diff --git a/ui/desktop/src/utils/usageStats.ts b/ui/desktop/src/utils/usageStats.ts new file mode 100644 index 000000000..00915de92 --- /dev/null +++ b/ui/desktop/src/utils/usageStats.ts @@ -0,0 +1,303 @@ +import type { ActivityWindow, UsageReportRow } from '../api'; +import { billedTokens, knownBilledTokens } from './usageAccounting'; + +/** + * The arithmetic behind Home's usage card, free of React and the DOM (the + * `messageClamp.ts` rule: a number you can only check by rendering a component + * is one nobody re-checks). + * + * Two scopes, never mixed (issue #224): + * - `ActivityWindow` (`GET /sessions/activity`) is the ACTIVITY view: chats that + * still exist, started per local day. It feeds the tiles and the heatmap. + * - `UsageReportRow` (`GET /usage/report`) is the BILLING view: it includes + * deleted chats and subagent turns. It feeds Favorite model and the Models + * tab only, which say so in their help. + * + * The range is a frontend filter over one 371-day window (owner decision 6.4): + * `1y` is the last 365 days, so it is never labelled "All". + */ + +export type UsageRange = '1y' | '30d' | '7d'; + +/** Display order, left to right. */ +export const USAGE_RANGES: readonly UsageRange[] = ['1y', '30d', '7d']; + +export const DEFAULT_USAGE_RANGE: UsageRange = '1y'; + +/** Calendar days in each range, today included. */ +export const USAGE_RANGE_DAYS: Readonly> = { + '1y': 365, + '30d': 30, + '7d': 7, +}; + +export function isUsageRange(value: unknown): value is UsageRange { + return typeof value === 'string' && (USAGE_RANGES as readonly string[]).includes(value); +} + +/** Local calendar day, `YYYY-MM-DD`, matching the server's `date('now','localtime')`. */ +export function isoDay(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** Local midnight of a `YYYY-MM-DD` day. */ +export function parseIsoDay(day: string): Date { + const [y, m, d] = day.split('-').map(Number); + return new Date(y, m - 1, d); +} + +/** The first day of `range`, inclusive, ending on `end` (a `YYYY-MM-DD` day). */ +export function rangeStart(end: string, range: UsageRange): string { + const start = parseIsoDay(end); + start.setDate(start.getDate() - (USAGE_RANGE_DAYS[range] - 1)); + return isoDay(start); +} + +/** `rangeStart` as unix seconds at local midnight, for `/usage/report?from=`. */ +export function rangeStartSeconds(end: string, range: UsageRange): number { + return Math.floor(parseIsoDay(rangeStart(end, range)).getTime() / 1000); +} + +const DAY_MS = 86_400_000; + +/** + * Columns the heatmap draws for a window: one per week, the first padded back + * to Sunday. A 371-day window is 53 columns only when it starts on a Sunday and + * 54 on every other weekday, so the count is computed and never written down. + */ +export function heatmapWeeks(start: string, end: string): number { + const first = parseIsoDay(start); + first.setDate(first.getDate() - first.getDay()); + const total = Math.round((parseIsoDay(end).getTime() - first.getTime()) / DAY_MS) + 1; + return Math.ceil(total / 7); +} + +/** + * The columns the daemon's `days`-day window ending on `today` will fill. The + * loading grid draws this many, so the card keeps its width and height when the + * real heatmap replaces it. + */ +export function expectedHeatmapWeeks(days: number, today: Date = new Date()): number { + const end = isoDay(today); + const start = parseIsoDay(end); + start.setDate(start.getDate() - (days - 1)); + return heatmapWeeks(isoDay(start), end); +} + +export interface UsageStats { + /** Chats started in range. */ + chats: number; + /** + * Messages in range. A lower bound: the daemon drops a day that has messages + * but no new chat and no billed tokens (`build_activity_window`). + */ + messages: number; + /** Billed tokens in range; a known subtotal when `tokensComplete` is false. */ + tokens: number; + /** False when any active day in range lacks full token records. */ + tokensComplete: boolean; + /** No trustworthy total: the sum is 0 and some day is incomplete. */ + tokensUnavailable: boolean; + /** Days in range with a new chat or billed tokens (the server's definition). */ + activeDays: number; + /** The range's length in days, the denominator of `activeDays`. */ + rangeDays: number; + currentStreak: number; + /** Longest run inside the fetched window only, not all time. */ + longestStreak: number; +} + +export function deriveUsageStats(window: ActivityWindow, range: UsageRange): UsageStats { + const start = rangeStart(window.end, range); + let chats = 0; + let messages = 0; + let tokens = 0; + let activeDays = 0; + let tokensComplete = true; + for (const day of window.days) { + // ISO days compare correctly as strings. + if (day.date < start || day.date > window.end) continue; + chats += day.sessions; + messages += day.messages; + tokens += day.tokens; + activeDays += 1; + if (!day.tokensComplete) tokensComplete = false; + } + return { + chats, + messages, + tokens, + tokensComplete, + tokensUnavailable: !tokensComplete && tokens === 0, + activeDays, + rangeDays: USAGE_RANGE_DAYS[range], + currentStreak: window.currentStreak, + longestStreak: window.longestStreak, + }; +} + +const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); +const FULL = new Intl.NumberFormat('en-US'); + +/** `230,115`. */ +export function formatCount(value: number): string { + return FULL.format(value); +} + +/** `87.9B`, `29.4M`, `128.4K`; the full number below 10,000. */ +export function formatTokensCompact(value: number): string { + if (!Number.isFinite(value) || value < 0) return '0'; + if (value < 10_000) return FULL.format(value); + return COMPACT.format(value); +} + +function days(count: number): string { + return count === 1 ? '1 day' : `${formatCount(count)} days`; +} + +/** + * The footnote under the heatmap: `3 day streak · longest 16 days`, or + * `Longest streak 16 days` when there is no current streak, or nothing at all. + */ +export function streakFootnote(currentStreak: number, longestStreak: number): string { + const longest = Math.max(longestStreak, currentStreak); + if (currentStreak <= 0 && longest <= 0) return ''; + if (currentStreak <= 0) return `Longest streak ${days(longest)}`; + return `${formatCount(currentStreak)} day streak · longest ${days(longest)}`; +} + +/** A report row's tokens: the certified total, else the known subtotal. */ +export function reportRowTokens(row: UsageReportRow): number { + return billedTokens(row) ?? knownBilledTokens(row); +} + +export interface ModelUsage { + /** Stable React key. */ + key: string; + /** Null for `Other` and for the unrecorded row. */ + modelId: string | null; + provider: string | null; + /** True for the `Other` row that folds the named models past the top rows. */ + other: boolean; + /** + * True for the one row holding usage saved without a model name. It is + * always last and carries no share: it says nothing about which models were + * used. + */ + unrecorded: boolean; + tokens: number; + turns: number; + /** + * Fraction of the NAMED models' tokens in range, 0 to 1; null on the + * unrecorded row. + */ + share: number | null; +} + +function hasUsage(row: UsageReportRow): boolean { + return reportRowTokens(row) > 0 || row.turns > 0; +} + +function byUsage(a: UsageReportRow, b: UsageReportRow): number { + return reportRowTokens(b) - reportRowTokens(a) || b.turns - a.turns; +} + +/** + * The model with the most tokens in range (ties: more turns). The unattributed + * bucket wins only when it is the only model there is. + */ +export function favoriteModel(rows: readonly UsageReportRow[]): UsageReportRow | null { + const used = rows.filter(hasUsage).sort(byUsage); + return used.find((row) => row.modelId) ?? used[0] ?? null; +} + +/** At most this many rows in the Models tab. */ +export const MODEL_ROWS_MAX = 4; + +function sumTokens(rows: readonly UsageReportRow[]): number { + return rows.reduce((sum, row) => sum + reportRowTokens(row), 0); +} + +function sumTurns(rows: readonly UsageReportRow[]): number { + return rows.reduce((sum, row) => sum + row.turns, 0); +} + +/** + * The Models tab, which agrees with Favorite model about the top row. + * + * Named models come first, by tokens, and their shares are of the named + * models' tokens only. Usage saved without a model name (the report's + * unattributed bucket, often most of an older history) is folded into ONE + * `unrecorded` row, ranked last whatever its size and given no share: leading + * the list with it, as the tab used to, made Overview and Models disagree + * about the top model and dwarfed every real bar. + * + * At most four rows: when the named models do not fit beside the unrecorded + * row, the top ones stay and one `Other` row holds the rest. + */ +export function modelBreakdown(rows: readonly UsageReportRow[]): ModelUsage[] { + const used = rows.filter(hasUsage); + const named = used.filter((row) => row.modelId).sort(byUsage); + const unrecorded = used.filter((row) => !row.modelId); + const namedTotal = sumTokens(named); + const share = (tokens: number) => (namedTotal > 0 ? tokens / namedTotal : 0); + const toUsage = (row: UsageReportRow): ModelUsage => { + const tokens = reportRowTokens(row); + return { + key: `${row.provider ?? ''}/${row.modelId}`, + modelId: row.modelId ?? null, + provider: row.provider ?? null, + other: false, + unrecorded: false, + tokens, + turns: row.turns, + share: share(tokens), + }; + }; + + const slots = MODEL_ROWS_MAX - (unrecorded.length > 0 ? 1 : 0); + let models: ModelUsage[]; + if (named.length <= slots) { + models = named.map(toUsage); + } else { + const rest = named.slice(slots - 1); + const restTokens = sumTokens(rest); + models = [ + ...named.slice(0, slots - 1).map(toUsage), + { + key: 'other', + modelId: null, + provider: null, + other: true, + unrecorded: false, + tokens: restTokens, + turns: sumTurns(rest), + share: share(restTokens), + }, + ]; + } + + if (unrecorded.length > 0) { + models.push({ + key: 'unrecorded', + modelId: null, + provider: null, + other: false, + unrecorded: true, + tokens: sumTokens(unrecorded), + turns: sumTurns(unrecorded), + share: null, + }); + } + return models; +} + +/** `62%`; `<1%` for a sliver that would otherwise round to nothing. */ +export function formatShare(share: number): string { + if (!(share > 0)) return '0%'; + const percent = Math.round(share * 100); + return percent < 1 ? '<1%' : `${percent}%`; +} diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts index 9972305d9..f747373f9 100644 --- a/ui/desktop/tests/e2e/app.spec.ts +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -162,26 +162,24 @@ test.describe('Biorouter App', () => { await removeExtensionIfPresent(mainWindow, MCP_EXTENSION_NAME); - await mainWindow.getByRole('button', { name: 'Add custom extension' }).first().click(); + // One Add menu in the band (spec 3.11); "Custom extension…" is its third item. + await mainWindow.getByRole('button', { name: 'Add', exact: true }).click(); + await mainWindow.getByRole('menuitem', { name: 'Custom extension…' }).click(); const dialog = mainWindow.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 10_000 }); - await dialog.locator('input[placeholder="Enter extension name..."]').fill(MCP_EXTENSION_NAME); + // Fields are labelled now; the instruction placeholders are gone. + await dialog.getByLabel('Name', { exact: true }).fill(MCP_EXTENSION_NAME); await dialog - .locator('input[placeholder="Optional description..."]') + .getByLabel('Description', { exact: true }) .fill('Inspirational running quotes MCP server'); - await dialog - .locator('input[placeholder="e.g. npx -y @modelcontextprotocol/my-extension "]') - .fill(`node ${MCP_SERVER_SCRIPT}`); + await dialog.getByLabel('Command', { exact: true }).fill(`node ${MCP_SERVER_SCRIPT}`); await mainWindow.screenshot({ path: 'test-results/mcp-filled-form.png' }); await mainWindow.getByTestId('extension-submit-btn').click(); - // The row is found by the toggle's own aria-label rather than by - // `div.flex:has-text(...)`, which matched every ancestor of the text. - const toggle = mainWindow.getByRole('switch', { - name: `Toggle ${MCP_EXTENSION_NAME} extension`, - }); + // The switch is named by its row's title (spec 2.6), the same in both states. + const toggle = mainWindow.getByRole('switch', { name: MCP_EXTENSION_NAME }); await expect(toggle).toBeVisible({ timeout: 60_000 }); await expect(toggle).toHaveAttribute('data-state', 'checked', { timeout: 15_000 }); await mainWindow.screenshot({ path: 'test-results/mcp-extension-added.png' }); @@ -238,18 +236,14 @@ async function waitForTurn(page: Page, budgetMs = LIVE_TURN_TIMEOUT_MS): Promise /** Removes a previously installed extension so the add flow starts from a clean state. */ async function removeExtensionIfPresent(page: Page, name: string): Promise { - const configure = page.getByRole('button', { name: `Configure ${name} extension` }); - if ((await configure.count()) === 0) return; - - await configure.first().click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible({ timeout: 10_000 }); - await dialog.getByRole('button', { name: /^Remove extension$/i }).click(); - - // Confirmation modal. - await page - .getByRole('button', { name: /^(Remove|Delete)$/i }) - .last() - .click(); - await expect(configure).toHaveCount(0, { timeout: 30_000 }); + const row = page.locator(`#extension-${name.toLowerCase().replace(/\s+/g, '-')}`); + if ((await row.count()) === 0) return; + + // The row's ⋯ menu appears on hover or focus; "Remove…" opens the dialog at its question. + await row.hover(); + await row.getByRole('button', { name: 'More actions' }).click(); + await page.getByRole('menuitem', { name: 'Remove…' }).click(); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Confirm removal' }).click(); + await expect(row).toHaveCount(0, { timeout: 30_000 }); } diff --git a/ui/desktop/tests/e2e/bioroffice-install.spec.ts b/ui/desktop/tests/e2e/bioroffice-install.spec.ts index 3030db6b2..abc218639 100644 --- a/ui/desktop/tests/e2e/bioroffice-install.spec.ts +++ b/ui/desktop/tests/e2e/bioroffice-install.spec.ts @@ -128,11 +128,15 @@ test.describe('BiorOffice .brxt — real install + agent usage', () => { await mainWindow.screenshot({ path: 'test-results/bioroffice-1-extensions.png' }); }); - test('2. Open Add extension modal and load bioroffice.brxt', async () => { - await mainWindow.click('button:has-text("Add extension")', { timeout: 5000 }); + test('2. Open the install-from-file dialog and load bioroffice.brxt', async () => { + // The band's one Add menu (spec 3.11); the file route is its second item. + await mainWindow.getByRole('button', { name: 'Add', exact: true }).click({ timeout: 5000 }); + await mainWindow.getByRole('menuitem', { name: 'Install from file (.brxt)' }).click(); await expect(mainWindow.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); // PLAYWRIGHT_BRXT_FILE hook returns our path without a native dialog - await mainWindow.click('button:has-text("Browse file")', { timeout: 5000 }); + await mainWindow + .getByRole('button', { name: /Drop a \.brxt file here/ }) + .click({ timeout: 5000 }); await expect(mainWindow.locator('text=BiorOffice').first()).toBeVisible({ timeout: 20000 }); await mainWindow.screenshot({ path: 'test-results/bioroffice-2-manifest.png' }); }); @@ -237,7 +241,7 @@ test.describe('BiorOffice .brxt — real install + agent usage', () => { created = true; break; } - for (const label of ['Always Allow', 'Allow Once', 'Allow']) { + for (const label of ['Always allow', 'Allow once', 'Allow']) { const allowBtn = mainWindow.locator(`button:has-text("${label}")`).first(); if (await allowBtn.isVisible({ timeout: 200 }).catch(() => false)) { await allowBtn.click().catch(() => {}); diff --git a/ui/desktop/tests/e2e/bioroffice-verify.spec.ts b/ui/desktop/tests/e2e/bioroffice-verify.spec.ts index 51f97bf7f..e9b56442e 100644 --- a/ui/desktop/tests/e2e/bioroffice-verify.spec.ts +++ b/ui/desktop/tests/e2e/bioroffice-verify.spec.ts @@ -148,7 +148,7 @@ test.describe('BiorOffice — dynamic skills + live agent usage', () => { created = true; break; } - for (const label of ['Always Allow', 'Allow Once', 'Allow']) { + for (const label of ['Always allow', 'Allow once', 'Allow']) { const allowBtn = mainWindow.locator(`button:has-text("${label}")`).first(); if (await allowBtn.isVisible({ timeout: 200 }).catch(() => false)) { await allowBtn.click().catch(() => {}); diff --git a/ui/desktop/tests/e2e/brxt-spoke-install.spec.ts b/ui/desktop/tests/e2e/brxt-spoke-install.spec.ts index 7a5919c4a..a5c707b0a 100644 --- a/ui/desktop/tests/e2e/brxt-spoke-install.spec.ts +++ b/ui/desktop/tests/e2e/brxt-spoke-install.spec.ts @@ -129,23 +129,28 @@ test.describe('SPOKEAgent .brxt install flow', () => { console.log('✓ Extensions tab'); }); - test('2. Click "Add extension" — modal opens', async () => { - await mainWindow.click('button:has-text("Add extension")', { timeout: 5000 }); + test('2. Add, then "Install from file (.brxt)" opens the modal', async () => { + // The Extensions band has one "Add" menu (Browse marketplace, Install from file, Custom extension…). + await mainWindow.getByRole('button', { name: 'Add', exact: true }).click({ timeout: 5000 }); + await mainWindow.getByRole('menuitem', { name: 'Install from file (.brxt)' }).click(); // Verify the modal dialog is open (avoid strict-mode by targeting the dialog role) await expect(mainWindow.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); await mainWindow.screenshot({ path: 'test-results/brxt-spoke-2-modal-open.png' }); console.log('✓ Modal opened'); }); - test('3. Click "Browse file…" — PLAYWRIGHT_BRXT_FILE hook returns SPOKEAgent .brxt', async () => { + test('3. Click the drop zone: PLAYWRIGHT_BRXT_FILE hook returns SPOKEAgent .brxt', async () => { // The PLAYWRIGHT_BRXT_FILE env var makes the main-process IPC handler return - // the preset path without opening a native dialog. - await mainWindow.click('button:has-text("Browse file")', { timeout: 5000 }); + // the preset path without opening a native dialog. The one-line drop zone is + // itself the file control (it calls openBrxtFilePicker()). + await mainWindow + .getByRole('button', { name: /Drop a \.brxt file here/ }) + .click({ timeout: 5000 }); // Validation runs; manifest preview card should appear await expect(mainWindow.locator('text=SPOKEAgent').first()).toBeVisible({ timeout: 20000 }); await mainWindow.screenshot({ path: 'test-results/brxt-spoke-3-manifest-loaded.png' }); - console.log('✓ Manifest preview loaded — Browse file button works correctly'); + console.log('✓ Manifest preview loaded: the drop zone opens the file picker'); }); test('4. Manifest info card shows name, version, description', async () => { @@ -163,15 +168,19 @@ test.describe('SPOKEAgent .brxt install flow', () => { await expect(mainWindow.locator('text=Configure SPOKEAgent')).toBeVisible({ timeout: 5000 }); await expect(mainWindow.locator('text=SPOKEAGENT_PASSCODE')).toBeVisible(); const installBtn = mainWindow.locator('button:has-text("Install Extension")'); - await expect(installBtn).toBeDisabled(); + // Pressable (Crew's dialogs); with the passcode empty, a press says which value is missing. + await expect(installBtn).toBeEnabled(); + await installBtn.click(); + await expect(mainWindow.locator('[role="dialog"]').getByText('Enter a value')).toBeVisible(); await mainWindow.screenshot({ path: 'test-results/brxt-spoke-5-env-form.png' }); - console.log('✓ Configure step shown, Install disabled'); + console.log('✓ Configure step shown, Install says what is missing'); }); test('6. Enter passcode → Install enabled', async () => { await mainWindow.fill('input[type="password"]', SPOKE_PASSCODE); const installBtn = mainWindow.locator('button:has-text("Install Extension")'); await expect(installBtn).toBeEnabled({ timeout: 3000 }); + await expect(mainWindow.locator('[role="dialog"]').getByText('Enter a value')).toHaveCount(0); await mainWindow.screenshot({ path: 'test-results/brxt-spoke-6-passcode-entered.png' }); console.log('✓ Passcode entered, Install enabled'); }); diff --git a/ui/desktop/tests/e2e/brxt.spec.ts b/ui/desktop/tests/e2e/brxt.spec.ts index 3689f243a..87789f254 100644 --- a/ui/desktop/tests/e2e/brxt.spec.ts +++ b/ui/desktop/tests/e2e/brxt.spec.ts @@ -1,7 +1,7 @@ /** * E2E tests for the .brxt extension bundle feature. * - * Tests 13-15 and 22: Button layout, drag-and-drop modal, invalid bundle error, + * Tests 13-15 and 22: The page's Add menu, drag-and-drop modal, invalid bundle error, * env var form, and no-env-var bundle skip. * * Notes: @@ -149,14 +149,14 @@ test.describe('BrxtInstallModal — .brxt extension bundle feature', () => { } // ------------------------------------------------------------------------- - // Helper: open the BrxtInstallModal via "Add extension" button + // Helper: open the BrxtInstallModal from the band's Add menu ("Install from file") // ------------------------------------------------------------------------- async function openBrxtModal(): Promise { - const addBtn = await page.waitForSelector('button:has-text("Add extension")', { - timeout: 10000, - state: 'visible', - }); + // The three labelled buttons became one "Add" menu in the 44px band (spec 3.11). + const addBtn = page.getByRole('button', { name: 'Add', exact: true }); + await addBtn.waitFor({ timeout: 10000, state: 'visible' }); await addBtn.click(); + await page.getByRole('menuitem', { name: 'Install from file (.brxt)' }).click(); // Wait for the dialog to appear await page.waitForSelector('[role="dialog"]', { timeout: 5000, state: 'visible' }); await page.waitForTimeout(300); @@ -176,37 +176,31 @@ test.describe('BrxtInstallModal — .brxt extension bundle feature', () => { } // --------------------------------------------------------------------------- - // Test 1: Button layout in the Extensions tab header + // Test 1: The band's one Add menu — Browse marketplace, Install from file, Custom extension // --------------------------------------------------------------------------- - test('Extensions tab has correct button layout — Add extension, Browse extensions, Add custom extension', async () => { + test('Extensions tab has one Add menu — Browse marketplace, Install from file, Custom extension', async () => { await goToExtensions(); - // Verify all three buttons are visible (UI copy is sentence case) - const addExtBtn = page.locator('button:has-text("Add extension")').first(); - const browseBtn = page.locator('button:has-text("Browse extensions")').first(); - const addCustomBtn = page.locator('button:has-text("Add custom extension")').first(); - - await expect(addExtBtn).toBeVisible(); - await expect(browseBtn).toBeVisible(); - await expect(addCustomBtn).toBeVisible(); - - // Verify DOM order in the header button row. The strip is - // `PageHeader`'s `.biorouter-settings-control-strip`; the `.flex.gap-3.mt-5` - // this used to name is the hand-rolled row that component replaced, and - // `styles/measures.test.ts` now bans it at the source. - const buttonTexts: string[] = await page.$$eval( - '.biorouter-settings-control-strip button', - (btns: Element[]) => btns.map((b) => b.textContent?.trim() ?? '') - ); - const addIdx = buttonTexts.findIndex((t) => t.includes('Add extension')); - const browseIdx = buttonTexts.findIndex((t) => t.includes('Browse extensions')); - const customIdx = buttonTexts.findIndex((t) => t.includes('Add custom extension')); + // The three labelled buttons are gone; their jobs are the menu's items (UI copy is sentence case). + await expect(page.locator('button:has-text("Browse extensions")')).toHaveCount(0); + await expect(page.locator('button:has-text("Add custom extension")')).toHaveCount(0); - expect(addIdx).toBeGreaterThanOrEqual(0); - expect(browseIdx).toBeGreaterThan(addIdx); - expect(customIdx).toBeGreaterThan(browseIdx); + // The band's action cluster (`PageHeader`'s `.biorouter-page-header-actions`, spec 3.10) + // holds the filter and the Add button. + const cluster = page.locator('.biorouter-page-header-actions'); + await expect(cluster.getByRole('searchbox', { name: 'Filter extensions' })).toBeVisible(); + const addBtn = cluster.getByRole('button', { name: 'Add', exact: true }); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + + // Verify the menu's order. + const items: string[] = await page + .getByRole('menuitem') + .evaluateAll((els) => els.map((el) => el.textContent?.trim() ?? '')); + expect(items).toEqual(['Browse marketplace', 'Install from file (.brxt)', 'Custom extension…']); - await page.screenshot({ path: 'test-results/brxt-button-layout.png' }); + await page.screenshot({ path: 'test-results/brxt-add-menu.png' }); + await page.keyboard.press('Escape'); }); // --------------------------------------------------------------------------- @@ -235,7 +229,7 @@ test.describe('BrxtInstallModal — .brxt extension bundle feature', () => { await expect(dialog.getByText('Test Extension', { exact: true })).toBeVisible(); await expect(dialog.locator('text=1.0.0')).toBeVisible(); - // The modal title should still say "Add extension" (step === 'drop') + // The modal title should still say "Add extension" (step === 'select') await expect(page.locator('[role="dialog"] [data-slot="dialog-title"]')).toHaveText( /Add extension/i ); @@ -316,9 +310,14 @@ test.describe('BrxtInstallModal — .brxt extension bundle feature', () => { await expect(requiredLabel).toBeVisible(); await expect(requiredLabel).toContainText('*'); - // "Install Extension" button should be disabled because required field is empty + // "Install extension" stays pressable (Crew's dialogs); with the required field empty, a press + // says which value is missing, under the field, instead of installing. const installBtn = page.getByRole('button', { name: /Install extension/i }); - await expect(installBtn).toBeDisabled({ timeout: 3000 }); + await expect(installBtn).toBeEnabled({ timeout: 3000 }); + await installBtn.click(); + await expect(page.locator('[role="dialog"]').getByText('Enter a value')).toBeVisible({ + timeout: 3000, + }); // The optional var (OPTIONAL_KEY) should have a "Show N optional variables" toggle const showOptionalToggle = page diff --git a/ui/desktop/tests/e2e/knowledge-ingest.spec.ts b/ui/desktop/tests/e2e/knowledge-ingest.spec.ts index d2d8bb218..32418579d 100644 --- a/ui/desktop/tests/e2e/knowledge-ingest.spec.ts +++ b/ui/desktop/tests/e2e/knowledge-ingest.spec.ts @@ -85,9 +85,9 @@ test.describe('Knowledge ingest workflow', () => { } await expect(page.getByTestId('knowledge-graph-canvas')).toBeVisible(); - await expect( - page.getByText('No pages yet. Ingest a source to populate the graph.') - ).toHaveCount(0); + // The graph's empty state, which must be gone once a page exists. It used to + // name a string the view no longer renders, so the check passed vacuously. + await expect(page.getByText('Nothing digested yet')).toHaveCount(0); }); test('stages folders and archives through the desktop flow and still forms the graph', async () => { @@ -192,10 +192,7 @@ function writeFixtures(baseDir: string): string { fs.writeFileSync( path.join(baseDir, 'sample.pdf'), fs.readFileSync( - path.join( - __dirname, - '../../../../crates/biorouter-mcp/src/webdocuments/tests/data/test.pdf' - ) + path.join(__dirname, '../../../../crates/biorouter-mcp/src/webdocuments/tests/data/test.pdf') ) ); fs.writeFileSync( diff --git a/ui/desktop/tests/e2e/spokeagent-skills.spec.ts b/ui/desktop/tests/e2e/spokeagent-skills.spec.ts index 09c5a3bd4..564015117 100644 --- a/ui/desktop/tests/e2e/spokeagent-skills.spec.ts +++ b/ui/desktop/tests/e2e/spokeagent-skills.spec.ts @@ -69,9 +69,10 @@ test.describe('SPOKEAgent .brxt — skills integration & propagation', () => { } async function openBrxtModal(): Promise { - const addBtn = page.locator('button:has-text("Add extension")'); + const addBtn = page.getByRole('button', { name: 'Add', exact: true }); await addBtn.waitFor({ timeout: 10000, state: 'visible' }); await addBtn.click(); + await page.getByRole('menuitem', { name: 'Install from file (.brxt)' }).click(); await page.waitForSelector('[role="dialog"]', { timeout: 5000, state: 'visible' }); await page.waitForTimeout(300); } @@ -134,8 +135,10 @@ test.describe('SPOKEAgent .brxt — skills integration & propagation', () => { await goToSkills(); await page.screenshot({ path: 'test-results/spoke-03-skills-settings.png' }); - // "Add skill" button always present - await expect(page.locator('button:has-text("Add skill")')).toBeVisible({ timeout: 5000 }); + // The band's one "Add" menu is always present + await expect(page.getByRole('button', { name: 'Add', exact: true })).toBeVisible({ + timeout: 5000, + }); // Count any switch elements — each skill row should have one const switches = page.locator('button[role="switch"]'); @@ -190,8 +193,8 @@ test.describe('SPOKEAgent .brxt — skills integration & propagation', () => { await page.screenshot({ path: 'test-results/spoke-05-chat-view.png' }); - // Find the skills button in the chat bottom bar - const skillsBtn = page.getByRole('button', { name: /^Manage skills \(\d+ enabled\)$/ }); + // Find the composer's Tools chip (extensions, skills and knowledge in one popover) + const skillsBtn = page.getByTestId('composer-tools-chip'); const skillsBtnVisible = await skillsBtn.isVisible().catch(() => false); console.log(`Chat skills button visible: ${skillsBtnVisible}`); @@ -200,8 +203,9 @@ test.describe('SPOKEAgent .brxt — skills integration & propagation', () => { await page.waitForTimeout(500); await page.screenshot({ path: 'test-results/spoke-06-skills-dropdown-open.png' }); - // The dropdown should open with a search input - const searchInput = page.getByPlaceholder('Search skills...'); + // Switch the popover to its Skills list, which has a search field + await page.getByRole('radio', { name: 'Skills' }).click(); + const searchInput = page.getByPlaceholder('Search skills…'); await expect(searchInput).toBeVisible({ timeout: 5000 }); // Search for the ralph project skill @@ -280,8 +284,8 @@ test.describe('SPOKEAgent .brxt — skills integration & propagation', () => { await page.screenshot({ path: 'test-results/spoke-11-settings-count.png' }); - // Get count from chat box skills button - const skillsBtn = page.getByRole('button', { name: /^Manage skills \(\d+ enabled\)$/ }); + // Get the count from the composer's Tools chip ("Tools N", N = extensions + skills + knowledge bases) + const skillsBtn = page.getByTestId('composer-tools-chip'); const skillsBtnVisible = await skillsBtn.isVisible().catch(() => false); if (skillsBtnVisible) { diff --git a/ui/desktop/tests/e2e/user-message-layout.spec.ts b/ui/desktop/tests/e2e/user-message-layout.spec.ts index 8b6728768..d189a7098 100644 --- a/ui/desktop/tests/e2e/user-message-layout.spec.ts +++ b/ui/desktop/tests/e2e/user-message-layout.spec.ts @@ -238,6 +238,9 @@ test('clamp, resource, attachment, and intentionally wide edit states remain int const edit = page.locator('[data-case="edit"]'); const normalWidth = (await geometry(page, 'edit')).bubble.width; + // The message's actions float over the bubble and appear on hover (Crew's + // row actions), so the pointer reaches the message before its Edit. + await edit.getByTestId('user-message-bubble').hover(); await edit.getByRole('button', { name: /edit message:/i }).click(); await expect(edit.getByRole('textbox', { name: 'Edit message content' })).toBeVisible(); await expect(edit.getByTestId('user-message-bubble')).toHaveCount(0); diff --git a/ui/desktop/themes/alma-mater.theme.mjs b/ui/desktop/themes/alma-mater.theme.mjs index 85d77c374..38cd29b31 100644 --- a/ui/desktop/themes/alma-mater.theme.mjs +++ b/ui/desktop/themes/alma-mater.theme.mjs @@ -85,7 +85,11 @@ export default { 'heat-4': '#0e5258', sidebar: '#f7f7f5', 'sidebar-foreground': '#052049', - 'sidebar-icon': '#14828c', + // Nav icons wear the row's resting ink, the muted label ink, as in + // Parchment and Roche Limit: one ink with the labels and the chat glyphs + // below (V1-R2-5). The accent is held for the active rail and the lock + // badge (redesign principle 7); teal icons were accent outside it (V1-M3). + 'sidebar-icon': '#506380', 'sidebar-hover': '#efefec', 'sidebar-active': '#eaeae6', 'sidebar-accent': '#efefec', @@ -256,14 +260,14 @@ export default { ring: '#a5a39d', 'background-focus': '#35342f', 'border-focus': '#9c9a93', - 'heat-0': '#1e1d1b', + 'heat-0': '#32312e', // one step up from #1e1d1b: zero days read on the dark card (redesign 2.3) 'heat-1': '#0e5258', 'heat-2': '#14828c', 'heat-3': '#16a0ac', 'heat-4': '#60d0da', sidebar: '#171716', 'sidebar-foreground': '#f2f3f4', - 'sidebar-icon': '#60d0da', + 'sidebar-icon': '#b4b9bf', // the muted label ink, as in light 'sidebar-hover': '#232320', 'sidebar-active': '#2e2e2a', 'sidebar-accent': '#232320', diff --git a/ui/desktop/themes/parchment.theme.mjs b/ui/desktop/themes/parchment.theme.mjs index 0ac8de2a5..0524ef9f5 100644 --- a/ui/desktop/themes/parchment.theme.mjs +++ b/ui/desktop/themes/parchment.theme.mjs @@ -91,7 +91,9 @@ export default { 'heat-4': '#a04a27', sidebar: '#f7f7f5', 'sidebar-foreground': '#2a2520', - 'sidebar-icon': '#2a2520', + // Nav icons wear the row's resting ink, the muted label ink, so the icon + // column is one ink with the labels and the chat glyphs below (V1-R2-5). + 'sidebar-icon': '#635c54', 'sidebar-hover': '#efefec', 'sidebar-active': '#eaeae6', 'sidebar-accent': '#efefec', @@ -228,14 +230,14 @@ export default { ring: '#a5a39d', 'background-focus': '#35342f', 'border-focus': '#9c9a93', - 'heat-0': '#1e1d1b', - 'heat-1': '#4a3524', + 'heat-0': '#32312e', // one step up from #1e1d1b: zero days read on the dark card (redesign 2.3) + 'heat-1': '#573e2a', // was #4a3524: 1.13:1 against the lifted heat-0 (was 1.46 against #1e1d1b); a step above it again 'heat-2': '#7a4d2e', 'heat-3': '#b0653a', 'heat-4': '#e8895f', sidebar: '#171716', 'sidebar-foreground': '#f4f0e6', - 'sidebar-icon': '#f4f0e6', + 'sidebar-icon': '#b0a892', // the muted label ink, as in light 'sidebar-hover': '#232320', 'sidebar-active': '#2e2e2a', 'sidebar-accent': '#232320', diff --git a/ui/desktop/themes/roche-limit.theme.mjs b/ui/desktop/themes/roche-limit.theme.mjs index 704bd45e8..382809e68 100644 --- a/ui/desktop/themes/roche-limit.theme.mjs +++ b/ui/desktop/themes/roche-limit.theme.mjs @@ -84,7 +84,11 @@ export default { 'heat-4': '#c4560e', sidebar: '#f7f7f5', 'sidebar-foreground': '#1f1e1c', - 'sidebar-icon': '#d95b08', + // Nav icons wear the row's resting ink, the muted label ink, as in + // Parchment and Alma Mater: one ink with the labels and the chat glyphs + // below (V1-R2-5). The accent is held for the active rail and the lock + // badge (redesign principle 7); orange icons were accent outside it (V1-M3). + 'sidebar-icon': '#5c5a55', 'sidebar-hover': '#efefec', 'sidebar-active': '#eaeae6', 'sidebar-accent': '#efefec', @@ -241,14 +245,14 @@ export default { ring: '#a5a39d', 'background-focus': '#35342f', 'border-focus': '#9c9a93', - 'heat-0': '#1e1d1b', - 'heat-1': '#4a2a0e', + 'heat-0': '#32312e', // one step up from #1e1d1b: zero days read on the dark card (redesign 2.3) + 'heat-1': '#5c3410', // was #4a2a0e: 1.01:1 against the lifted heat-0; a step above it again 'heat-2': '#7a4413', 'heat-3': '#b45f18', 'heat-4': '#ee6c1a', sidebar: '#171716', 'sidebar-foreground': '#ededea', - 'sidebar-icon': '#ee6c1a', + 'sidebar-icon': '#a5a39d', // the muted label ink, as in light 'sidebar-hover': '#232320', 'sidebar-active': '#2e2e2a', 'sidebar-accent': '#232320', diff --git a/ui/desktop/vite-plugins/fontScale.mjs b/ui/desktop/vite-plugins/fontScale.mjs index 3f7010300..36a60898e 100644 --- a/ui/desktop/vite-plugins/fontScale.mjs +++ b/ui/desktop/vite-plugins/fontScale.mjs @@ -13,6 +13,7 @@ const controlTokens = new Set([ '--control-compact', '--row-height', '--row-height-rail', + '--row-height-nav', '--md-code-pad', ]); const spacingProperty =